Skip to content

Commit 5b084eb

Browse files
authored
fix(sdui): stop the react page's "no adapter yet" fallback churning its provider context (#3000)
Audit of the remaining half of ReactKindPage's scope memo, [schema, adapter]. The schema half was the live bug fixed in #2984; this is the adapter half. The hosts are fine — both AdapterCtx.Provider call sites pass a stable value (AdapterProvider from useState, the console preview from a module constant), so there is no state loss in the shipped app. One real instance remained, one layer down: `dataSource={adapter ?? {}}` minted a fresh object every render while the adapter was still null (the window before the host connects). That is a context value and SchemaRendererProvider memoises on its identity, so every block inside the page had its schema re-cloned and its expressions re-run on each render. Now a module constant. The `adapter` dependency itself must stay, and is now pinned. It looks like the obvious thing to optimise away, but ReactRunner hands React the same element object while (code, scope) hold, and React bails out on an identical element reference — so recompiling is the ONLY path by which a new adapter reaches the blocks inside the page. Verified by removing it: every block stays pinned to the first adapter forever, with no error, just a dead data source. react-page-adapter.test.tsx pins both directions. Docs: the react-pages guide now states the host-side requirement — an adapter constructed inline on every render resets every react page on every render.
1 parent ea99466 commit 5b084eb

4 files changed

Lines changed: 176 additions & 3 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
"@object-ui/components": patch
3+
---
4+
5+
fix(sdui): the react page's "no adapter yet" fallback stops churning its provider context
6+
7+
Audit of the remaining half of `ReactKindPage`'s scope memo, `[schema, adapter]`.
8+
The `schema` half was the live bug fixed in objectui#2984; this is the adapter
9+
half.
10+
11+
**The hosts are fine.** Both `AdapterCtx.Provider` call sites pass a stable
12+
value — `AdapterProvider` from `useState`, the console preview from a module
13+
constant — so there is no state loss in the shipped app.
14+
15+
**One real instance remained**, one layer down: `<SchemaRendererProvider
16+
dataSource={adapter ?? {}}>` minted a fresh object on every render while the
17+
adapter was still null (the window before the host connects). That is a context
18+
value, and `SchemaRendererProvider` memoises on its identity, so every block
19+
inside the page had its schema re-cloned and its expressions re-run on each
20+
render of the page. Now a module constant, like the `SchemaRenderer` fallback
21+
it mirrors.
22+
23+
**The `adapter` dependency itself must stay**, and is now pinned. It looks like
24+
the obvious thing to optimise away — it is the last remaining trigger that can
25+
recompile a page and cost its `useState`. But `ReactRunner` hands React the same
26+
element object while `(code, scope)` hold, and React bails out on an identical
27+
element reference, so the page subtree never re-renders on its own: recompiling
28+
is the *only* path by which a new adapter reaches the blocks inside the page.
29+
Removing the dependency strands every block on the first adapter forever — no
30+
error, just a dead data source. `react-page-adapter.test.tsx` pins both
31+
directions, so the tradeoff cannot be quietly re-litigated.
32+
33+
Docs: the react-pages guide now states the host-side requirement — an adapter
34+
constructed inline on every render resets every react page on every render.

content/docs/guide/react-pages.md

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,8 +167,27 @@ injected — use HTML) or a block outside the public contract (use `Block`).
167167
### Page state
168168

169169
A react page keeps its own `useState` across re-renders and across lazy plugin
170-
loads. Two things reset it, both intentional: a change to `source`, and a change
171-
to the page's data/variables — the page is genuinely a different page then.
170+
loads. Three things reset it, all intentional: a change to `source`, a change to
171+
the page's data/variables, and a **new data source** — the page is genuinely a
172+
different page then.
173+
174+
That last one is a requirement on the **host**, not the author. The page is
175+
recompiled when the adapter's *identity* changes, because recompiling is the
176+
only way the new adapter reaches the blocks inside the page. So a host that
177+
constructs a new adapter on every render resets every react page on every
178+
render. Provide it from state or a module constant:
179+
180+
```tsx
181+
// ❌ new adapter object every render — every react page below loses its state
182+
<AdapterCtx.Provider value={new ObjectStackAdapter(config)}>
183+
184+
// ✅
185+
const [adapter, setAdapter] = useState<ObjectStackAdapter | null>(null);
186+
<AdapterCtx.Provider value={adapter}>
187+
```
188+
189+
`@object-ui/app-shell`'s `AdapterProvider` already does this correctly; the rule
190+
matters for custom hosts and preview surfaces.
172191

