diff --git a/.changeset/gantt-envelope-wait-anchor-8665.md b/.changeset/gantt-envelope-wait-anchor-8665.md
new file mode 100644
index 0000000000..124f64aa92
--- /dev/null
+++ b/.changeset/gantt-envelope-wait-anchor-8665.md
@@ -0,0 +1,4 @@
+---
+---
+
+Test-only change: the two `contractEnvelope-6839` refusal arms in `@object-ui/plugin-gantt` now anchor on a completion signal instead of reading straight after a query was issued. No published behaviour changes.
diff --git a/packages/plugin-gantt/src/ObjectGantt.contractEnvelope-6839.test.tsx b/packages/plugin-gantt/src/ObjectGantt.contractEnvelope-6839.test.tsx
index 44c7ae49bd..6b2bc9382a 100644
--- a/packages/plugin-gantt/src/ObjectGantt.contractEnvelope-6839.test.tsx
+++ b/packages/plugin-gantt/src/ObjectGantt.contractEnvelope-6839.test.tsx
@@ -37,6 +37,26 @@
* `data` and bare-array cases refuse it: they push the SAME rows through the
* SAME mounts, on BOTH sinks.
*
+ * ## The waits were repaired in objectui#8665 — what was measured
+ *
+ * Both refusal arms below used to read WITHOUT a completion anchor: sink 1 read
+ * the bars straight after a wait on `find` merely having been CALLED, and sink
+ * 2 asserted an ABSENCE with nothing proving the domain query had answered.
+ * Probed on this component with the two queries answering at different times:
+ *
+ * - sink 2, LIVE `data` envelope, domain answering one macrotask later:
+ * `offersP3=false, offered=[p1]` where the old shape read it, and
+ * `offered=[p1,p2,p3]` once the same mount settled. ⇒ the refusal
+ * assertion PASSED against a component that resolved the whole domain.
+ * The arm was measuring the polling tick, not the envelope.
+ * - sink 1, same profile: the DOM at the old wait was the `Loading Gantt
+ * chart…` placeholder and the read threw `Unable to find an element by:
+ * [data-testid="gantt-view"]` — a failure that is not about the envelope.
+ *
+ * Both now anchor on something that appears only once the relevant query has
+ * settled, and both anchors are PRESENCE assertions, so neither arm can pass
+ * by timing out. See {@link mount} and {@link domainSettled}.
+ *
* MODULE RESOLUTION: this file imports the component by relative source path
* and `@object-ui/core` is aliased by the root `vitest.config.mts` to
* `packages/core/src`, so both legs resolve to SOURCE — no package `exports`
@@ -51,9 +71,19 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
* `GanttView` is mocked to a thin shell surfacing the task count, exactly as
* `ObjectGantt.quickfilter.test.tsx` and `ObjectGantt.referenceArms-6837
* .test.tsx` do — how the chart paints bars is not what this file observes.
+ *
+ * It also surfaces the drawn TITLES. A count alone is satisfied by any two
+ * rows, so the positive arms below assert which two arrived; the count on its
+ * own could not tell `TASKS` from some other pair the unwrap ladder invented.
*/
vi.mock('./GanttView', () => ({
- GanttView: ({ tasks }: any) =>
,
+ GanttView: ({ tasks }: any) => (
+ t.title).join('|')}
+ />
+ ),
}));
import { ObjectGantt } from './ObjectGantt';
@@ -73,6 +103,19 @@ const PROJECTS = [
{ id: 'p3', name: 'Cygnus' },
];
+/**
+ * The CONTROL dimension's domain — the completion anchor sink 2 is read
+ * against. See {@link domainSettled}.
+ *
+ * No loaded task carries an `owner`, so the distinct-values fallback offers
+ * NOTHING for this dimension: an `owner` option in the dropdown can only have
+ * come from the domain fetch having been read and committed.
+ */
+const PEOPLE = [
+ { id: 'u1', name: 'Ada' },
+ { id: 'u2', name: 'Grace' },
+];
+
/** How one case wraps its rows on the way back out of `find()`. */
type Envelope = (rows: unknown[]) => unknown;
@@ -86,27 +129,54 @@ const GANTT_SCHEMA = {
startDateField: 'start',
endDateField: 'end',
titleField: 'name',
- quickFilters: [{ field: 'project', label: 'Project' }],
+ quickFilters: [
+ { field: 'project', label: 'Project' },
+ // The control dimension — resolved by the SAME `setLookupOptions` commit
+ // as `project`, and answered OUTSIDE the envelope under test.
+ { field: 'owner', label: 'Owner' },
+ ],
} as any;
/**
- * Mount over a `find()` that wraps BOTH answers in `envelope`, and wait for the
- * schema-dependent commit.
+ * Mount over a `find()` that wraps BOTH answers in `envelope`, wait for the
+ * schema-dependent query, and then wait for the chart to have FINISHED
+ * loading.
+ *
+ * ## The two waits do different jobs — objectui#8665
+ *
+ * The first is envelope-INDEPENDENT evidence that the schema was consumed:
+ * once `objectSchema` lands, the record query is re-issued carrying `$expand`,
+ * and `buildExpandFields` decides that from the field's `type` alone. So the
+ * recorded `find('task', {$expand})` proves the component consumed the schema
+ * for a REFUSED envelope exactly as it does for a live one.
+ *
+ * ⛔ But that call being RECORDED is not the query having ANSWERED, and this
+ * file used to read the bars straight off it. Probed on this component
+ * (objectui#8665): with the row query answering one macrotask later, the DOM at
+ * the moment that wait passed was the `Loading Gantt chart…` placeholder, and
+ * the read threw `Unable to find an element by: [data-testid="gantt-view"]` —
+ * the pin failing for a reason that is not about the envelope at all.
*
- * The settle signal is envelope-INDEPENDENT: once `objectSchema` lands, the
- * record query is re-issued carrying `$expand`, and `buildExpandFields` decides
- * that from the field's `type` alone. So the recorded `find('task', {$expand})`
- * proves the component consumed the schema for a REFUSED envelope exactly as it
- * does for a live one — and the option-fetch effect shares that commit, so a
- * resolving arm has already recorded `find('projects', …)` by then.
+ * ⭐ So the second wait is the COMPLETION ANCHOR, and it is keyed to the
+ * component's own mechanism: `gantt-view` renders BELOW `ObjectGantt`'s
+ * `if (loading)` early return, and `loading` is cleared in `reload`'s
+ * `finally` — in the same commit as `setData(capped.rows)`, and only by the
+ * newest reload (both are guarded by `isCurrent()`). So this node's presence is
+ * proof that the row query settled AND committed. It is a PRESENCE assertion,
+ * so it cannot pass by timing out, which is the failure mode every
+ * absence-shaped pin has.
*
* ⛔ Call ONCE per case, never inside a `waitFor` predicate (objectui#7802).
*/
async function mount(envelope: Envelope) {
const ds: any = {
- find: vi.fn(async (object: string) =>
- object === 'projects' ? envelope(PROJECTS) : envelope(TASKS),
- ),
+ find: vi.fn(async (object: string) => {
+ // The control domain is answered OUTSIDE the envelope under test, as a
+ // bare array, so {@link domainSettled} anchors on the same DOM whichever
+ // envelope this case is exercising.
+ if (object === 'people') return PEOPLE;
+ return object === 'projects' ? envelope(PROJECTS) : envelope(TASKS);
+ }),
findOne: vi.fn(),
create: vi.fn(),
update: vi.fn(),
@@ -118,6 +188,7 @@ async function mount(envelope: Envelope) {
start: { type: 'date' },
end: { type: 'date' },
project: { type: 'lookup', reference: 'projects' },
+ owner: { type: 'lookup', reference: 'people' },
},
}),
};
@@ -127,18 +198,60 @@ async function mount(envelope: Envelope) {
ds.find.mock.calls.some((c: any[]) => c[0] === 'task' && c[1]?.$expand?.includes('project')),
).toBe(true),
);
- return { ds, view };
+ await view.findByTestId('gantt-view');
+ // Carried as its own observation so "the chart finished loading" and "the
+ // chart drew no bars" can never stand in for one another.
+ const drewChart = view.queryByTestId('gantt-view') !== null;
+ return { ds, view, drewChart };
}
/** Bars the chart drew — sink 1. */
const bars = (view: any) => Number(view.getByTestId('gantt-view').getAttribute('data-count'));
+/** The titles the chart drew — sink 1, as a shape rather than a count. */
+const barTitles = (view: any) =>
+ view.getByTestId('gantt-view').getAttribute('data-titles') as string;
+
+/**
+ * ⭐ THE COMPLETION ANCHOR FOR SINK 2 (objectui#8665).
+ *
+ * Sink 2's refusal case is an ABSENCE — "`p3` is not offered" — and an absence
+ * is satisfied by a component that has not answered yet. Probed on this
+ * component: with the domain query answering one macrotask after the rows, the
+ * LIVE `data` envelope produced `offersP3=false, offered=[p1]` at the moment
+ * the old shape read it, while the very same mount settled to
+ * `offered=[p1,p2,p3]`. ⇒ the refusal assertion passed against a component
+ * that demonstrably DID resolve the domain. Nothing about the envelope was
+ * being measured; the polling tick was.
+ *
+ * `ObjectGantt` publishes no DOM marker for that fetch on the REFUSING branch —
+ * a refused domain leaves `lookupOptions[field]` an empty array, the resolver
+ * falls through to "distinct values present in the loaded data", and the
+ * dropdown looks exactly as it does before the fetch answers. So the anchor is
+ * a CONTROL DIMENSION instead: `owner` is a second lookup resolved by the same
+ * effect, whose `setLookupOptions` call sits AFTER the loop over every
+ * dimension — one commit for all of them. No loaded task carries an `owner`,
+ * so the fallback offers nothing there and `u1` can only come from the domain
+ * fetch having been read and committed.
+ *
+ * ⇒ waiting for `u1` proves the `projects` answer was consumed in that same
+ * commit, and it is a PRESENCE assertion, so this arm cannot pass by timing
+ * out. It also reddens under "resolve nothing, ever" — the implementation
+ * strictly worse than the bug, which the old absence-only shape passed.
+ */
+async function domainSettled(view: any): Promise {
+ fireEvent.click(view.getByTestId('quick-filter-trigger-owner'));
+ await view.findByTestId('quick-filter-option-owner-u1');
+}
+
/**
* Is `p3` — a project NO loaded row points at — offered in the dropdown?
*
* This is the only observation that separates sink 2 from sink 1: the fallback
* arm ("distinct values present in the loaded data") would still offer `p1`
* whether or not the domain fetch was read.
+ *
+ * ⛔ Read it only AFTER {@link domainSettled}.
*/
async function offersUnloadedProject(view: any): Promise {
fireEvent.click(view.getByTestId('quick-filter-trigger-project'));
@@ -150,28 +263,39 @@ describe('ObjectGantt — the find() envelopes it reads (objectui#6839)', () =>
describe('sink 1 — the rows, via applyNonGridRowCeiling', () => {
it("still reads the contract's `data` member", async () => {
const { view } = await mount(asData);
- await waitFor(() => expect(bars(view), 'the declared rows member must still draw').toBe(2));
+ expect(bars(view), 'the declared rows member must still draw').toBe(2);
+ expect(barTitles(view), 'and it must draw THOSE two tasks').toBe('Alpha|Beta');
});
it('still reads a bare array — the live non-envelope shape fakes answer with', async () => {
const { view } = await mount(asBareArray);
- await waitFor(() => expect(bars(view), 'the bare-array arm must still draw').toBe(2));
+ expect(bars(view), 'the bare-array arm must still draw').toBe(2);
+ expect(barTitles(view), 'and it must draw THOSE two tasks').toBe('Alpha|Beta');
});
it('does NOT read `records` — not a QueryResult member', async () => {
// Before the fix these two tasks drew off a key `QueryResult` does not
// declare, and did so AHEAD of `data`.
- const { view } = await mount(asRecords);
+ const { view, drewChart } = await mount(asRecords);
+ // TWO observations, kept apart on purpose: without the first, a zero-bar
+ // reading is indistinguishable from a chart that had not drawn yet —
+ // which is what this arm did before objectui#8665.
+ expect(
+ drewChart,
+ 'the chart must have finished loading before it is read — an unanchored zero cannot tell a refusal from a chart still in its loading placeholder',
+ ).toBe(true);
expect(
bars(view),
'a `records` envelope must reach the chart as zero bars, not as the tasks it names',
).toBe(0);
+ expect(barTitles(view), 'and no task titles at all').toBe('');
});
});
describe('sink 2 — the quick-filter option domain, read directly', () => {
it("still reads the contract's `data` member", async () => {
const { view } = await mount(asData);
+ await domainSettled(view);
expect(
await offersUnloadedProject(view),
'the full referenced domain must still widen the dropdown past the loaded rows',
@@ -180,6 +304,7 @@ describe('ObjectGantt — the find() envelopes it reads (objectui#6839)', () =>
it('still reads a bare array', async () => {
const { view } = await mount(asBareArray);
+ await domainSettled(view);
expect(await offersUnloadedProject(view)).toBe(true);
});
@@ -188,6 +313,10 @@ describe('ObjectGantt — the find() envelopes it reads (objectui#6839)', () =>
// rows-only pin would have called this module green while the dropdown
// silently narrowed to the values already on screen.
const { view } = await mount(asRecords);
+ // ⭐ The absence below is only worth reading once the domain resolution
+ // has COMMITTED. `domainSettled` is what makes this arm fail when the
+ // domain is read, instead of passing because it has not answered yet.
+ await domainSettled(view);
expect(
await offersUnloadedProject(view),
'a `records` envelope must not resolve the referenced domain',