Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .changeset/6592-rekey-fetch-effects-on-primitives.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
'@object-ui/plugin-map': patch
'@object-ui/plugin-calendar': patch
'@object-ui/plugin-gantt': patch
---

Re-key the load-bearing fetch effects in `ObjectMap`, `ObjectCalendar` and
`ObjectGantt` onto the primitive fields they actually read off `dataConfig`
(`provider` / `object` / `items`) instead of the whole memoised `dataConfig`
object (objectui#6592, the deferred half of objectui#6270/PR #6591).
`ObjectTree` is a census member too but is deferred out of this change — see
the PR body — because its own fetch effects are the surface of PR #6696
(objectui#6481), open at the same time.

`useMemo` carries no semantic guarantee — React is permitted to discard a
memo cache and recompute even when its dependency array compares equal to
the previous render, and the local `getDataConfig(schema)` helper each of
these renderers carries builds a fresh `{ provider, object }` /
`{ provider, items }` wrapper object on every call. So a fetch effect keyed
on `dataConfig` itself was correct only for as long as that identity
happened to survive a discard: a recompute alone (no author or caller
action) was enough to re-run the effect and issue an extra
`dataSource.find` / `dataSource.getObjectSchema` call. Keying the effects
on the primitives instead makes a cache discard a no-op, restoring
`useMemo` to a pure optimisation.

`ObjectGantt`'s `effectiveDataSource` memo deliberately keeps `dataConfig`
as a dependency (`resolveDataSource` needs the whole provider-shaped
value — the `api` provider's `read`/`write` request config cannot be
flattened to a fixed primitive list the way `object`/`value` can), so its
`reload()` fetch is decoupled from the redundant direct `dataConfig`
dependency but not from `effectiveDataSource`'s own; for the `object`/`value`
providers `resolveDataSource` returns its `fallback`/a fresh
`ValueDataSource` respectively rather than reading further into the config,
which is enough for the two fetch effects to observe no extra call under a
recomputed-but-equivalent `dataConfig` in the common case.

No behaviour change for a schema whose `useMemo` caches survive normally;
the effects are unaffected by React discarding one.
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* 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#6592 — see `ObjectMap.discardedConfigMemo.test.tsx` for the full
* rationale (`useMemo` carries no semantic guarantee, so a fetch effect
* keyed on `dataConfig`'s object identity is correct only for as long as
* that identity happens to survive). This file pins the same contract for
* `ObjectCalendar`'s record-fetch effect.
*
* `ObjectCalendar`'s own `dataConfig` memo is ALREADY keyed on primitives
* (`schema.data` / `schema.staticData` / `schema.objectName` — objectui#6018),
* so the map/tree discard proxy ("a schema with a new reference but equal
* `objectName`") does not even recompute `dataConfig` here: those three deps
* would all compare equal and the memo would keep its old cached object. The
* discard proxy for THIS component instead varies `schema.data` itself — one
* of the memo's OWN deps — across two object literals that carry the SAME
* `provider`/`object` but a different reference, which forces the recompute
* (`(schema as any).data` is compared by Object.is, not by value) while
* leaving every primitive the fetch effect reads unchanged. That is the same
* "different identity, same content" shape a genuine memo-cache discard
* would produce.
*/

import React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, waitFor, cleanup } from '@testing-library/react';
import { ObjectCalendar } from './ObjectCalendar';

afterEach(cleanup);

const today = new Date();
const dayInThisMonth = (d: number) => new Date(today.getFullYear(), today.getMonth(), d, 9, 0, 0, 0);

const ROWS = [{ id: 'v1', name: 'Site visit', starts_at: dayInThisMonth(10).toISOString() }];

function makeDataSource() {
return {
find: vi.fn().mockResolvedValue({ data: ROWS }),
getObjectSchema: vi.fn().mockResolvedValue({ name: 'visit', fields: {} }),
} as any;
}

const CALENDAR = { startDateField: 'starts_at', titleField: 'name' };

describe('ObjectCalendar — record-fetch effect survives a discarded `dataConfig` memo (objectui#6592)', () => {
it('does not re-fire the fetch when `dataConfig` recomputes to a new identity with the SAME primitive fields', async () => {
const dataSource = makeDataSource();
// Two different `data` object references, byte-identical content — forces
// `dataConfig`'s own memo to recompute to a NEW object (its `data` dep is
// compared by reference) while `provider`/`object` stay unchanged.
const schemaA: any = { type: 'object-calendar', calendar: CALENDAR, data: { provider: 'object', object: 'visit' } };
const schemaB: any = { type: 'object-calendar', calendar: CALENDAR, data: { provider: 'object', object: 'visit' } };
expect(schemaA.data).not.toBe(schemaB.data);
expect(schemaA.data).toEqual(schemaB.data);

const { rerender } = render(<ObjectCalendar schema={schemaA} dataSource={dataSource} />);
await waitFor(() => expect(screen.getByText('Site visit')).toBeTruthy());
await waitFor(() => expect(dataSource.getObjectSchema).toHaveBeenCalled());

const findCallsAtRest = dataSource.find.mock.calls.length;
const schemaCallsAtRest = dataSource.getObjectSchema.mock.calls.length;
expect(findCallsAtRest).toBeGreaterThan(0);

rerender(<ObjectCalendar schema={schemaB} dataSource={dataSource} />);
await new Promise((r) => setTimeout(r, 0));

expect(dataSource.find.mock.calls.length).toBe(findCallsAtRest);
expect(dataSource.getObjectSchema.mock.calls.length).toBe(schemaCallsAtRest);
});

it('still DOES re-fire when the recomputed `dataConfig` carries a genuinely different `object`', async () => {
const dataSource = makeDataSource();
const schemaA: any = { type: 'object-calendar', objectName: 'visit', calendar: CALENDAR };
const schemaB: any = { type: 'object-calendar', objectName: 'appointment', calendar: CALENDAR };

const { rerender } = render(<ObjectCalendar schema={schemaA} dataSource={dataSource} />);
await waitFor(() => expect(dataSource.find).toHaveBeenCalledWith('visit', expect.any(Object)));
const callsBefore = dataSource.find.mock.calls.length;

rerender(<ObjectCalendar schema={schemaB} dataSource={dataSource} />);

await waitFor(() => expect(dataSource.find.mock.calls.length).toBeGreaterThan(callsBefore));
expect(dataSource.find).toHaveBeenCalledWith('appointment', expect.any(Object));
});
});
39 changes: 28 additions & 11 deletions packages/plugin-calendar/src/ObjectCalendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,20 @@ export const ObjectCalendar: React.FC<ObjectCalendarComponentProps> = ({
(schema as any).colorField
]);
const hasInlineData = dataConfig?.provider === 'value';
/**
* The record-fetch effect below used to key on `dataConfig` itself — the
* whole memoised object identity. `useMemo` carries no semantic
* guarantee (React may discard its cache and recompute), and
* `getDataConfig(schema)` builds a fresh wrapper object on every call
* even when its own deps haven't changed, so a discard alone was enough
* to re-run the effect and refetch. `dataProvider` and `dataItems` are
* the remaining primitive fields that effect reads off `dataConfig` —
* `schemaObjectName` below already covers the `object` field for the
* same purpose. Keying on all three instead of the container object
* makes a cache discard a no-op (objectui#6592).
*/
const dataProvider = dataConfig?.provider;
const dataItems = dataConfig?.provider === 'value' ? dataConfig.items : undefined;

// ⭐ objectui#6453 — this replaces a `useRef` written in the render body
// (`objectSchemaRef.current = objectSchema`), which existed so the fetch
Expand Down Expand Up @@ -303,17 +317,17 @@ export const ObjectCalendar: React.FC<ObjectCalendarComponentProps> = ({
// 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;
if (dataProvider === 'object' && !objectSchemaReady) return;

let isMounted = true;
const fetchData = async () => {
try {
if (!isMounted) return;
setLoading(true);
if (hasInlineData && dataConfig?.provider === 'value') {

if (hasInlineData && dataProvider === 'value') {
if (isMounted) {
setData(dataConfig.items as any[]);
setData(dataItems as any[]);
setLoading(false);
}
return;
Expand All @@ -323,8 +337,11 @@ export const ObjectCalendar: React.FC<ObjectCalendarComponentProps> = ({
throw new Error('DataSource required for object/api providers');
}

if (dataConfig?.provider === 'object') {
const objectName = dataConfig.object;
if (dataProvider === 'object') {
// `schemaObjectName` already resolves this same 'object' branch's
// `dataConfig.object` (required on that discriminated-union
// variant), computed once above for the schema-fetch gate too.
const objectName = schemaObjectName as string;
// Auto-inject $expand for lookup/master_detail fields
// Reached only with the schema resolved (the gate above), so a
// calendar whose object declares relations queries WITH its
Expand All @@ -336,13 +353,13 @@ export const ObjectCalendar: React.FC<ObjectCalendarComponentProps> = ({
$orderby: convertSortToQueryParams(schema.sort),
...(expand.length > 0 ? { $expand: expand } : {}),
});

const items: any[] = extractRecords(result);

if (isMounted) {
setData(items);
}
} else if (dataConfig?.provider === 'api') {
} else if (dataProvider === 'api') {
console.warn('API provider not yet implemented for ObjectCalendar');
if (isMounted) setData([]);
}
Expand All @@ -359,8 +376,8 @@ export const ObjectCalendar: React.FC<ObjectCalendarComponentProps> = ({

fetchData();
return () => { isMounted = false; };
}, [hasExternalData, dataConfig, dataSource, hasInlineData, schema.filter, schema.sort,
refreshKey, objectSchemaReady, objectSchema]);
}, [hasExternalData, dataProvider, schemaObjectName, dataItems, dataSource, hasInlineData,
schema.filter, schema.sort, refreshKey, objectSchemaReady, objectSchema]);

// Fetch object schema for field metadata.
//
Expand Down
124 changes: 124 additions & 0 deletions packages/plugin-gantt/src/ObjectGantt.discardedConfigMemo.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/**
* 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#6592 — see `ObjectMap.discardedConfigMemo.test.tsx` for the full
* rationale. This file pins the same contract for `ObjectGantt`'s
* `reload()` fetch (the `useEffect(() => { reload(); }, [reload])` mount
* effect) and its "fetch object schema" effect.
*
* `ObjectGantt`'s own `dataConfig` memo already buys a value-stable identity
* via `useMemo(() => rawDataConfig, [JSON.stringify(rawDataConfig)])`
* (pre-dating this card, and untouched by it) — so the map/tree/calendar
* discard proxy ("a schema with a new reference but byte-identical `.data`")
* would not even recompute `dataConfig` here: the JSON-string dep would
* compare equal and the memo would keep its old cached object. So the
* discard proxy for THIS component adds an inert field to `schema.data` that
* changes value between renders — `_probe` below, never read by
* `getDataConfig`, `reload`, or `resolveDataSource` — which forces the JSON
* string (and so `dataConfig`'s identity) to change while every primitive
* either fetch effect actually reads (`provider`, `object`) stays the same.
* That is the same "different identity, same content" shape a genuine
* memo-cache discard would produce.
*
* One more thing has to hold for this to isolate the two effects under
* test rather than a third, architecturally-unavoidable one:
* `effectiveDataSource = useMemo(() => resolveDataSource(dataConfig, ...), [dataConfig, ...])`
* also depends on `dataConfig`'s identity, and objectui#6592 deliberately
* leaves it that way (see the comment on `dataItems` in `ObjectGantt.tsx` —
* `resolveDataSource` reads a provider-shaped slice of `dataConfig` that
* cannot be flattened to a fixed primitive list). For the `object` provider
* this component is tested with here, though, `resolveDataSource` returns
* the `fallback` context DataSource UNCHANGED (`packages/core/src/adapters/resolveDataSource.ts`
* — no new adapter is constructed), so `effectiveDataSource`'s value stays
* referentially the SAME object across the `_probe` churn even though its
* memo's factory reran. That is what makes the two fixed effects observable
* in isolation below; it is also why this file's own comment does not claim
* gantt is unconditionally immune to a `dataConfig` discard — see the PR
* body's "Known boundary" note for the `api`/`value` providers, where
* `resolveDataSource` allocates a fresh adapter every call and this
* decoupling does not hold.
*
* The two effects under test used to list bare `dataConfig`; after
* objectui#6592 the `reload` callback lists `dataProvider`/`dataItems` and
* the "fetch object schema" effect drops the (unused) dependency entirely.
*/

import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { ObjectGantt } from './ObjectGantt';
import { DataSource } from '@object-ui/types';

vi.mock('./GanttView', () => ({
GanttView: ({ tasks }: any) => (
<div data-testid="gantt-view">
{tasks.map((t: any) => (
<div key={t.id} data-testid="gantt-task">{t.title}</div>
))}
</div>
),
}));

const ROWS = [
{ id: '1', name: 'Task 1', start_date: '2024-01-01', end_date: '2024-01-05', progress: 50 },
];

function makeDataSource(): DataSource {
return {
find: vi.fn().mockResolvedValue({ data: ROWS }),
findOne: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
getObjectSchema: vi.fn().mockResolvedValue({ fields: { name: { type: 'text' } } }),
} as any;
}

const GANTT_CONFIG = { titleField: 'name', startDateField: 'start_date', endDateField: 'end_date' };

describe('ObjectGantt — reload/schema fetch effects survive a discarded `dataConfig` memo (objectui#6592)', () => {
it('does not re-fire either fetch when `dataConfig` recomputes to a new identity with the SAME primitive fields', async () => {
const dataSource = makeDataSource();
// `_probe` is read by nothing under test — it exists only to force
// `JSON.stringify(rawDataConfig)` to differ so `dataConfig`'s OWN memo
// (unrelated to this card, see the file docblock) recomputes to a new
// object identity while `provider`/`object` stay unchanged.
const schemaA: any = { type: 'gantt', gantt: GANTT_CONFIG, data: { provider: 'object', object: 'tasks', _probe: 'a' } };
const schemaB: any = { type: 'gantt', gantt: GANTT_CONFIG, data: { provider: 'object', object: 'tasks', _probe: 'b' } };

const { rerender } = render(<ObjectGantt schema={schemaA} dataSource={dataSource} />);
await waitFor(() => expect(screen.getAllByTestId('gantt-task')).toHaveLength(1));
await waitFor(() => expect((dataSource.getObjectSchema as any)).toHaveBeenCalled());

const findCallsAtRest = (dataSource.find as any).mock.calls.length;
const schemaCallsAtRest = (dataSource.getObjectSchema as any).mock.calls.length;
expect(findCallsAtRest).toBeGreaterThan(0);

rerender(<ObjectGantt schema={schemaB} dataSource={dataSource} />);
await new Promise((r) => setTimeout(r, 0));

expect((dataSource.find as any).mock.calls.length).toBe(findCallsAtRest);
expect((dataSource.getObjectSchema as any).mock.calls.length).toBe(schemaCallsAtRest);
});

it('still DOES re-fire when the recomputed `dataConfig` carries a genuinely different `object`', async () => {
const dataSource = makeDataSource();
const schemaA: any = { type: 'gantt', gantt: GANTT_CONFIG, data: { provider: 'object', object: 'tasks' } };
const schemaB: any = { type: 'gantt', gantt: GANTT_CONFIG, data: { provider: 'object', object: 'milestones' } };

const { rerender } = render(<ObjectGantt schema={schemaA} dataSource={dataSource} />);
await waitFor(() => expect(dataSource.find).toHaveBeenCalledWith('tasks', expect.any(Object)));
const callsBefore = (dataSource.find as any).mock.calls.length;

rerender(<ObjectGantt schema={schemaB} dataSource={dataSource} />);

await waitFor(() => expect((dataSource.find as any).mock.calls.length).toBeGreaterThan(callsBefore));
expect(dataSource.find).toHaveBeenCalledWith('milestones', expect.any(Object));
});
});
28 changes: 23 additions & 5 deletions packages/plugin-gantt/src/ObjectGantt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -598,7 +598,22 @@ export const ObjectGantt: React.FC<ObjectGanttProps> = ({
}, [JSON.stringify(rawDataConfig)]);

const ganttConfig = getGanttConfig(schema);
const hasInlineData = dataConfig?.provider === 'value';
const dataProvider = dataConfig?.provider;
const hasInlineData = dataProvider === 'value';
/**
* The one primitive field `reload` (below) reads off `dataConfig` beyond
* `dataProvider` — the inline-data payload for the `value` provider.
* `reload` used to key on `dataConfig` itself: `useMemo` carries no
* semantic guarantee (React may discard its cache and recompute), and a
* discard alone was enough to give `reload` a fresh identity and re-fire
* the mount effect below, refetching. `effectiveDataSource`'s own memo
* intentionally keeps `dataConfig` as a dependency (not just its
* `object`/`items` primitives): `resolveDataSource` reads a
* provider-shaped slice of it (the whole `read`/`write` request config
* on `api`), which cannot be flattened to a fixed primitive list the way
* the 'object'/'value' branches below can be (objectui#6592).
*/
const dataItems = dataConfig?.provider === 'value' ? dataConfig.items : undefined;

// Resolve the ViewData config into a concrete DataSource adapter:
// provider: 'object' → the context DataSource passed via props (unchanged)
Expand Down Expand Up @@ -644,8 +659,8 @@ export const ObjectGantt: React.FC<ObjectGanttProps> = ({
return;
}

if (hasInlineData && dataConfig?.provider === 'value') {
if (isCurrent()) setData(dataConfig.items as any[]);
if (hasInlineData && dataProvider === 'value') {
if (isCurrent()) setData(dataItems as any[]);
return;
}

Expand Down Expand Up @@ -675,7 +690,7 @@ export const ObjectGantt: React.FC<ObjectGanttProps> = ({
else setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- (rest as any).data intentionally untracked, matching the original effect
}, [effectiveDataSource, resource, hasInlineData, dataConfig, schema.filter, schema.sort, objectSchema]);
}, [effectiveDataSource, resource, hasInlineData, dataProvider, dataItems, schema.filter, schema.sort, objectSchema]);

useEffect(() => {
reload();
Expand All @@ -698,7 +713,10 @@ export const ObjectGantt: React.FC<ObjectGanttProps> = ({
if (!hasInlineData && effectiveDataSource) {
fetchObjectSchema();
}
}, [resource, effectiveDataSource, hasInlineData, dataConfig]);
// `dataConfig` was listed here but never read in this effect (`resource`
// already carries the one field — `object` — this effect needs from
// it); dropped rather than re-keyed (objectui#6592).
}, [resource, effectiveDataSource, hasInlineData]);

// Transform data to gantt tasks
const tasks = useMemo(() => {
Expand Down
Loading
Loading