173192
## `kind:'html'`
174193

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
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+
* The adapter reaches a `kind:'react'` page's blocks, and keeps reaching them
9+
* when the host swaps it.
10+
*
11+
* `ReactKindPage` memoises the injected scope on `[schema, adapter]`, and that
12+
* `adapter` dependency looks like something to optimise away — it is the one
13+
* remaining thing that can recompile the page and cost its `useState`
14+
* (objectui#2954). It cannot go.
15+
*
16+
* `ReactRunner` returns the SAME element object while `(code, scope)` are
17+
* unchanged, and React bails out of re-rendering a child whose element is
18+
* referentially identical. So the page subtree does not re-render on its own:
19+
* recompiling is the ONLY way a new adapter reaches the blocks inside the page.
20+
* Drop the dependency (or read the adapter through a ref to "keep the scope
21+
* stable") and every block in every react page silently keeps querying through
22+
* the dead adapter — no error, just stale or failing data.
23+
*
24+
* These pin both halves so the tradeoff cannot be quietly re-litigated:
25+
* a swapped adapter must reach the blocks, and an unchanged one must not
26+
* disturb them.
27+
*/
28+
29+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
30+
import { render, waitFor } from '@testing-library/react';
31+
import React from 'react';
32+
import { ComponentRegistry } from '@object-ui/core';
33+
import { SchemaRenderer, AdapterCtx } from '@object-ui/react';
34+
35+
/** The dataSource each render of the stand-in block was handed. */
36+
const seen: unknown[] = [];
37+
38+
const makeAdapter = (id: string) => ({ id, find: async () => [] }) as any;
39+
40+
const SOURCE = `
41+
function Page() {
42+
const [n, setN] = React.useState(0);
43+
return (
44+
<div>
45+
<button data-testid="counter" onClick={() => setN(n + 1)}>{n}</button>
46+
<ListView objectName="showcase_project" />
47+
</div>
48+
);
49+
}`;
50+
51+
const SCHEMA = { type: 'home', kind: 'react', name: 'adapter_page', source: SOURCE };
52+
53+
beforeAll(async () => {
54+
await import('../renderers');
55+
ComponentRegistry.register('list-view', (props: any) => {
56+
seen.push(props.schema?.dataSource);
57+
return <div data-testid="list-view-double" />;
58+
});
59+
}, 30000);
60+
61+
afterAll(() => {
62+
ComponentRegistry.unregister('list-view');
63+
});
64+
65+
function Host({ adapter }: { adapter: any }) {
66+
return (
67+
<AdapterCtx.Provider value={adapter}>
68+
<SchemaRenderer schema={SCHEMA} />
69+
</AdapterCtx.Provider>
70+
);
71+
}
72+
73+
describe('kind:\'react\' page ↔ adapter identity', () => {
74+
it('hands blocks the new adapter when the host swaps it', async () => {
75+
seen.length = 0;
76+
const first = makeAdapter('first');
77+
const second = makeAdapter('second');
78+
79+
const { findByTestId, rerender } = render(<Host adapter={first} />);
80+
await findByTestId('list-view-double');
81+
expect(seen.at(-1)).toBe(first);
82+
83+
rerender(<Host adapter={second} />);
84+
85+
// Nothing else re-renders the page subtree — ReactRunner hands React the
86+
// same element object while (code, scope) hold, and React bails out on an
87+
// identical element reference. Recompiling on the new scope is what carries
88+
// the adapter in.
89+
await waitFor(() => expect(seen.at(-1)).toBe(second));
90+
});
91+
92+
it('leaves the page alone while the adapter identity holds', async () => {
93+
seen.length = 0;
94+
const adapter = makeAdapter('stable');
95+
96+
const { findByTestId, getByTestId, rerender } = render(<Host adapter={adapter} />);
97+
const counter = await findByTestId('counter');
98+
counter.click();
99+
await waitFor(() => expect(getByTestId('counter').textContent).toBe('1'));
100+
101+
rerender(<Host adapter={adapter} />);
102+
103+
// The other half of the tradeoff: the same adapter must not recompile, or
104+
// the page loses its state on every parent render.
105+
expect(getByTestId('counter').textContent).toBe('1');
106+
expect(seen.at(-1)).toBe(adapter);
107+
});
108+
});

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,18 @@ function buildComponentScope(dataSource: unknown): Record<string, React.Componen
9393
return scope;
9494
}
9595

96+
/**
97+
* Stand-in for "no adapter yet" — the window before the host's AdapterProvider
98+
* finishes connecting, and any surface that renders a react page without one.
99+
*
100+
* A module constant, not an inline `?? {}`: this is a context value, and
101+
* SchemaRendererProvider memoises on its identity. A fresh object per render
102+
* would break that memo for every block inside the page, re-cloning each
103+
* block's schema and re-running its expressions on every render of the page —
104+
* the same defect the SchemaRenderer fallback had (objectui#2954).
105+
*/
106+
const NO_DATA_SOURCE = {};
107+
96108
function CapabilityDisabledNotice(): React.ReactElement {
97109
return (
98110
<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">
@@ -179,7 +191,7 @@ export const ReactKindPage: React.FC<{ schema: any }> = ({ schema }) => {
179191

180192
const { ReactRunner } = runtime;
181193
return (
182-
<SchemaRendererProvider dataSource={adapter ?? {}}>
194+
<SchemaRendererProvider dataSource={adapter ?? NO_DATA_SOURCE}>
183195
<ReactRunner
184196
code={source}
185197
scope={scope}

0 commit comments

Comments
 (0)