Skip to content

Commit 7975f2d

Browse files
claude[bot]claude
andauthored
fix(plugin-calendar): gate ObjectCalendar's standalone query on the object schema (#6484)
* fix(plugin-calendar): gate ObjectCalendar's standalone query on the object schema 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q * test(plugin-calendar): record which expand-gate pins actually discriminate 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 622f33c commit 7975f2d

3 files changed

Lines changed: 513 additions & 23 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'@object-ui/plugin-calendar': patch
3+
---
4+
5+
A standalone `object-calendar` bound to an object now queries WITH its `$expand`, so
6+
lookup / master_detail / user / tree fields render the related record instead of a raw
7+
foreign-key id (objectui#6453).
8+
9+
`ObjectCalendar`'s fetch effect built its expand set from a ref assigned in the render body
10+
(`objectSchemaRef.current = objectSchema`) and left `objectSchema` out of its dependency
11+
list. That bought the effect exactly one run per mount and paid for it with the expansion,
12+
permanently: on that one run the ref was still `null`, `buildExpandFields` saw no fields,
13+
the query went out with no `$expand` at all, and nothing re-ran the effect when the schema
14+
landed. Only the standalone calendar reached this path — one hosted by `ObjectView` or
15+
`ListView` receives its rows as `data`, which objectui#6419 already covers.
16+
17+
The ref is replaced by a settled-and-keyed resolution (`{ key, def } | null`) that GATES the
18+
record query, the third member of the family after objectui#6271 (`ObjectKanban`) and
19+
objectui#6419 (`ObjectView`). Measured on this component rather than inherited: gated, the
20+
calendar issues one query carrying `$expand` in every latency profile; the alternative of
21+
adding `objectSchema` to the dependency list issued two, and when the schema read was the
22+
slower of the two it painted raw ids, reverted to the "Loading calendar..." placeholder,
23+
then swapped — a three-step paint the correct rows do not arrive any later than.
24+
25+
The gate is on the schema read having SETTLED, never on a truthy schema: an adapter that
26+
exposes no `getObjectSchema`, and a read that throws, both settle with nothing and the
27+
calendar still queries (unexpanded) rather than waiting forever. An inline `value` data set
28+
is deliberately not gated — it issues no metadata read, so there would be no resolution to
29+
wait for.

‎packages/plugin-calendar/src/ObjectCalendar.tsx‎

Lines changed: 82 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
* - Works with object/value data providers
2323
*/
2424

25-
import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react';
25+
import React, { useEffect, useState, useCallback, useMemo } from 'react';
2626
import type { ObjectGridSchema, DataSource, ViewData, CalendarConfig } from '@object-ui/types';
2727
import { CalendarView } from './CalendarView';
2828
import { usePullToRefresh } from '@object-ui/mobile';
@@ -177,7 +177,12 @@ export const ObjectCalendar: React.FC<ObjectCalendarComponentProps> = ({
177177
const [data, setData] = useState<any[]>(hasExternalData ? externalData! : []);
178178
const [loading, setLoading] = useState(hasExternalData ? (externalLoading ?? false) : true);
179179
const [error, setError] = useState<Error | null>(null);
180-
const [objectSchema, setObjectSchema] = useState<any>(null);
180+
// The object-schema read and the fact that it has SETTLED are ONE piece of
181+
// state, keyed by the object it belongs to (objectui#6453). The derived
182+
// `objectSchema` / `objectSchemaReady` pair lives further down, next to
183+
// `dataConfig`, because the key is the object the RECORD QUERY will use.
184+
const [schemaResolution, setSchemaResolution] =
185+
useState<{ key: string; def: any } | null>(null);
181186
const [currentDate, setCurrentDate] = useState(new Date());
182187
const isMobile = useIsMobile();
183188
const schemaDefaultView = (schema as any).defaultView as 'month' | 'week' | 'day' | undefined;
@@ -237,9 +242,33 @@ export const ObjectCalendar: React.FC<ObjectCalendarComponentProps> = ({
237242
]);
238243
const hasInlineData = dataConfig?.provider === 'value';
239244

240-
// Use ref for objectSchema to avoid double-fetch on mount
241-
const objectSchemaRef = useRef<any>(null);
242-
objectSchemaRef.current = objectSchema;
245+
// ⭐ objectui#6453 — this replaces a `useRef` written in the render body
246+
// (`objectSchemaRef.current = objectSchema`), which existed so the fetch
247+
// effect below could read the schema without listing it as a dependency.
248+
// That bought the effect one run per mount and paid for it with the
249+
// expansion, permanently: on that one run the ref was still `null`,
250+
// `buildExpandFields` saw no fields, and the standalone calendar's query went
251+
// out with no `$expand` at all — so every lookup / master_detail / user /
252+
// tree field rendered from its raw foreign-key id, forever.
253+
//
254+
// The KEY is the object the record query will use, which on this component is
255+
// NOT simply `schema.objectName`: an authored `data` block can name a
256+
// different object. Comparing it during render means switching objects closes
257+
// the gate in the same commit that changes it, not one commit later, so no
258+
// query can carry the previous object's expand set.
259+
const schemaObjectName =
260+
dataConfig?.provider === 'object' ? dataConfig.object : schema.objectName;
261+
const schemaKey = schemaObjectName ?? '';
262+
/**
263+
* Has the object schema for THIS object finished resolving? Note what this is
264+
* NOT: "`objectSchema` is truthy". A calendar whose adapter exposes no
265+
* `getObjectSchema`, or whose schema read failed, must still fetch its
266+
* records — gating on a truthy schema would leave those calendars empty
267+
* forever. "Settled with nothing" and "not yet settled" are different states
268+
* and only the second may hold the query.
269+
*/
270+
const objectSchemaReady = schemaResolution !== null && schemaResolution.key === schemaKey;
271+
const objectSchema = objectSchemaReady ? schemaResolution.def : null;
243272

244273
// Sync external data/loading changes from parent (e.g. ObjectView re-fetches after filter change)
245274
useEffect(() => {
@@ -259,6 +288,23 @@ export const ObjectCalendar: React.FC<ObjectCalendarComponentProps> = ({
259288
// Skip internal fetch when data is managed by a parent component
260289
if (hasExternalData) return;
261290

291+
// ⭐ objectui#6453 — the object schema GATES this query; it does not refine
292+
// it afterwards. Measured on THIS component (instrumented adapter, three
293+
// latency profiles), the alternative — putting `objectSchema` in the
294+
// dependency list below — costs two queries and, when the schema read is
295+
// the slower of the two, a THREE-step paint: raw ids, back to the
296+
// "Loading calendar..." placeholder (this effect calls `setLoading(true)`
297+
// on re-run, and `loading` is an early return above), then the expanded
298+
// rows. When the schema read is the faster one the first response is
299+
// instead discarded on arrival — a round trip bought and thrown away.
300+
// Gating is the only shape that is right in every profile.
301+
//
302+
// Scoped to the `object` provider deliberately: an inline (`value`) data
303+
// set has no expand set to derive and issues no metadata read at all, so
304+
// gating it would hold a query open on a resolution nothing was going to
305+
// produce.
306+
if (dataConfig?.provider === 'object' && !objectSchemaReady) return;
307+
262308
let isMounted = true;
263309
const fetchData = async () => {
264310
try {
@@ -280,7 +326,11 @@ export const ObjectCalendar: React.FC<ObjectCalendarComponentProps> = ({
280326
if (dataConfig?.provider === 'object') {
281327
const objectName = dataConfig.object;
282328
// Auto-inject $expand for lookup/master_detail fields
283-
const expand = buildExpandFields(objectSchemaRef.current?.fields);
329+
// Reached only with the schema resolved (the gate above), so a
330+
// calendar whose object declares relations queries WITH its
331+
// expansion the first time. `objectSchema` is `null` here only
332+
// when there was nothing to resolve it from.
333+
const expand = buildExpandFields(objectSchema?.fields);
284334
const result = await dataSource.find(objectName, {
285335
$filter: schema.filter,
286336
$orderby: convertSortToQueryParams(schema.sort),
@@ -309,31 +359,40 @@ export const ObjectCalendar: React.FC<ObjectCalendarComponentProps> = ({
309359

310360
fetchData();
311361
return () => { isMounted = false; };
312-
}, [hasExternalData, dataConfig, dataSource, hasInlineData, schema.filter, schema.sort, refreshKey]);
313-
314-
// Fetch object schema for field metadata
362+
}, [hasExternalData, dataConfig, dataSource, hasInlineData, schema.filter, schema.sort,
363+
refreshKey, objectSchemaReady, objectSchema]);
364+
365+
// Fetch object schema for field metadata.
366+
//
367+
// Every exit settles the resolution — success, failure, and "there is nothing
368+
// to read from" alike — because the record query above WAITS on this
369+
// (objectui#6453). A path that returned without settling would not merely
370+
// skip the expansion, it would hold that query open forever.
315371
useEffect(() => {
372+
let isMounted = true;
373+
const key = schemaKey;
316374
const fetchObjectSchema = async () => {
375+
// No source for a schema — including an inline (`value`) data set, which
376+
// issues no metadata read here and did not before. Settle with none, so
377+
// anything gated on this still runs (unexpanded: with no schema there is
378+
// no expand set to derive, which is the same query these cases produced
379+
// before).
380+
if (hasInlineData || !dataSource || !key || typeof dataSource.getObjectSchema !== 'function') {
381+
if (isMounted) setSchemaResolution({ key, def: null });
382+
return;
383+
}
317384
try {
318-
if (!dataSource) return;
319-
320-
const objectName = dataConfig?.provider === 'object'
321-
? dataConfig.object
322-
: schema.objectName;
323-
324-
if (!objectName) return;
325-
326-
const schemaData = await dataSource.getObjectSchema(objectName);
327-
setObjectSchema(schemaData);
385+
const schemaData = await dataSource.getObjectSchema(key);
386+
if (isMounted) setSchemaResolution({ key, def: schemaData });
328387
} catch (err) {
329388
console.error('Failed to fetch object schema:', err);
389+
if (isMounted) setSchemaResolution({ key, def: null });
330390
}
331391
};
332392

333-
if (!hasInlineData && dataSource) {
334-
fetchObjectSchema();
335-
}
336-
}, [schema.objectName, dataSource, hasInlineData, dataConfig]);
393+
fetchObjectSchema();
394+
return () => { isMounted = false; };
395+
}, [schemaKey, dataSource, hasInlineData]);
337396

338397
// Transform data to calendar events
339398
const events = useMemo(() => {

0 commit comments

Comments
 (0)