Skip to content
This repository was archived by the owner on Oct 26, 2026. It is now read-only.

Commit 8cd4db4

Browse files
fix(components): recompile a react page on its content, not its schema identity (#13269)
Fixes #13268 Clause-②: no ## What changed `ReactKindPage` (`packages/components/src/renderers/layout/react-page.tsx`) built its compile scope in a `useMemo` keyed on `[schema, adapter]`. `ReactRunner` recompiles whenever the scope identity changes, and every compile mints a new `Page` function, so a new schema object with the same content remounted the page and wiped its `useState`. The console's `PageView` hands `SchemaRenderer` a fresh spread of the page on every render, so any `PageView` re-render reset a react page (measured on the showcase Task Desk: `editId` reset and the open record drawer vanished). The scope is now rebuilt only when what it is compiled from changes: - the schema's **content**, compared structurally by a file-local `sameContent` helper (plain objects and arrays by their own keys, everything else by `Object.is`, cycle-safe). `page` in the scope carries the whole schema, so this covers `source`, `data`, `variables`, and every other page value the page can read (`context.params` included); - the adapter, by identity (unchanged; the reason it stays a recompile trigger is kept in the comment). The cached scope lives in `useState` and is replaced during render when the content moves (React's documented shape for state derived from props), not in a `useMemo`: React may discard a memo, and here a new object is a remount (AGENTS.md #10). A first draft held it in a ref; `react-hooks/refs` flags reading a ref during render, so it moved to state. ## Seam chosen, and why `ReactKindPage`, not `PageView`. The defect is that the page keys its compile on identity; any host can hand it a fresh object, and `PageView` is only the one measured. Stabilising `PageView`'s spread would fix that host and leave the contract identity-sensitive for every other. `PageView.tsx` is untouched; StrictMode is untouched; `PageView` re-renders are not suppressed. No new export or prop. ## Pins (`packages/components/src/__tests__/react-page-content-identity.test.tsx`, all under `React.StrictMode`) - (a) a react page with `useState` set by a click keeps it across two host re-renders that each hand it a new but equal schema object; - (b) CONTROL: a changed `source` recompiles and the page resets; - (c) CONTROL: a changed declared scope value (`variables.greeting`) recompiles, the new value reaches the page, and the page resets. Measured: scope values are compiled in, not passed as props, so a value change reaches the page only through a recompile; - (c2) CONTROL: a changed nested page value (`context.params`) recompiles too. `react-page-state.test.tsx` already had "survives a parent re-render", but its host re-renders with a module-scope schema, the one shape a real host does not produce; its header comment is updated to the new keying. ## Evidence - Red on the unfixed tree (base `1ca759a16f`): (a) failed, `Tests 1 failed | 3 passed (4)`; the three controls passed. - Ablation on the final implementation (head `c016ba798f`), via `ablation-replace.mjs`: `!sameContent(compiled.schema, schema)` replaced by `compiled.schema !== schema` (anchor x1 to x0, blob `149963d2927e` to `16dddb7d4244`); (a) went red, `Tests 1 failed | 3 passed (4)`. Restored by the tool's trap: blob after restore `149963d2927e6dc51a1ff183e1ca24863ab6e077` equals the HEAD blob, `git diff HEAD` empty. The test imports the renderer through `../renderers` (source), so no build was in the path. - Test family naming the touched file (20 existing files that name `react-page` / `ReactKindPage` across components, console, plugin-charts, plugin-list, sdui-parser, types, core, plus the new file), `--maxWorkers=1`, at `c016ba798f`: `Test Files 21 passed (21)`, `Tests 764 passed (764)`. - `pnpm --filter @object-ui/components type-check` (after `pnpm --filter '@object-ui/components^...' build`) exit 0 at `c016ba798f`; `tsconfig.test.json --listFiles` includes the new test. - `eslint --no-inline-config` on the 3 changed TS files: 0 errors. `react-page.tsx` warnings: the same six pre-existing `no-explicit-any` as base, minus the base's `react-hooks/exhaustive-deps` (the memo it sat on is gone). - Pre-tsc battery: `check:phantom-deps`, `self-import`, `unreferenced-sources`, `doc-example-readers`, `handler-key-reads`, `side-effects-array`, `element-data-source-declaration`, `spec-symbols`, `action-forward-parity`, `designer-field-key-parity`, `icon-record-names`, `i18n-keys`, `i18n-drift`, `i18n-designer-parity`, `control-bytes`, `check-hand-rolled-comment-mask.mjs`, `check-changeset-fixed.mjs`, `check-changeset-presence.mjs`, `check:new-line-citations` all exit 0. `check:spec-floors` exit 1 with "no-artifact: Build the workspace before running this gate": NOT MEASURED (it reads built artifacts, and no full workspace build was run here). Declared to CI: the full `packages/components/` directory (no whole-directory run here; the container's test lock is shared). ## Acceptance notes - `PageView.tsx` still hands a fresh spread every render. That is now harmless to react pages; other blocks under it are not in this card's scope. - A host that puts a non-plain value (a function, a `Date`) in the page schema and hands a new one every render will still recompile each time: `sameContent` compares such values by identity, the conservative answer. Whether any producer of page metadata does that was not searched. Changeset: `.changeset/13268-react-page-identity.md` (patch, `@object-ui/components`). Session: `https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB` --- _Generated by [Claude Code](https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a29f615 commit 8cd4db4

4 files changed

Lines changed: 200 additions & 26 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@object-ui/components': patch
3+
---
4+
5+
A `kind:'react'` page keeps its own state when its host re-renders (objectui#13268). The page was recompiled whenever it received a new schema object, and the console hands it a fresh copy of the page on every render, so a re-render remounted the page: a selected record reset and an open drawer closed. The page now recompiles only when its content changes (its `source`, its `data` / `variables`, or any other page value it can read) or the adapter does. A changed source or value still recompiles and resets the page, as before.
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*
8+
* A `kind:'react'` page recompiles on CONTENT, not on schema identity
9+
* (objectui#13268).
10+
*
11+
* The console's `PageView` hands `SchemaRenderer` a fresh spread of the page
12+
* schema on every render, and any other host may do the same. `ReactKindPage`
13+
* used to key its scope on that object's identity, so every host re-render
14+
* recompiled the page — a new `Page` function, a new element type, a remount —
15+
* and the page's own `useState` was lost (measured on the showcase Task Desk:
16+
* its `editId` reset and an open record drawer vanished).
17+
*
18+
* `react-page-state.test.tsx` did not catch it: its host re-renders with a
19+
* module-scope schema, the one shape a real host does not produce. These pins
20+
* re-render with a NEW object of EQUAL content, under StrictMode, and keep the
21+
* controls that stop "stable" from meaning "stale": a changed source or a
22+
* changed declared scope value still recompiles.
23+
*/
24+
25+
import { describe, it, expect } from 'vitest';
26+
import { render, fireEvent } from '@testing-library/react';
27+
import React from 'react';
28+
import { SchemaRenderer, AdapterCtx } from '@object-ui/react';
29+
// Registers PageRenderer for `type:'home'`, which dispatches kind:'react'. At
30+
// module scope, not in a hook (object-ui/no-dynamic-import-in-test-hook).
31+
import '../renderers';
32+
33+
const adapter = { find: async () => [], getObjectSchema: async () => ({ name: 'x', fields: {} }) } as any;
34+
35+
/** A page with local state, which also prints a declared scope value. */
36+
const counterSource = (extra = '') => `
37+
function Page() {
38+
const [n, setN] = React.useState(0);
39+
return (
40+
<div>
41+
<button data-testid="counter" onClick={() => setN(n + 1)}>{n}</button>
42+
<span data-testid="greeting">{variables.greeting}</span>
43+
${extra}
44+
</div>
45+
);
46+
}`;
47+
48+
/** A FRESH schema object on every call, the shape `PageView` hands over. */
49+
function pageSchema(over: Record<string, unknown> = {}) {
50+
return {
51+
type: 'home',
52+
kind: 'react',
53+
name: 'identity_page',
54+
source: counterSource(),
55+
variables: { greeting: 'hello' },
56+
context: { params: { appName: 'showcase' } },
57+
...over,
58+
};
59+
}
60+
61+
function Host({ tick, schema }: { tick: number; schema: any }) {
62+
return (
63+
<React.StrictMode>
64+
<AdapterCtx.Provider value={adapter}>
65+
<span data-testid="tick">{tick}</span>
66+
<SchemaRenderer schema={schema} />
67+
</AdapterCtx.Provider>
68+
</React.StrictMode>
69+
);
70+
}
71+
72+
async function mountAndClick() {
73+
const utils = render(<Host tick={0} schema={pageSchema()} />);
74+
fireEvent.click(await utils.findByTestId('counter'));
75+
expect(utils.getByTestId('counter').textContent).toBe('1');
76+
return utils;
77+
}
78+
79+
describe("kind:'react' page recompiles on content, not schema identity (objectui#13268)", () => {
80+
it('keeps its state across a host re-render that hands it a new but equal schema object', async () => {
81+
const { getByTestId, rerender } = await mountAndClick();
82+
83+
rerender(<Host tick={1} schema={pageSchema()} />);
84+
rerender(<Host tick={2} schema={pageSchema()} />);
85+
86+
expect(getByTestId('tick').textContent).toBe('2');
87+
// '0' means the page was remounted: a new schema object with the same
88+
// content recompiled it.
89+
expect(getByTestId('counter').textContent).toBe('1');
90+
});
91+
92+
it('CONTROL: a changed source recompiles, and the page resets', async () => {
93+
const { findByTestId, getByTestId, rerender } = await mountAndClick();
94+
95+
rerender(<Host tick={1} schema={pageSchema({ source: counterSource('<i data-testid="marker" />') })} />);
96+
97+
expect(await findByTestId('marker')).toBeTruthy();
98+
expect(getByTestId('counter').textContent).toBe('0');
99+
});
100+
101+
it('CONTROL: a changed declared scope value recompiles, reaches the page, and the page resets', async () => {
102+
const { findByText, getByTestId, rerender } = await mountAndClick();
103+
expect(getByTestId('greeting').textContent).toBe('hello');
104+
105+
rerender(<Host tick={1} schema={pageSchema({ variables: { greeting: 'bonjour' } })} />);
106+
107+
expect(await findByText('bonjour')).toBeTruthy();
108+
// Scope values are compiled in, not passed as props: the new value reaches
109+
// the page through a recompile, so the page's state resets with it.
110+
expect(getByTestId('counter').textContent).toBe('0');
111+
});
112+
113+
it('CONTROL: a changed nested page value (a route param) recompiles too', async () => {
114+
const { getByTestId, rerender } = await mountAndClick();
115+
116+
rerender(<Host tick={1} schema={pageSchema({ context: { params: { appName: 'crm' } } })} />);
117+
118+
// `page` is a declared scope value, and `context.params` rides it.
119+
expect(getByTestId('counter').textContent).toBe('0');
120+
});
121+
});

‎packages/components/src/__tests__/react-page-state.test.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111
* compile — a new element TYPE, which React remounts. So the page's `useState`
1212
* survives only as long as nothing recompiles it, and that holds only while
1313
* BOTH links in the chain stay identity-stable: `ReactRunner` memoises on
14-
* `(code, scope)`, and `ReactKindPage` memoises `scope` on `[schema, adapter]`.
14+
* `(code, scope)`, and `ReactKindPage` rebuilds `scope` only when the schema's
15+
* CONTENT or the adapter changes (objectui#13268; a new but equal schema object
16+
* is pinned in `react-page-content-identity.test.tsx`).
1517
*
1618
* Break either link and nothing fails loudly. Types check, every scope-contract
1719
* test still passes, the page still renders — it just silently drops whatever

‎packages/components/src/renderers/layout/react-page.tsx‎

Lines changed: 71 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,57 @@ function buildComponentScope(): Record<string, React.ComponentType<any>> {
148148
* an adapter.
149149
*/
150150

151+
/**
152+
* The scope a react page's `source` is compiled against. Everything in it is
153+
* either module-level (the block wrappers resolve through the registry when
154+
* they render, the hooks are module functions) or read off `schema`, so equal
155+
* schema content yields an equivalent scope.
156+
*/
157+
function buildPageScope(schema: { data?: unknown; variables?: unknown } | null | undefined): Record<string, unknown> {
158+
return {
159+
...buildComponentScope(),
160+
// Live data access — `const adapter = useAdapter()` inside the page, then
161+
// adapter.find('object', {...}) / .create / .update. Hooks injected as
162+
// closure vars; the page calls them from its own component body.
163+
useAdapter,
164+
// The data-invalidation bus reader (objectui#10887), the same hook
165+
// `ListView` reads to refresh its rows. `useDataInvalidation('object')`
166+
// returns a nonce that moves when the bus reports a write to that object
167+
// (or `'*'`); a page names it in the dependency list of the effect that
168+
// reads through `useAdapter`, and the read re-runs in place, with no
169+
// remount. A module-level function, so it leaves the scope's identity
170+
// exactly as stable as before.
171+
useDataInvalidation,
172+
data: schema?.data ?? schema?.variables ?? {},
173+
variables: schema?.variables ?? {},
174+
page: schema ?? {},
175+
};
176+
}
177+
178+
/**
179+
* Structural equality for page metadata: plain objects and arrays compare by
180+
* their own enumerable keys, everything else by `Object.is`. A value that is
181+
* not plain data (a function, a `Date`, a class instance) is equal only to
182+
* itself, so a host that hands over a new one recompiles the page, which is
183+
* the conservative answer. `seen` keeps a cyclic value from recursing forever.
184+
*/
185+
function sameContent(a: unknown, b: unknown, seen: Map<object, object> = new Map()): boolean {
186+
if (Object.is(a, b)) return true;
187+
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false;
188+
const proto = Object.getPrototypeOf(a);
189+
if (proto !== Object.getPrototypeOf(b)) return false;
190+
if (proto !== Object.prototype && proto !== Array.prototype && proto !== null) return false;
191+
if (seen.get(a) === b) return true;
192+
seen.set(a, b);
193+
const aKeys = Object.keys(a);
194+
if (aKeys.length !== Object.keys(b).length) return false;
195+
for (const key of aKeys) {
196+
if (!Object.prototype.hasOwnProperty.call(b, key)) return false;
197+
if (!sameContent((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key], seen)) return false;
198+
}
199+
return true;
200+
}
201+
151202
function CapabilityDisabledNotice(): React.ReactElement {
152203
return (
153204
<div className="m-4 rounded-md border border-amber-400/40 bg-amber-50 p-4 text-sm text-amber-900 dark:bg-amber-950/30 dark:text-amber-200">
@@ -195,36 +246,31 @@ export const ReactKindPage: React.FC<{ schema: any }> = ({ schema }) => {
195246
// scope there would reset every interactive page on the screen. It doesn't
196247
// need to — `buildComponentScope` already sees lazy blocks (objectui#2953).
197248
//
198-
// `adapter` stays in the dependency list although the scope no longer
199-
// carries it (objectui#11070). The blocks follow a swapped adapter through the
249+
// The scope is rebuilt only when what it is compiled FROM changes: the
250+
// schema's CONTENT (`page` carries all of it, `data` / `variables` are read
251+
// off it) or the adapter. Not the schema object's identity — the console's
252+
// `PageView` hands a fresh spread of the page on every render, and any host
253+
// may, so keying on identity remounted the page on every host re-render
254+
// (objectui#13268). The scope is held in state, not in a `useMemo`: React
255+
// may discard a memo and hand back a new object, and here a new object IS a
256+
// remount (AGENTS.md #10). When the content does change, the new scope is
257+
// stored during render, React's pattern for state derived from props.
258+
//
259+
// `adapter` stays a rebuild trigger although the scope no longer carries it
260+
// (objectui#11070). The blocks follow a swapped adapter through the
200261
// provider below without a recompile. The page's OWN code may not: an effect
201262
// that reads through `useAdapter()` and omits `adapter` from its dependencies
202263
// ran once, against the absent adapter, and the recompile on a swap (the
203-
// host connecting one) is what re-runs it. Dropping the dependency would keep
264+
// host connecting one) is what re-runs it. Dropping the trigger would keep
204265
// the page's state across a swap, but it would also leave such a page empty.
205266
// That is a separate behaviour change, not made here.
206-
const scope = React.useMemo(
207-
() => ({
208-
...buildComponentScope(),
209-
// Live data access — `const adapter = useAdapter()` inside the page, then
210-
// adapter.find('object', {...}) / .create / .update. Hooks injected as
211-
// closure vars; the page calls them from its own component body.
212-
useAdapter,
213-
// The data-invalidation bus reader (objectui#10887), the same hook
214-
// `ListView` reads to refresh its rows. `useDataInvalidation('object')`
215-
// returns a nonce that moves when the bus reports a write to that object
216-
// (or `'*'`); a page names it in the dependency list of the effect that
217-
// reads through `useAdapter`, and the read re-runs in place, with no
218-
// remount. A module-level function, so it leaves the scope's identity
219-
// exactly as stable as before.
220-
useDataInvalidation,
221-
data: schema?.data ?? schema?.variables ?? {},
222-
variables: schema?.variables ?? {},
223-
page: schema ?? {},
224-
}),
225-
// eslint-disable-next-line react-hooks/exhaustive-deps -- `adapter` is a recompile trigger kept on purpose, not a read; the reason is above this memo.
226-
[schema, adapter],
227-
);
267+
const [compiled, setCompiled] = React.useState(() => ({ schema, adapter, scope: buildPageScope(schema) }));
268+
let scope = compiled.scope;
269+
if (compiled.adapter !== adapter || !sameContent(compiled.schema, schema)) {
270+
const next = { schema, adapter, scope: buildPageScope(schema) };
271+
setCompiled(next);
272+
scope = next.scope;
273+
}
228274

229275
// Capability gate — returned after all hooks above so hook order stays stable.
230276
if (!capabilityEnabled) {

0 commit comments

Comments
 (0)