From b1b3c4b201cfffc74a94eeb1972b5f12cba9be43 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 02:55:20 +0000 Subject: [PATCH] fix(plugin-list): ObjectGallery waits for the object definition instead of querying twice (objectui#7903) `ObjectGallery` sat outside the set objectui#6482 converged on the shared settled-schema gate, and nothing marked it a deliberate exclusion. It held the object definition in a local `useState` fed by its own metadata effect and listed that definition in the record-fetch effect's dependency array, so every object-bound load issued two `find` calls: the first before the definition landed, with `buildExpandFields` seeing no fields and therefore carrying no `$expand` at all, and a second one after. The first paint was a grid of cards rendered from raw foreign-key ids. Measured on this component rather than inherited from the matching shape (objectui#6482's own per-component standard): instrumented renderer, one mount per hold, `getObjectSchema` held 0/1/2/3/4/5/6/7/8/9/10/15/25/50/100 ms, with `ObjectCalendar` as a positive control in the same run. Before: 2 `find` calls with expand sets [null, ['owner']] at every hold, order always schema:issued / find(unexpanded) / schema:settled / find(expanded), two painted states, 3 late writes, first paint flat at 3-7 ms across the whole sweep. After: 1 `find` carrying ['owner'], one painted state, 0 late writes, first paint tracking the hold (9 ms at +3, 35 ms at +25, 106 ms at +100). The cost here is a two-step paint, not the three-step one `ObjectCalendar` and `ObjectTimeline` measured: their `loading` is an unconditional early return, so the re-run drops them back to a placeholder; this component's is `loading && !items.length`, so the raw ids were replaced in place. The resolution half is now `useSettledSchema` from `@object-ui/react`, which settles on every exit -- no source, no `getObjectSchema`, no object name, and a read that threw alike. The replaced effect returned without settling on all four (objectui#7232), which cost nothing while nothing waited on it and would hold a gated query open forever. Two departures, judged for this component rather than copied from a sibling: the metadata read is NOT disabled on the authored-items path, because this component reads the definition on every path for cell semantics and ADR-0079 card titles; and the gate branch holds the loading placeholder, which the two siblings get from their initial `loading` state and this one does not. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KbJQ1y1J12nZxYzFWhP8Q3 --- .../7903-gallery-settled-schema-gate.md | 56 ++++ packages/plugin-list/src/ObjectGallery.tsx | 119 ++++++-- .../ObjectGallery.fetchGate-7903.test.tsx | 282 ++++++++++++++++++ 3 files changed, 438 insertions(+), 19 deletions(-) create mode 100644 .changeset/7903-gallery-settled-schema-gate.md create mode 100644 packages/plugin-list/src/__tests__/ObjectGallery.fetchGate-7903.test.tsx diff --git a/.changeset/7903-gallery-settled-schema-gate.md b/.changeset/7903-gallery-settled-schema-gate.md new file mode 100644 index 0000000000..89eae6fc51 --- /dev/null +++ b/.changeset/7903-gallery-settled-schema-gate.md @@ -0,0 +1,56 @@ +--- +'@object-ui/plugin-list': patch +--- + +`ObjectGallery` waits for the object definition instead of querying twice +(objectui#7903). + +It sat outside the set objectui#6482 converged on the shared settled-schema gate +— `ObjectKanban`, `ObjectView`, `ObjectCalendar` and `ObjectTree` were named +there, `ObjectGantt` was ask 2 of objectui#7225 and `ObjectTimeline` was +objectui#7895 — and nothing marked it a deliberate exclusion. It still held the +object definition in a local `useState` fed by its own metadata effect, and +listed that definition in the record-fetch effect's dependency array. + +**User-visible.** Every object-bound gallery load issued **two** `find` calls +instead of one: the first before the definition landed, with `buildExpandFields` +seeing no fields and therefore carrying no `$expand` at all, and a second one +after. The first paint was therefore a grid of cards rendered from raw +foreign-key ids. After this change the gallery paints once, from a query that +already carries its expansion. + +Measured on the component with an instrumented renderer, one mount per hold, +`getObjectSchema` held 0/1/2/3/4/5/6/7/8/9/10/15/25/50/100 ms, with +`ObjectCalendar` as a positive control in the same run: before, 2 `find` calls +with expand sets `[null, ['owner']]` at every hold, the issue order always +`schema:issued, find(unexpanded), schema:settled, find(expanded)`, two distinct +painted states, 3 late writes into the card grid after the first paint, and a +first-paint time flat at 3-7 ms across the whole sweep; after, 1 `find` carrying +`['owner']`, one painted state, 0 late writes, and a first paint that tracks the +hold (9 ms at +3, 15 ms at +10, 35 ms at +25, 106 ms at +100). The control read +1 `find` carrying `['owner']` and a hold-tracking first paint both before and +after. + +The cost this component was paying is a **two**-step paint, not the three-step +one `ObjectCalendar` and `ObjectTimeline` each measured: those make `loading` an +unconditional early return, so their re-run drops back to a placeholder in +between, while this component's early return is `loading && !items.length` — the +raw ids were replaced in place. Measured here rather than inherited from the +matching shape, which is objectui#6482's own per-component standard. + +The resolution half is now `useSettledSchema` from `@object-ui/react`, which +settles on **every** exit — no source, no `getObjectSchema`, no object name, and +a read that threw alike. That is what makes the gate safe: the replaced effect +returned without settling on all four, which cost nothing while nothing waited on +it and would have held a gated query open forever. Pinned by +`ObjectGallery.fetchGate-7903.test.tsx`, including a gallery whose adapter +exposes no `getObjectSchema` and one whose definition read rejects — both still +query, unexpanded. + +Two departures, each judged for this component rather than copied. Like +`ObjectTimeline` and unlike `ObjectCalendar` / `ObjectGantt`, the metadata read +is **not** disabled for a gallery whose records were authored inline: this +component reads the definition on every path, for cell semantics and for ADR-0079 +card titles, not only to expand a query. And the gate window now holds the +loading placeholder rather than showing "No items to display", which the two +siblings get from their initial `loading` state and this one did not. diff --git a/packages/plugin-list/src/ObjectGallery.tsx b/packages/plugin-list/src/ObjectGallery.tsx index 49ee0b6f82..9a0ddc5419 100644 --- a/packages/plugin-list/src/ObjectGallery.tsx +++ b/packages/plugin-list/src/ObjectGallery.tsx @@ -7,7 +7,7 @@ */ import React, { useState, useEffect, useCallback, useMemo, useContext } from 'react'; -import { useDataScope, SchemaRendererContext, useNavigationOverlay, useSafeFieldLabel } from '@object-ui/react'; +import { useDataScope, SchemaRendererContext, useNavigationOverlay, useSafeFieldLabel, useSettledSchema } from '@object-ui/react'; import { ComponentRegistry, buildExpandFields, getRecordDisplayName } from '@object-ui/core'; import { cn, Card, CardContent, NavigationOverlay } from '@object-ui/components'; import type { GalleryConfig, ObjectGallerySchema } from '@object-ui/types'; @@ -219,7 +219,77 @@ export const ObjectGallery: React.FC = (props) => { const [fetchedData, setFetchedData] = useState[]>([]); const [loading, setLoading] = useState(false); - const [objectDef, setObjectDef] = useState(null); + + /** + * The object definition, and whether the read for THIS object has SETTLED — + * one piece of state, through the shared hook (objectui#7903). + * + * `ObjectGallery` sat outside the set objectui#6482 converged + * (`ObjectKanban`, `ObjectView`, `ObjectCalendar`, `ObjectTree`; `ObjectGantt` + * at objectui#7225 ask 2, `ObjectTimeline` at objectui#7895) and nothing + * marked it a deliberate exclusion. It still held the definition in a local + * `useState` fed by its own metadata effect, with `objectDef` listed in the + * record-fetch effect's dependency array below — so every object-bound + * gallery load issued the record query TWICE: once before the definition + * landed, with `buildExpandFields` seeing no fields and therefore carrying + * no `$expand` at all, and once after. + * + * Measured on THIS component before the change (objectui#6482's per-component + * standard — the cost differs by component), instrumented renderer, one mount + * per hold, `getObjectSchema` held 0/1/2/3/4/5/6/7/8/9/10/15/25/50/100 ms, + * with `ObjectCalendar` as a positive control in the same run: 2 `find` calls + * with expand sets `[null, ['owner']]` at EVERY hold, the issue order always + * `schema:issued, find(unexpanded), schema:settled, find(expanded)`, two + * distinct painted states (raw foreign-key ids, then the expanded rows), 3 + * late writes into the card grid after the first paint, and a first paint + * FLAT at 3-8 ms across the whole 0->100 ms sweep. The control read 1 `find` + * carrying `['owner']`, one painted state, and a first paint that TRACKS the + * hold (33-69 ms at 0-15 ms, 77 ms at +50, 161 ms at +100). + * + * ⚠️ This component's visible cost is a TWO-step paint, not the three-step + * one `ObjectCalendar` (objectui#6453) and `ObjectTimeline` (objectui#7895) + * each measured. Those two make `loading` an unconditional early return, so + * the re-run's `setLoading(true)` drops them back to their placeholder + * between the two paints. Here the early return is `loading && !items.length` + * — once the first (raw) rows are in state the skeleton cannot come back — + * so the user sees raw foreign-key ids replaced in place by the expanded + * rows. Measured, not inherited: `skelAfter=false` at every hold. + * + * ⚠️ The gate below is only safe because this resolution SETTLES ON EVERY + * EXIT (objectui#7232) — no source, no `getObjectSchema`, no object name, and + * a read that threw alike. The hand-written effect it replaces returned + * WITHOUT settling on all four, which cost nothing while nothing waited on it + * and would hold a gated query open forever. + * + * ⛔ `dataSource` is passed unconditionally, NOT `hasInlineData ? undefined : + * dataSource` the way `ObjectCalendar` and `ObjectGantt` pass it — the same + * departure `ObjectTimeline` made, and it applies here for a stronger reason. + * Those two read metadata only to expand a record query, so an inline data + * set has nothing to wait for. This component reads the definition on EVERY + * path, query or not: `buildEnrichedField` above reads `objectDef.fields` for + * each visible field's type, options, currency, precision and reference + * target, and `getRecordDisplayName(objectDef, item)` below resolves each + * card's title under ADR-0079. Disabling the read for authored `data` / + * `bind` items would strip cell semantics and card titles off exactly the + * paths that issue no query — a second, unasked-for change riding on a + * fetch-sequencing fix. + * + * The key is `schema.objectName` — the object the record query itself names + * (`dataSource.find(schema.objectName, …)` below) and the one the replaced + * effect read. ⛔ Not `resolveRecordSourceObjectName`, the same departure + * `ObjectTimeline` made, and again for a stronger reason: that reader's + * second rung is a resolved `data` BLOCK (`dataConfig.provider === 'object'`), + * and `ObjectGallerySchema['data']` is typed `Record[]` — a + * bare inline record array, not a provider config. This component calls + * `getDataConfig` nowhere, so there is no second name to read; the ladder + * would degenerate to `schema.objectName` with extra spelling. Gating on a + * key the query does not use is exactly the stale-key mismatch + * `useSettledSchema`'s render-time comparison exists to make unrepresentable. + */ + const { ready: objectDefReady, def: objectDef } = useSettledSchema( + schema.objectName ?? '', + dataSource as any, + ); // --- NavigationConfig support --- const navigation = useNavigationOverlay({ @@ -288,22 +358,6 @@ export const ObjectGallery: React.FC = (props) => { return enriched; }, [objectDef, schema.objectName, fieldLabel, fieldOptionLabel]); - // Fetch object definition for metadata - useEffect(() => { - let isMounted = true; - const fetchMeta = async () => { - if (!dataSource || typeof dataSource.getObjectSchema !== 'function' || !schema.objectName) return; - try { - const def = await dataSource.getObjectSchema(schema.objectName); - if (isMounted) setObjectDef(def); - } catch (e) { - console.warn('Failed to fetch object def for ObjectGallery', e); - } - }; - fetchMeta(); - return () => { isMounted = false; }; - }, [schema.objectName, dataSource]); - useEffect(() => { let isMounted = true; @@ -346,10 +400,37 @@ export const ObjectGallery: React.FC = (props) => { }; if (schema.objectName && !boundData && !schema.data && !props.data) { + // ⭐ objectui#7903 — the object definition GATES this query; it does + // not refine it afterwards. `objectDef` stays in the dependency list + // below and the two are ONE mechanism, not two: the dependency is + // what makes this effect re-run when the definition lands, and this + // branch is what stops the first run from spending a query before it + // has. Removing either half alone restores the double fetch. + // + // Scoped to the branch that actually issues the query. The authored + // (`props.data` / `schema.data`) and bound (`bind`) paths never query, + // so gating them would hold nothing useful — and this component still + // reads the definition on those paths (cell semantics and ADR-0079 + // card titles), which is why the resolution above is not disabled for + // them. + if (!objectDefReady) { + // ⚠️ Hold the placeholder across the gate window. `loading` starts + // `false` here and was only ever flipped inside `fetchData`, so a + // bare `return` would leave the gallery showing "No items to + // display" — a FALSE empty state — for the whole metadata read, + // where before the gate it showed the loading placeholder. The + // two siblings get this for free from their own initial state + // (`ObjectCalendar` starts `loading` at `true`; `ObjectTimeline` + // computes the same thing in a lazy initializer); this component + // does not, so the same guarantee is stated here, in the one + // branch that knows a query is coming. + if (isMounted) setLoading(true); + return; + } fetchData(); } return () => { isMounted = false; }; - }, [schema.objectName, dataSource, boundData, schema.data, schema.filter, props.data, objectDef]); + }, [schema.objectName, dataSource, boundData, schema.data, schema.filter, props.data, objectDefReady, objectDef]); const items: Record[] = props.data || boundData || schema.data || fetchedData || []; diff --git a/packages/plugin-list/src/__tests__/ObjectGallery.fetchGate-7903.test.tsx b/packages/plugin-list/src/__tests__/ObjectGallery.fetchGate-7903.test.tsx new file mode 100644 index 0000000000..394a9780f1 --- /dev/null +++ b/packages/plugin-list/src/__tests__/ObjectGallery.fetchGate-7903.test.tsx @@ -0,0 +1,282 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#7903 — `ObjectGallery`'s DUPLICATE query is gated. + * + * It sat outside the set objectui#6482 converged (`ObjectKanban`, `ObjectView`, + * `ObjectCalendar`, `ObjectTree`; `ObjectGantt` at objectui#7225 ask 2, + * `ObjectTimeline` at objectui#7895) and nothing marked it a deliberate + * exclusion. It held the object definition in a local `useState` and listed it + * in the record-fetch effect's dependency array, so a mount issued TWO `find` + * calls: one before the definition settled, with `buildExpandFields` seeing no + * fields and therefore NO `$expand` at all, and one after. + * + * ⛔ A GREEN RUN WITH NO HOLD IS ZERO EVIDENCE HERE. This container settles the + * metadata read and the record read close together, so several of the readings + * below are invisible unperturbed — the lesson objectui#7466 paid for. The lever + * is the METADATA fetch, so `getObjectSchema` is HELD and the writes into the + * rendered card grid are counted per SINGLE mount. Measured on this component + * with an instrumented renderer, `ObjectCalendar` as a positive control in the + * same vitest run, holds 0/1/2/3/4/5/6/7/8/9/10/15/25/50/100 ms: + * + * component when finds expand sets states late first paint + * ObjectGallery before 2 [null, ['owner']] 2 3 flat 3-8ms + * ObjectGallery after 1 [['owner']] 1 0 tracks hold + * ObjectCalendar before 1 [['owner']] 1 - tracks hold + * ObjectCalendar after 1 [['owner']] 1 - tracks hold + * + * ⭐ "First paint tracks the hold" is the signature that the gate is LIVE, and + * it is what the ordering case below pins — not as a wall-clock threshold, which + * would be a timing test, but as an ORDER: the definition resolves, then the + * query goes out, then the card grid is written to, exactly once. Before the + * gate that order was find, paint, definition, find, paint. Measured after the + * gate: 9ms at a +3ms hold, 15ms at +10, 35ms at +25, 106ms at +100, against a + * flat 3-8ms at every hold before it. + * + * ⚠️ This component's visible cost was a TWO-step paint, not the three-step one + * `ObjectCalendar` (objectui#6453) and `ObjectTimeline` (objectui#7895) each + * measured. Those make `loading` an unconditional early return, so the re-run's + * `setLoading(true)` drops them back to their placeholder in between. Here the + * early return is `loading && !items.length`, so once the raw rows are in state + * the skeleton cannot come back — measured `skelAfter=false` at every hold. The + * user saw raw foreign-key ids replaced IN PLACE by the expanded rows. Recorded + * because objectui#6482's standard is that the cost is measured per component, + * never inherited from a sibling with a matching shape. + * + * ⚠️ The gate is only safe because the resolution now SETTLES ON EVERY EXIT + * (objectui#7232, via the shared `useSettledSchema`): the replaced effect + * returned without settling on `!dataSource`, on a missing `getObjectSchema`, on + * an absent object name and in its `catch`. That cost nothing while nothing + * waited on it and would hold a gated query open FOREVER. The two middle cases + * below are that trap, pinned: a gallery that never loads is the failure this + * file exists to make impossible. + * + * ⛔ GATING IS NOT EXPANDING. objectui#7429 is the separate, non-overlapping + * concern of whether the `$expand` set this query carries is FLS-gated, and + * objectui#7390 is the unbounded fetch on this same component. This file asserts + * only HOW MANY queries go out, WHEN, and whether the first one carries its + * expansion. + */ + +import React from 'react'; +import { render, screen, waitFor, act, cleanup } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ObjectGallery } from '../ObjectGallery'; + +/** Definition resolve / query / paint, interleaved in the order they happen. */ +let sequence: string[] = []; + +/** A definition with a lookup, so a gated query has a real `$expand` to carry. */ +const OBJECT_SCHEMA = { + name: 'visit', + label: 'Visit', + fields: { + id: { name: 'id', type: 'text' }, + name: { name: 'name', type: 'text', label: 'Name' }, + owner: { name: 'owner', type: 'lookup', label: 'Owner', reference: 'user' }, + }, +}; + +const TITLE = 'Site visit'; + +/** + * A FRESH array per response, as the wire produces, tagged with the query that + * produced it. Returning one shared array would make `setFetchedData` a + * reference-equal no-op and hide every extra delivery. + */ +const rowsFor = (tag: string) => [{ id: 'v1', name: TITLE, owner: `u1-${tag}` }]; + +function makeAdapter(getObjectSchema?: any) { + return { + find: vi.fn(async (_object: string, params: any) => { + const tag = Array.isArray(params?.$expand) && params.$expand.length > 0 ? 'expanded' : 'raw'; + sequence.push('find'); + return { records: rowsFor(tag) }; + }), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + ...(getObjectSchema === undefined ? {} : { getObjectSchema }), + } as any; +} + +/** The expand sets of every issued query, in order. */ +function expandSets(adapter: any): Array { + return adapter.find.mock.calls.map(([, params]: [string, any]) => params?.$expand ?? null); +} + +const gallerySchema: any = { + type: 'object-gallery', + objectName: 'visit', + gallery: { titleField: 'name', visibleFields: ['owner'] }, +}; + +/** + * Every write into the rendered card grid, in order. `loading && !items.length` + * is an early return above the grid, so an entry here is a real paint. + */ +function renderGallery(adapter: any, schema: any = gallerySchema, data?: any) { + const onRender = () => { + if ((document.body.textContent ?? '').includes(TITLE) && sequence[sequence.length - 1] !== 'paint') { + sequence.push('paint'); + } + }; + return render( + + + , + ); +} + +beforeEach(() => { + sequence = []; +}); +afterEach(() => cleanup()); + +describe('objectui#7903 — the gallery waits for the object definition instead of querying twice', () => { + it('issues ONE query per load, and it already carries the expansion', async () => { + const adapter = makeAdapter(vi.fn(async () => OBJECT_SCHEMA)); + + renderGallery(adapter); + + await waitFor(() => expect(screen.getByText(TITLE)).toBeTruthy()); + await waitFor(() => expect(adapter.find).toHaveBeenCalledTimes(1)); + + // The old regime's signature was `[null, ['owner']]`. One expanded call. + expect(expandSets(adapter)).toEqual([['owner']]); + expect(adapter.getObjectSchema).toHaveBeenCalledTimes(1); + }); + + it('never issues an UNEXPANDED query for an object that declares a lookup', async () => { + const adapter = makeAdapter(vi.fn(async () => OBJECT_SCHEMA)); + + renderGallery(adapter); + + await waitFor(() => expect(adapter.find).toHaveBeenCalled()); + // The discarded round trip — and the raw-id frame it painted — is the thing + // gating removes. Not "fewer" unexpanded calls: none. + for (const params of adapter.find.mock.calls.map(([, p]: [string, any]) => p)) { + expect(params.$expand).toEqual(['owner']); + } + }); + + it('still queries when the adapter exposes NO `getObjectSchema` — the gate is on SETTLED, not on truthy', async () => { + // objectui#7232's trap: an exit that returns without settling would hold + // this query open forever, and the gallery would never load. + const adapter = makeAdapter(undefined); + + renderGallery(adapter); + + await waitFor(() => expect(screen.getByText(TITLE)).toBeTruthy()); + expect(adapter.find).toHaveBeenCalledTimes(1); + // Nothing to derive an expand set from, so the query is unexpanded — the + // same query this case produced before the gate. + expect(expandSets(adapter)).toEqual([null]); + }); + + it('still queries when the definition read REJECTS', async () => { + const adapter = makeAdapter( + vi.fn(async () => { + throw new Error('metadata endpoint down'); + }), + ); + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + renderGallery(adapter); + + await waitFor(() => expect(screen.getByText(TITLE)).toBeTruthy()); + expect(adapter.find).toHaveBeenCalledTimes(1); + expect(expandSets(adapter)).toEqual([null]); + expect(String(error.mock.calls[0]?.[0] ?? '')).toContain('[useSettledSchema]'); + } finally { + error.mockRestore(); + } + }); + + it('an AUTHORED `data` gallery paints without issuing any record query — and STILL reads the definition', async () => { + const adapter = makeAdapter(vi.fn(async () => OBJECT_SCHEMA)); + + renderGallery(adapter, gallerySchema, [{ id: 'a1', name: TITLE, owner: 'u9' }]); + + await waitFor(() => expect(screen.getByText(TITLE)).toBeTruthy()); + expect(adapter.find).not.toHaveBeenCalled(); + // ⛔ The metadata read is NOT disabled on this path, unlike the sibling + // components' `hasInlineData ? undefined : dataSource`. `buildEnrichedField` + // reads `objectDef.fields` for each visible field's type/options/currency, + // and `getRecordDisplayName(objectDef, item)` resolves the card title under + // ADR-0079 — on the authored path too, where no query is issued at all. + // Taking the read away would be a second, unasked-for change riding on a + // fetch-sequencing fix. + expect(adapter.getObjectSchema).toHaveBeenCalledTimes(1); + }); + + it('under a HELD definition read: ONE paint, and it arrives AFTER the definition', async () => { + // ⛔ The hold is the whole instrument. Without it this container settles both + // reads close together and the case is far weaker on the unfixed component. + const HOLD_MS = 25; + const adapter = makeAdapter( + vi.fn(async () => { + await new Promise((r) => setTimeout(r, HOLD_MS)); + sequence.push('definition'); + return OBJECT_SCHEMA; + }), + ); + + renderGallery(adapter); + + await waitFor(() => expect(screen.getByText(TITLE)).toBeTruthy()); + // Leave the render MOUNTED and let anything still in flight land — the late + // writes are only observable on a render that is still there to receive + // them. Measured before the gate at this hold: 3 of them, and a second + // painted state. + await act(async () => { + await new Promise((r) => setTimeout(r, HOLD_MS + 150)); + }); + + // The ORDER is the signature, not a wall-clock threshold. Before the gate: + // find, paint, definition, find, paint. + expect(sequence).toEqual(['definition', 'find', 'paint']); + expect(adapter.find).toHaveBeenCalledTimes(1); + expect(expandSets(adapter)).toEqual([['owner']]); + }); + + it('holds the LOADING placeholder across the gate window, never a false empty state', async () => { + // ⚠️ This component's own departure. `loading` starts `false` here and was + // only ever flipped inside the fetch, so a bare `return` at the gate would + // show "No items to display" for the whole metadata read — a FALSE empty + // state where the pre-gate component showed the placeholder. The two + // siblings get this from their initial state (`ObjectCalendar` starts + // `loading` at `true`, `ObjectTimeline` computes it in a lazy initializer); + // this component states it in the gate branch instead. + let releaseDefinition!: () => void; + const held = new Promise((resolve) => { + releaseDefinition = resolve; + }); + const adapter = makeAdapter( + vi.fn(async () => { + await held; + return OBJECT_SCHEMA; + }), + ); + + renderGallery(adapter); + + await waitFor(() => expect(screen.getByText(/Loading Gallery/i)).toBeTruthy()); + expect(screen.queryByText(/No items to display/i)).toBeNull(); + expect(adapter.find).not.toHaveBeenCalled(); + + await act(async () => { + releaseDefinition(); + await held; + }); + await waitFor(() => expect(screen.getByText(TITLE)).toBeTruthy()); + expect(expandSets(adapter)).toEqual([['owner']]); + }); +});