From 1aff06bd4884c0b83d0e9d5003035f4f526a99f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 02:53:51 +0000 Subject: [PATCH 1/2] fix(sdui): lazy public blocks reach a kind:'react' page scope; ReactRunner keeps its errors (#2953, #2954) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects in the trusted `kind:'react'` page tier. objectui#2953 — the contract skipped lazily-registered blocks. `getPublicConfigs()` resolved every curated `PUBLIC_BLOCKS` tag through `getConfig()`, which reads loaded registrations only, so a block registered via `registerLazy()` was absent from the contract until its plugin chunk happened to be imported. In apps/console that silently dropped `object-kanban`, `object-calendar`, `object-gantt`, `object-timeline`, `object-map` and `markdown` from every react page's scope: `` threw a ReferenceError even though the tag is a first-class contract member, and whether it threw depended on load order. `getPublicConfigs()` now resolves pending lazy stubs too, returning them with `lazy: true` and no `component` (new `PublicComponentConfig` type). The injected wrapper renders through `SchemaRenderer`, which already triggers the loader and shows its placeholder, so the scope is complete at build time and never has to be rebuilt. `getConfig()` stays loaded-only by design — callers read `.component` off it — and now says so. objectui#2954 — ReactRunner discarded the errors it caught. `getDerivedStateFromProps` re-transpiled and re-evaluated the page source on every render and unconditionally set `error: null`. React runs it before the re-render that follows `getDerivedStateFromError`, so the boundary threw away the error it had just caught, rebuilt an identical throwing element, and the throw escaped past its own `fallback` to the renderer's generic panel; `onError` was gated on state that had already been cleared, and never fired at all for a compile-time error; and every eval minted a fresh page function — a new element type — remounting the subtree and wiping the page's `useState`. The transpile+eval is now memoised on `(code, scope)`, errors persist until those inputs actually change, and `onError` reports each error exactly once (including at mount). That also makes the scope's identity stability load bearing, which is why the react page deliberately does not subscribe to registry changes — the #2953 fix means it no longer needs to. Tests: `@object-ui/react-runtime` had none; adds a suite covering the boundary, recovery, onError, compile memoisation and mount stability (6 of 12 fail against the old runner). Adds lazy-contract cases to the registry's public-tier tests and an end-to-end lazy block to the react-page scope tests, and pins the "React page error" panel the negative control previously had to route around. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01N4mrr1ihhwnfEHFSWmGoMp --- .../react-page-lazy-scope-and-runner-memo.md | 34 +++ .../src/__tests__/react-page-scope.test.tsx | 85 ++++++-- .../src/renderers/layout/react-page.tsx | 16 ++ packages/core/src/registry/Registry.ts | 66 +++++- .../registry/__tests__/public-tier.test.ts | 79 +++++++ .../src/__tests__/ReactRunner.test.tsx | 198 ++++++++++++++++++ packages/react-runtime/src/index.tsx | 42 +++- 7 files changed, 496 insertions(+), 24 deletions(-) create mode 100644 .changeset/react-page-lazy-scope-and-runner-memo.md create mode 100644 packages/react-runtime/src/__tests__/ReactRunner.test.tsx diff --git a/.changeset/react-page-lazy-scope-and-runner-memo.md b/.changeset/react-page-lazy-scope-and-runner-memo.md new file mode 100644 index 0000000000..195ca6869c --- /dev/null +++ b/.changeset/react-page-lazy-scope-and-runner-memo.md @@ -0,0 +1,34 @@ +--- +"@object-ui/core": patch +"@object-ui/react-runtime": patch +"@object-ui/components": patch +--- + +fix(sdui): lazily-registered public blocks reach a `kind:'react'` page's scope, and ReactRunner keeps the errors it catches + +Two defects in the trusted `kind:'react'` page tier. + +**objectui#2953 — the contract skipped lazy blocks.** `getPublicConfigs()` +resolved every curated `PUBLIC_BLOCKS` tag through `getConfig()`, which reads +loaded registrations only, so a block registered with `registerLazy()` was +absent from the contract until its plugin chunk happened to be imported. In +`apps/console` that silently dropped `object-kanban`, `object-calendar`, +`object-gantt`, `object-timeline`, `object-map` and `markdown` from every react +page's scope — writing `` threw `ReferenceError` even though the +tag is a first-class contract member, and whether it threw depended on load +order. `getPublicConfigs()` now resolves pending lazy stubs too, returning them +with `lazy: true` and no `component` (new `PublicComponentConfig` type); the +injected wrapper renders through `SchemaRenderer`, which triggers the loader and +shows its placeholder. `getConfig()` stays loaded-only by design. + +**objectui#2954 — ReactRunner discarded its own error state.** +`getDerivedStateFromProps` re-transpiled and re-evaluated the page source on +every render and unconditionally set `error: null`. React runs it before the +re-render that follows `getDerivedStateFromError`, so the boundary threw away +the error it had just caught, rebuilt an identical throwing element, and the +throw escaped past its own `fallback` to the renderer's generic panel; `onError` +was gated on state that had already been cleared and never fired for a +compile-time error at all; and each compile minted a fresh page function — a new +element type — that remounted the subtree and wiped the page's `useState`. The +transpile+eval is now memoised on `(code, scope)`, errors persist until the +inputs actually change, and `onError` reports each error exactly once. diff --git a/packages/components/src/__tests__/react-page-scope.test.tsx b/packages/components/src/__tests__/react-page-scope.test.tsx index 3ccde8bd40..d850c17d89 100644 --- a/packages/components/src/__tests__/react-page-scope.test.tsx +++ b/packages/components/src/__tests__/react-page-scope.test.tsx @@ -16,12 +16,15 @@ * PUBLIC_BLOCKS or flip it to `isContainer` and it silently vanishes from every * react page's scope while type-check, lint and build all stay green. * - * These tests pin the three halves of that contract: + * These tests pin the four halves of that contract: * 1. `list-view` / `object-form` stay eligible for injection; * 2. an author writes `` with FLAT props and no import, and the * wrapper folds them into the block's `schema`; - * 3. an identifier that is genuinely absent fails LOUDLY — the negative - * control that keeps (2) from passing vacuously. + * 3. a block that is only *lazily* registered is in the scope too — the + * contract may not depend on which plugin chunks happen to be loaded + * (objectui#2953); + * 4. an identifier that is genuinely absent fails LOUDLY — the negative + * control that keeps (2) and (3) from passing vacuously. * * Registered stand-ins are used instead of the real plugin-list / plugin-form: * `packages/components` sits BELOW the plugins in the dependency graph, and the @@ -35,7 +38,7 @@ import { ComponentRegistry } from '@object-ui/core'; import { SchemaRenderer, AdapterCtx } from '@object-ui/react'; /** Props the stand-in blocks last received, for flat-prop assertions. */ -const captured: { listView?: any; objectForm?: any } = {}; +const captured: { listView?: any; objectForm?: any; kanban?: any } = {}; const adapter = { find: async () => [], getObjectSchema: async () => ({ name: 'showcase_project', fields: {} }) } as any; @@ -73,6 +76,7 @@ afterAll(() => { beforeEach(() => { captured.listView = undefined; captured.objectForm = undefined; + captured.kanban = undefined; }); // --------------------------------------------------------------------------- @@ -152,27 +156,80 @@ function Page() { }); // --------------------------------------------------------------------------- -// 3. Negative control — absence must be loud +// 3. Lazily-registered blocks are contract members too (objectui#2953) +// --------------------------------------------------------------------------- + +describe('kind:\'react\' scope — lazily-registered blocks', () => { + it('injects a curated block that is only registered lazily', async () => { + // How apps/console registers the heavy view plugins: a stub at boot, the + // chunk imported on first use. Six PUBLIC_BLOCKS tags live this way + // (object-kanban / -calendar / -gantt / -timeline / -map / markdown), and + // all six used to be missing from every react page's scope, because + // getPublicConfigs() resolved each curated tag through getConfig(), which + // reads loaded registrations only. + ComponentRegistry.registerLazy( + 'object-kanban', + async () => { + ComponentRegistry.register( + 'object-kanban', + (props: any) => { + captured.kanban = props; + return
; + }, + { namespace: 'plugin-kanban' }, + ); + }, + { namespace: 'plugin-kanban', category: 'view' }, + ); + + try { + // The tag IS in the contract before its chunk has been imported … + const cfg = ComponentRegistry.getPublicConfigs().find((c: any) => c.type === 'object-kanban'); + expect(cfg).toBeTruthy(); + expect((cfg as any).lazy).toBe(true); + + // … so `` resolves in the page source instead of throwing + // `ReferenceError: ObjectKanban is not defined`. The injected wrapper + // defers to SchemaRenderer, which fires the loader, shows the "Loading…" + // placeholder, and re-renders once the plugin registers for real. + const source = ` +function Page() { + return ; +}`; + const { findByTestId, queryByText } = renderReactPage(source); + + expect(await findByTestId('kanban-double')).toBeTruthy(); + expect(queryByText('React page error')).toBeNull(); + expect(captured.kanban.schema).toMatchObject({ type: 'object-kanban', groupBy: 'status' }); + } finally { + ComponentRegistry.unregister('object-kanban', 'plugin-kanban'); + ComponentRegistry.unregister('object-kanban'); + } + }); +}); + +// --------------------------------------------------------------------------- +// 4. Negative control — absence must be loud // --------------------------------------------------------------------------- describe('kind:\'react\' unknown identifier', () => { - it('surfaces the ReferenceError instead of failing silently', async () => { + it('surfaces the ReferenceError in the page-level error panel', async () => { const source = ` function Page() { return ; }`; - const { container } = renderReactPage(source); + const { container, findByText } = renderReactPage(source); // Proves the assertions above are meaningful: when a block really is absent // from scope, the author sees this. // - // Asserted on the message rather than on a specific panel, because the - // catching boundary is NOT the obvious one. ReactRunner is its own error - // boundary, but `getDerivedStateFromProps` re-transpiles on every render - // and unconditionally resets `error: null`, so the recovery render - // re-throws and the error escapes PAST the "React page error" fallback to - // SchemaRenderer's boundary ("Component "home" failed to render"). Pinning - // the panel would pin that quirk; pinning the message pins the contract. + // The panel is ReactRunner's own `fallback` (react-page.tsx:170-175). + // Pinning it also pins objectui#2954: `getDerivedStateFromProps` used to + // re-transpile on every render and reset `error: null`, so the recovery + // render rebuilt the same throwing element and the error escaped PAST this + // fallback to SchemaRenderer's boundary ("Component "home" failed to + // render") — the styled, page-specific panel was unreachable. + expect(await findByText('React page error')).toBeTruthy(); await waitFor(() => expect(container.textContent).toContain('TotallyNotARegisteredBlock is not defined'), ); diff --git a/packages/components/src/renderers/layout/react-page.tsx b/packages/components/src/renderers/layout/react-page.tsx index 2d185d8bae..ac7135854d 100644 --- a/packages/components/src/renderers/layout/react-page.tsx +++ b/packages/components/src/renderers/layout/react-page.tsx @@ -47,6 +47,14 @@ function toPascal(tag: string): string { // data/leaf blocks (non-containers) as prop-driven wrappers; layout containers // are intentionally left out — in react mode the author composes layout with // real HTML + Tailwind, not our schema-children renderers. +// +// Lazily-registered blocks (`object-kanban`, `object-map`, `markdown`, … — see +// apps/console/src/main.tsx) are in here too: `getPublicConfigs()` resolves +// `registerLazy` stubs, so the scope is complete at build time regardless of +// which plugin chunks have been imported (objectui#2953). Each wrapper defers +// to SchemaRenderer, which triggers the loader and renders the placeholder, so +// the scope itself never has to change once built — which is exactly what lets +// it stay identity-stable and keeps the page from remounting (objectui#2954). function buildComponentScope(dataSource: unknown): Record> { const scope: Record> = {}; const seen = new Set(); @@ -123,6 +131,14 @@ export const ReactKindPage: React.FC<{ schema: any }> = ({ schema }) => { }; }, [capabilityEnabled]); + // Keep this identity STABLE. ReactRunner recompiles the page whenever the + // scope identity changes, and every compile mints a fresh `Page` function — + // i.e. a new element type — which remounts the subtree and wipes the page's + // own `useState` (objectui#2954). That's why this deliberately does not + // subscribe to ComponentRegistry changes the way SchemaRenderer does: a lazy + // plugin finishing its registration notifies the registry, and rebuilding the + // scope there would reset every interactive page on the screen. It doesn't + // need to — `buildComponentScope` already sees lazy blocks (objectui#2953). const scope = React.useMemo( () => ({ ...buildComponentScope(adapter), diff --git a/packages/core/src/registry/Registry.ts b/packages/core/src/registry/Registry.ts index a641919eac..a162b3c3d6 100644 --- a/packages/core/src/registry/Registry.ts +++ b/packages/core/src/registry/Registry.ts @@ -66,6 +66,23 @@ export type ComponentConfig = ComponentMeta & { component: ComponentRenderer; }; +/** + * A CONTRACT-surface entry (ADR-0080), as returned by + * {@link Registry.getPublicConfigs}. + * + * Same shape as {@link ComponentConfig} except `component` is absent while the + * entry is still a pending `registerLazy` stub: the plugin module has not been + * imported yet, so there is no renderer to hand out. Consumers render such an + * entry through `SchemaRenderer`, which triggers the loader and shows a + * placeholder in the meantime (objectui#2953). + */ +export type PublicComponentConfig = ComponentMeta & { + type: string; + component?: ComponentRenderer; + /** True while this entry is a `registerLazy` stub whose loader has not run. */ + lazy?: boolean; +}; + /** * Lazy loader function used by `Registry.registerLazy`. The loader is invoked * the first time a missing component type is requested through `getAsync`/the @@ -298,7 +315,14 @@ export class Registry { /** * Get component configuration by type with namespace support. - * + * + * LOADED registrations only — a type that exists solely as a `registerLazy` + * stub returns `undefined` here, because callers read `.component` off the + * result and a stub has no renderer until its loader runs. The *contract* + * question ("is this tag part of the public surface?") must not depend on + * load order, so {@link getPublicConfigs} resolves lazy stubs separately + * (objectui#2953). Use {@link hasLazy} / {@link loadLazy} to reach a stub. + * * @param type - Component type (e.g., 'button' or 'ui:button') * @param namespace - Optional namespace for lookup priority * @returns Component configuration or undefined @@ -349,18 +373,46 @@ export class Registry { return Array.from(this.components.values()); } + /** + * Resolve `tag` for the CONTRACT surface: the loaded registration when the + * component is already in the registry, otherwise the metadata of a pending + * `registerLazy` stub. + * + * A block registered lazily is a first-class member of the contract — the + * loader is recorded at boot and the plugin chunk is imported on first use — + * so contract membership must not hinge on whether that import happened to + * have run yet (objectui#2953). + */ + private getContractConfig(tag: string): PublicComponentConfig | undefined { + const loaded = this.components.get(tag); + if (loaded) return loaded; + const entry = this.lazyEntries.get(tag); + if (!entry) return undefined; + // Mirror registerLazy's key derivation so the canonical `type` matches the + // one `register()` will store once the loader runs — that keeps the dedupe + // below stable across the load. `tag` may already carry the namespace + // (lazyEntries holds both keys; curated tags like `record:details` are + // themselves colon-shaped), so don't prefix it twice. + const ns = entry.meta?.namespace; + const canonical = ns && !tag.startsWith(`${ns}:`) ? `${ns}:${tag}` : tag; + return { ...entry.meta, type: canonical, lazy: true }; + } + /** * Get the curated PUBLIC-tier component configs (ADR-0080) — those registered * with `tier: 'public'`. This is the contract/AI-vocabulary surface, a subset * of the full rendering capability returned by {@link getAllConfigs}. + * + * Includes blocks that are only lazily registered so far; those come back + * with `lazy: true` and no `component` (see {@link PublicComponentConfig}). */ - getPublicConfigs(): ComponentConfig[] { + getPublicConfigs(): PublicComponentConfig[] { // Dedupe by the config's canonical (namespaced) `type` — a component is // registered under both a bare and a namespaced key pointing at the same // canonical type, and we want one contract entry per component. const seenCanonical = new Set(); - const out: ComponentConfig[] = []; - const add = (tag: string, cfg: ComponentConfig | undefined): void => { + const out: PublicComponentConfig[] = []; + const add = (tag: string, cfg: PublicComponentConfig | undefined): void => { if (!cfg || seenCanonical.has(cfg.type)) return; seenCanonical.add(cfg.type); // The contract surface is keyed by the bare/curated tag authors write, @@ -368,11 +420,15 @@ export class Registry { out.push({ ...cfg, type: tag }); }; // Curated contract list first (stable, reviewable order) … - for (const tag of PUBLIC_BLOCKS) add(tag, this.getConfig(tag)); + for (const tag of PUBLIC_BLOCKS) add(tag, this.getContractConfig(tag)); // … plus any bare registration that opted in explicitly via `tier: 'public'`. for (const [key, cfg] of this.components.entries()) { if (cfg.tier === 'public' && !key.includes(':')) add(key, cfg); } + // … and the same opt-in for stubs whose loader has not run yet. + for (const [key, entry] of this.lazyEntries.entries()) { + if (entry.meta?.tier === 'public' && !key.includes(':')) add(key, this.getContractConfig(key)); + } return out; } diff --git a/packages/core/src/registry/__tests__/public-tier.test.ts b/packages/core/src/registry/__tests__/public-tier.test.ts index 54ff0704c4..c77db5430d 100644 --- a/packages/core/src/registry/__tests__/public-tier.test.ts +++ b/packages/core/src/registry/__tests__/public-tier.test.ts @@ -33,3 +33,82 @@ describe('getPublicConfigs — ADR-0080 curated public tier (capability ≠ cont expect(PUBLIC_BLOCK_SET.size).toBe(PUBLIC_BLOCKS.length); }); }); + +// --------------------------------------------------------------------------- +// Lazy registrations are contract members too (objectui#2953) +// --------------------------------------------------------------------------- + +describe('getPublicConfigs — lazy registrations (objectui#2953)', () => { + const loader = () => Promise.resolve(); + + it('includes a curated block that is only lazily registered', () => { + const r = new Registry(); + r.registerLazy('object-kanban', loader, { namespace: 'plugin-kanban', category: 'view' }); + + const cfg = r.getPublicConfigs().find((c) => c.type === 'object-kanban'); + // Before the fix this was `undefined`, because getPublicConfigs() resolved + // each curated tag through getConfig(), which only reads loaded + // registrations — so every lazily-registered public block silently fell + // out of the contract (and out of every kind:'react' page's scope). + expect(cfg).toBeTruthy(); + expect(cfg!.lazy).toBe(true); + expect(cfg!.component).toBeUndefined(); + // Metadata comes from the stub, so consumers can still filter on it. + expect(cfg!.namespace).toBe('plugin-kanban'); + expect(cfg!.isContainer).toBeFalsy(); + }); + + it('does not depend on load order — same entry before and after the load', () => { + const r = new Registry(); + r.registerLazy('object-map', loader, { namespace: 'plugin-map' }); + const before = r.getPublicConfigs().map((c) => c.type); + + // The plugin chunk lands: its module registers the real component. + r.register('object-map', C, { namespace: 'plugin-map' }); + const after = r.getPublicConfigs(); + + expect(before).toEqual(['object-map']); + expect(after.map((c) => c.type)).toEqual(['object-map']); + // …and now it is a fully loaded entry, not a stub. + expect(after[0].lazy).toBeFalsy(); + expect(after[0].component).toBe(C); + }); + + it('emits one entry per component when a stub is registered under both keys', () => { + const r = new Registry(); + // registerLazy stores the entry under BOTH `plugin-kanban:object-kanban` + // and the bare `object-kanban`; the contract wants one row, keyed bare. + r.registerLazy('object-kanban', loader, { namespace: 'plugin-kanban' }); + r.registerLazy('kanban', loader, { namespace: 'view' }); // not curated + + const types = r.getPublicConfigs().map((c) => c.type); + expect(types).toEqual(['object-kanban']); + }); + + it('honours a colon-shaped curated tag without double-prefixing its namespace', () => { + const r = new Registry(); + r.registerLazy('record:details', loader, { namespace: 'record' }); + + const types = r.getPublicConfigs().map((c) => c.type); + expect(types).toEqual(['record:details']); + }); + + it('surfaces a lazy `tier:public` opt-in outside the curated list', () => { + const r = new Registry(); + r.registerLazy('my-widget', loader, { namespace: 'x', tier: 'public' }); + r.registerLazy('my-internal', loader, { namespace: 'x' }); + + const types = r.getPublicConfigs().map((c) => c.type); + expect(types).toEqual(['my-widget']); + }); + + it('leaves getConfig() loaded-only — a stub is not a renderable config', () => { + const r = new Registry(); + r.registerLazy('object-gantt', loader, { namespace: 'plugin-gantt' }); + + // Callers of getConfig() read `.component`; handing them a stub would give + // them `undefined` to render. They go through hasLazy()/loadLazy() instead. + expect(r.getConfig('object-gantt')).toBeUndefined(); + expect(r.hasLazy('object-gantt')).toBe(true); + }); +}); diff --git a/packages/react-runtime/src/__tests__/ReactRunner.test.tsx b/packages/react-runtime/src/__tests__/ReactRunner.test.tsx new file mode 100644 index 0000000000..cf54990761 --- /dev/null +++ b/packages/react-runtime/src/__tests__/ReactRunner.test.tsx @@ -0,0 +1,198 @@ +/** + * 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. + * + * `ReactRunner` — the trusted `kind:'react'` execution tier's error boundary + * and compile cache (objectui#2954). + * + * `getDerivedStateFromProps` used to transpile + eval the page source on EVERY + * render and unconditionally set `error: null`. React runs it before the + * re-render that follows `getDerivedStateFromError`, so the boundary threw away + * the error it had just caught, rebuilt an identical throwing element, and the + * throw escaped past its own `fallback` — plus each compile minted a fresh + * `Page` function, i.e. a new element type, remounting the page and wiping its + * state. + * + * None of that is visible to type-check, lint or build, and the failure is + * silent-ish (the page still renders, just via somebody else's error panel and + * with its state reset), so it is pinned here. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render, fireEvent } from '@testing-library/react'; +import React from 'react'; +import { ReactRunner, generateElement } from '../index.js'; + +/** Module-scope so the identity stays stable across re-renders — the runner + * recompiles on `scope` identity change, which is the point of several tests. */ +const EMPTY_SCOPE = {}; + +const fallback = (error: Error) =>
{String(error)}
; + +const COUNTER_SOURCE = ` +export default function Page() { + const [n, setN] = React.useState(0); + return ; +}`; + +// --------------------------------------------------------------------------- +// Baseline +// --------------------------------------------------------------------------- + +describe('generateElement', () => { + it('compiles a bare JSX fragment into an element', () => { + const el = generateElement('

hi

'); + expect(React.isValidElement(el)).toBe(true); + }); + + it('returns null for empty source', () => { + expect(generateElement(' ')).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// 1. The boundary must actually hold its own errors +// --------------------------------------------------------------------------- + +describe('ReactRunner error boundary', () => { + it('renders its own fallback for an error thrown while RENDERING the page', () => { + const { queryByTestId } = render( + ; }`} scope={EMPTY_SCOPE} fallback={fallback} />, + ); + + // The regression: `getDerivedStateFromProps` cleared the error before the + // recovery render, the same element was rebuilt, and the ReferenceError + // escaped to whatever boundary sat ABOVE the runner — so `fallback` was + // dead code for every render-phase error. + const panel = queryByTestId('runner-fallback'); + expect(panel).toBeTruthy(); + expect(panel!.textContent).toContain('NotInScope is not defined'); + }); + + it('renders its own fallback for an error thrown while COMPILING the page', () => { + const { queryByTestId } = render( + ; }`} scope={EMPTY_SCOPE} fallback={fallback} />, + ); + expect(queryByTestId('runner-fallback')).toBeTruthy(); + }); + + it('recovers when the code changes to something that works', () => { + const { queryByTestId, rerender } = render( + ; }`} scope={EMPTY_SCOPE} fallback={fallback} />, + ); + expect(queryByTestId('runner-fallback')).toBeTruthy(); + + // Holding the error must not make it sticky: new inputs => fresh compile. + rerender( + ; }`} scope={EMPTY_SCOPE} fallback={fallback} />, + ); + expect(queryByTestId('runner-fallback')).toBeNull(); + expect(queryByTestId('ok')).toBeTruthy(); + }); + + it('reports a render-phase error through onError exactly once', () => { + const onError = vi.fn(); + const { rerender } = render( + ; }`} scope={EMPTY_SCOPE} fallback={fallback} onError={onError} />, + ); + // `componentDidUpdate` gated on an `error` the next + // `getDerivedStateFromProps` had already cleared, so this used to be 0. + expect(onError).toHaveBeenCalledTimes(1); + expect(String(onError.mock.calls[0][0])).toContain('NotInScope is not defined'); + + // Held state must not re-report on every subsequent render. + rerender( + ; }`} scope={EMPTY_SCOPE} fallback={fallback} onError={onError} />, + ); + expect(onError).toHaveBeenCalledTimes(1); + }); + + it('reports a compile-time error through onError on mount', () => { + const onError = vi.fn(); + // Only `componentDidUpdate` reported errors, and it does not run for the + // first render — so an eval error at mount was never reported at all. + render( + ; }`} scope={EMPTY_SCOPE} fallback={fallback} onError={onError} />, + ); + expect(onError).toHaveBeenCalledTimes(1); + }); +}); + +// --------------------------------------------------------------------------- +// 2. Compile once per (code, scope) — not once per render +// --------------------------------------------------------------------------- + +describe('ReactRunner compile memoisation', () => { + it('does not recompile when re-rendered with the same code and scope', () => { + const compiled = vi.fn(); + const scope = { compiled }; + const source = ` +compiled(); +export default function Page() { return ; }`; + + const { rerender, queryByTestId } = render(); + expect(queryByTestId('probe')).toBeTruthy(); + expect(compiled).toHaveBeenCalledTimes(1); + + rerender(); + rerender(); + expect(compiled).toHaveBeenCalledTimes(1); + }); + + it('recompiles when the code changes', () => { + const compiled = vi.fn(); + const scope = { compiled }; + const mk = (label: string) => ` +compiled(); +export default function Page() { return ${label}; }`; + + const { rerender, getByTestId } = render(); + rerender(); + + expect(compiled).toHaveBeenCalledTimes(2); + expect(getByTestId('probe').textContent).toBe('b'); + }); + + it('recompiles when the scope identity changes', () => { + const compiled = vi.fn(); + const source = ` +compiled(); +export default function Page() { return ; }`; + + const { rerender } = render(); + rerender(); + expect(compiled).toHaveBeenCalledTimes(2); + }); + + it('keeps the page mounted — its useState survives a parent re-render', () => { + function Host({ tick }: { tick: number }) { + return ( + <> + {tick} + + + ); + } + + const { getByTestId, rerender } = render(); + fireEvent.click(getByTestId('counter')); + expect(getByTestId('counter').textContent).toBe('1'); + + rerender(); + + // Every compile produces a new `Page` function — a new element TYPE — so + // recompiling per render remounted the subtree and reset this to '0'. + expect(getByTestId('tick').textContent).toBe('1'); + expect(getByTestId('counter').textContent).toBe('1'); + }); + + it('keeps the page mounted across its OWN state updates', () => { + const { getByTestId } = render(); + fireEvent.click(getByTestId('counter')); + fireEvent.click(getByTestId('counter')); + expect(getByTestId('counter').textContent).toBe('2'); + }); +}); diff --git a/packages/react-runtime/src/index.tsx b/packages/react-runtime/src/index.tsx index 3f4cbf0114..6e0365d348 100644 --- a/packages/react-runtime/src/index.tsx +++ b/packages/react-runtime/src/index.tsx @@ -57,28 +57,60 @@ export interface ReactRunnerProps { onError?: (error: Error) => void; } +/** Marks "nothing compiled yet" — distinct from any real `code`/`scope` value. */ +const UNSET = Symbol('unset'); + interface ReactRunnerState { element: ReactElement | null; error: Error | null; + /** The `code` the current `element`/`error` was produced from. */ + compiledCode: string | typeof UNSET; + /** The `scope` (by identity) the current `element`/`error` was produced from. */ + compiledScope: Scope | undefined | typeof UNSET; } /** Renders a JSX/TSX source string with a built-in error boundary. */ export class ReactRunner extends Component { - state: ReactRunnerState = { element: null, error: null }; + state: ReactRunnerState = { element: null, error: null, compiledCode: UNSET, compiledScope: UNSET }; - static getDerivedStateFromProps(props: ReactRunnerProps): Partial | null { + /** + * Transpile + eval ONLY when `code` or the `scope` identity actually changed. + * + * Recompiling on every render broke this boundary three ways (objectui#2954): + * 1. React runs this before the re-render that follows + * `getDerivedStateFromError`, and the old body unconditionally set + * `error: null` — so the error just caught was discarded, an identical + * throwing element was rebuilt, and the throw escaped PAST our own + * `fallback` to whatever boundary sits above us. + * 2. `onError` was gated on an `error` that this had already cleared. + * 3. Every eval mints a fresh `Page` function, so a new element *type* on + * each render remounts the page subtree and wipes its `useState`. + */ + static getDerivedStateFromProps( + props: ReactRunnerProps, + state: ReactRunnerState, + ): Partial | null { + if (props.code === state.compiledCode && props.scope === state.compiledScope) return null; + const compiled = { compiledCode: props.code, compiledScope: props.scope }; try { - return { element: generateElement(props.code, props.scope), error: null }; + return { ...compiled, element: generateElement(props.code, props.scope), error: null }; } catch (error) { - return { element: null, error: error as Error }; + return { ...compiled, element: null, error: error as Error }; } } static getDerivedStateFromError(error: Error): Partial { return { error }; } - componentDidUpdate(): void { + componentDidMount(): void { + // A transpile/eval error is already in state by the time we mount, and + // `componentDidUpdate` never runs for the first render. if (this.state.error) this.props.onError?.(this.state.error); } + componentDidUpdate(_prevProps: ReactRunnerProps, prevState: ReactRunnerState): void { + // Report each error once, on the transition — the state now persists, so + // firing on every subsequent render would repeat the same one forever. + if (this.state.error && this.state.error !== prevState.error) this.props.onError?.(this.state.error); + } render(): ReactNode { if (this.state.error) { return this.props.fallback From 87ae7797d29ef29616cd248838bf39c5f2aedddb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 02:57:59 +0000 Subject: [PATCH 2/2] docs(components): refresh react-page.tsx line references in the scope tests The comments in react-page-scope.test.tsx cite react-page.tsx by line, and the previous commit shifted those lines. Re-point them at the current anchors. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01N4mrr1ihhwnfEHFSWmGoMp --- .../components/src/__tests__/react-page-scope.test.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/components/src/__tests__/react-page-scope.test.tsx b/packages/components/src/__tests__/react-page-scope.test.tsx index d850c17d89..5f31d77510 100644 --- a/packages/components/src/__tests__/react-page-scope.test.tsx +++ b/packages/components/src/__tests__/react-page-scope.test.tsx @@ -89,7 +89,7 @@ describe('kind:\'react\' scope eligibility', () => { (tag) => { const cfg = ComponentRegistry.getPublicConfigs().find((c: any) => c.type === tag); // Missing from PUBLIC_BLOCKS, or marked isContainer, => dropped from the - // scope of every kind:'react' page (react-page.tsx:56-58). + // scope of every kind:'react' page (react-page.tsx:64-66). expect(cfg).toBeTruthy(); expect((cfg as any).isContainer).toBeFalsy(); }, @@ -116,7 +116,7 @@ function Page() { expect(await findByTestId('list-view-double')).toBeTruthy(); expect(await findByTestId('object-form-double')).toBeTruthy(); // The failure mode this guards: `ReferenceError: ListView is not defined` - // surfaced through ReactRunner's fallback (react-page.tsx:170-175). + // surfaced through ReactRunner's fallback (react-page.tsx:186-191). expect(queryByText('React page error')).toBeNull(); }); @@ -129,7 +129,7 @@ function Page() { await findByTestId('list-view-double'); // Flat JSX props are folded into the schema bag, and the discriminator wins - // the `type` slot (react-page.tsx:71-76). + // the `type` slot (react-page.tsx:79-84). expect(captured.listView.schema).toMatchObject({ type: 'list-view', objectName: 'showcase_project', @@ -140,7 +140,7 @@ function Page() { // apps/console/src/sdui-workbench-preview.tsx is built on onRowClick. expect(typeof captured.listView.onRowClick).toBe('function'); // The wrapper stamps the live adapter in, since list-view reads dataSource - // from props rather than from context (react-page.tsx:52-55). + // from props rather than from context (react-page.tsx:61-63). expect(captured.listView.schema.dataSource).toBe(adapter); }); @@ -223,7 +223,7 @@ function Page() { // Proves the assertions above are meaningful: when a block really is absent // from scope, the author sees this. // - // The panel is ReactRunner's own `fallback` (react-page.tsx:170-175). + // The panel is ReactRunner's own `fallback` (react-page.tsx:186-191). // Pinning it also pins objectui#2954: `getDerivedStateFromProps` used to // re-transpile on every render and reset `error: null`, so the recovery // render rebuilt the same throwing element and the error escaped PAST this