From cf2728e68aceaaebd309244047bf80ee375848ac Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 02:46:53 +0000 Subject: [PATCH 1/2] fix(plugin-calendar): gate ObjectCalendar's standalone query on the object schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fetch effect built its expand set from `objectSchemaRef.current`, a ref assigned in the render body, and deliberately omitted `objectSchema` from its dependency list. That bought 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 — and nothing re-ran the effect when the schema landed. Only the standalone `object-calendar` with `dataConfig.provider === 'object'` reaches this path; one hosted by ObjectView or ListView takes its rows from the parent, which objectui#6419 already covers. On the standalone calendar every lookup / master_detail / user / tree field rendered from its raw foreign-key id. 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), written as a third copy rather than an extraction because neither of those landed a shared helper and this component's key and gate scope both diverge from theirs. Measured on this component, not inherited. Instrumented adapter, rows tagged with the query that produced them, observations read from the DOM and from real child commits, three latency profiles: before 1 find, `$expand` NEVER present. Raw ids paint and stay. `objectSchema` 2 finds. Schema slower: raw ids at 30ms, back to the in the deps "Loading calendar..." placeholder at 71ms, expanded at 94ms — a three-step paint. Schema faster: the first response is discarded on arrival. gated 1 find carrying `$expand` the first time, in all three profiles; one paint, expanded, at 107/88/78ms vs 108/94/79ms via the deps. The gate is on the read having SETTLED, never on a truthy schema: an adapter with no `getObjectSchema` and a read that throws both settle with nothing and the calendar still queries, unexpanded. The gate is scoped to the `object` provider because an inline `value` set issues no metadata read at all — a whole-effect gate would hold its query open on a resolution nothing produces. No public surface change: no `index.ts` touched, no export added or removed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q --- .changeset/6453-calendar-expand-gate.md | 29 ++ .../plugin-calendar/src/ObjectCalendar.tsx | 105 +++-- .../ObjectCalendar.expandGate-6453.test.tsx | 386 ++++++++++++++++++ 3 files changed, 497 insertions(+), 23 deletions(-) create mode 100644 .changeset/6453-calendar-expand-gate.md create mode 100644 packages/plugin-calendar/src/__tests__/ObjectCalendar.expandGate-6453.test.tsx 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..925dc5bd30 --- /dev/null +++ b/packages/plugin-calendar/src/__tests__/ObjectCalendar.expandGate-6453.test.tsx @@ -0,0 +1,386 @@ +/** + * 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. Stated honestly: those two tests CANNOT discriminate + * against `origin/main`, which has no gate at all and therefore queries in both + * cases anyway. They are green before and after. They earn their place by going + * red the moment anyone "simplifies" the gate to `if (!objectSchema) return;`. + * + * The inline-data test is the same kind of pin for the other direction: the + * gate is scoped to the `object` provider because a `value` provider issues no + * metadata read, so a whole-effect gate would hold its 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, same honest caveat as the test above. + 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(); + }); +}); From 6b1f48ba0f1a8893cbaabcf8d5073a170c15a4cd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 03:03:51 +0000 Subject: [PATCH 2/2] test(plugin-calendar): record which expand-gate pins actually discriminate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverse verification against the branch's base commit turned SIX of the nine pins red, not four: the REJECTS pin discriminates too, through its ordering assertion (`['find', 'schema:issued']` against the base — the query went out before the schema was even requested). The docstring claimed it could not. Corrected to the measured result, and the three pins that genuinely stay green in both directions are now named with the reason each still earns its place. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q --- .../ObjectCalendar.expandGate-6453.test.tsx | 34 ++++++++++++++----- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/packages/plugin-calendar/src/__tests__/ObjectCalendar.expandGate-6453.test.tsx b/packages/plugin-calendar/src/__tests__/ObjectCalendar.expandGate-6453.test.tsx index 925dc5bd30..b8874d53b8 100644 --- a/packages/plugin-calendar/src/__tests__/ObjectCalendar.expandGate-6453.test.tsx +++ b/packages/plugin-calendar/src/__tests__/ObjectCalendar.expandGate-6453.test.tsx @@ -65,15 +65,28 @@ * 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. Stated honestly: those two tests CANNOT discriminate - * against `origin/main`, which has no gate at all and therefore queries in both - * cases anyway. They are green before and after. They earn their place by going - * red the moment anyone "simplifies" the gate to `if (!objectSchema) return;`. + * no error and no request. * - * The inline-data test is the same kind of pin for the other direction: the - * gate is scoped to the `object` provider because a `value` provider issues no - * metadata read, so a whole-effect gate would hold its query open on a - * resolution nothing was going to produce. + * ## 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 * @@ -302,7 +315,10 @@ describe('ObjectCalendar gates its standalone query on the object schema (object }); it('still queries — and paints — when the schema read REJECTS', async () => { - // Same class of pin, same honest caveat as the test above. + // 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');