diff --git a/.changeset/7007-relatedlist-schema-chain-gate.md b/.changeset/7007-relatedlist-schema-chain-gate.md
new file mode 100644
index 0000000000..a19c1bed1f
--- /dev/null
+++ b/.changeset/7007-relatedlist-schema-chain-gate.md
@@ -0,0 +1,31 @@
+---
+---
+
+Test-only: `RelatedList.unmaterializedSort.test.tsx` now gates the `getObjectSchema` chain
+as well as the fetch chain. No published behaviour changes; no runtime code was touched.
+
+Both `#3950` refusal blocks settled only the FETCH chain — `columnSortable('name')` for the
+column headers, `h.schema?.type === 'data-list'` for the sort-button row — and then read a
+value that arrives on a DIFFERENT chain: `withheldFromServerSort` derives its verdict from
+`objectSchema.fields[field]`, fetched by the effect at `RelatedList.tsx:430`. With no schema
+in state yet the predicate falls through to `isUnmaterializedFieldType(undefined)`, which is
+`false` — "offer the sort" — so `Total` is still sortable and still has a button. The blocks
+were green only because that effect is declared before the fetch effect and both mocks
+resolve on the same microtask; nothing pinned that ordering, and a flaky RED in the merge
+queue ejects unrelated pull requests.
+
+Measured rather than assumed: with the timing FACT mutated and nothing else — the mock
+schema resolving after a 50ms delay instead of on the first microtask — the old spelling
+fails at `:123` (`expected true to be false`) and at `:153` (`expected
+to be null`), with the stored-column controls present in both blocks, reproducing the race.
+The new spelling passes under the same delay.
+
+Each block KEEPS its existing gate and ADDS the second one (the objectui#6959 precedent and
+its trap): the first gate proves the view committed with rows at all, which is what keeps
+`queryBy…).toBeNull()` from passing vacuously; the second proves the schema the verdict is
+derived from has landed. Trading one for the other would have left the block blind on
+whichever side it dropped.
+
+The gate is not `expect(getObjectSchema).toHaveBeenCalled()` — a mock CALL is issued one
+resolution before its value reaches state, which is the trap #6959 recorded. It awaits the
+promise the component itself awaited and flushes the commit through `act`.
diff --git a/packages/plugin-detail/src/__tests__/RelatedList.unmaterializedSort.test.tsx b/packages/plugin-detail/src/__tests__/RelatedList.unmaterializedSort.test.tsx
index fa8ca33061..bfed343a3c 100644
--- a/packages/plugin-detail/src/__tests__/RelatedList.unmaterializedSort.test.tsx
+++ b/packages/plugin-detail/src/__tests__/RelatedList.unmaterializedSort.test.tsx
@@ -111,15 +111,56 @@ function renderList(props?: Record) {
return dataSource;
}
+/**
+ * Gate 2 — the `getObjectSchema` chain (objectui#7007).
+ *
+ * Every verdict this file measures is a function of the FETCHED object schema:
+ * `withheldFromServerSort` reads `objectSchema.fields[field]`, and with nothing
+ * there it falls through to `isUnmaterializedFieldType(undefined)` — `false`,
+ * i.e. "offer the sort". So a block that has only settled the FETCH chain is
+ * reading the value one promise resolution too early: the row/table is
+ * committed and populated, and the schema-dependent half of it is not.
+ *
+ * Measured, not assumed: with the timing fact mutated and nothing else — the
+ * mock schema resolving after a 50ms delay instead of on the first microtask —
+ * the block's own gate passes, `Quantity`/`Unit Price`/`Sequence` are present,
+ * and `Total` is present too, so the assertions below go red. It is green on
+ * `main` only because that effect is declared before the fetch effect and both
+ * mocks resolve on the same microtask; nothing pins that ordering.
+ *
+ * ⚠️ The blocks KEEP their existing gate (objectui#6959's precedent, and its
+ * trap). The two gates prove different facts — the first that the view
+ * committed with rows at all, which is what makes a `queryBy…(…)).toBeNull()`
+ * below non-vacuous; the second that the schema the verdict is derived from has
+ * landed. Trading one for the other would leave the block blind on whichever
+ * side it dropped.
+ *
+ * ⛔ `expect(getObjectSchema).toHaveBeenCalled()` is NOT this gate, for the
+ * reason #6959 records: a mock CALL is ISSUED one resolution before its value
+ * reaches state. It is used here only to get hold of the promise; the gate is
+ * awaiting that same promise — the component registered its
+ * `.then(setObjectSchema)` on it first, so our continuation runs after that
+ * setter — and flushing the commit through `act`.
+ */
+async function settleObjectSchema(ds: ReturnType) {
+ await waitFor(() => expect(ds.getObjectSchema).toHaveBeenCalled());
+ await React.act(async () => {
+ await Promise.all(ds.getObjectSchema.mock.results.map((r) => r.value));
+ });
+}
+
beforeEach(() => {
h.schema = null;
});
describe('RelatedList — column headers (#3950)', () => {
it('withholds the header from a formula column while windowed', async () => {
- renderList();
+ const dataSource = renderList();
// A stored column stays sortable — which also proves the table rendered.
await waitFor(() => expect(columnSortable('name')).toBe(true));
+ // …and `total`'s verdict rides the schema chain, which that gate does not
+ // cover (#7007). `name` is sortable either way, so it cannot stand in.
+ await settleObjectSchema(dataSource);
expect(columnSortable('total')).toBe(false);
});
@@ -142,8 +183,11 @@ describe('RelatedList — column headers (#3950)', () => {
describe('RelatedList — the sort-button row for a `list` card (#3950)', () => {
it('offers no button for a formula field while windowed', async () => {
- renderList({ type: 'list' });
+ const dataSource = renderList({ type: 'list' });
await waitFor(() => expect(h.schema?.type).toBe('data-list'));
+ // Second gate, second chain: `Total`'s absence is derived from the fetched
+ // schema, which the gate above does not settle (#7007).
+ await settleObjectSchema(dataSource);
// Controls first: the row exists and carries the stored columns.
expect(screen.getByRole('button', { name: /Quantity/ })).toBeTruthy();