Skip to content

fix(sdui): a react page keeps its state; a source that exports nothing fails loudly - #2984

Merged
os-zhuang merged 1 commit into
mainfrom
claude/react-lazy-block-scope-bpvef6
Jul 30, 2026
Merged

fix(sdui): a react page keeps its state; a source that exports nothing fails loudly#2984
os-zhuang merged 1 commit into
mainfrom
claude/react-lazy-block-scope-bpvef6

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Follow-up to #2976 / #2979. Started as "write the regression guard for the state-loss hazard #2954 flagged" — the guard failed on first run, because the hazard was already real on the default path.

1. evaluatedSchema was memoised on values rebuilt every render

Two fallbacks minted a fresh object per call:

constdataSource=context?.dataSource||{};// SchemaRenderer.tsxreturn{variables: {},definitions: [], ... };// usePageVariables(), outside a provider

Both feed the evaluatedSchemauseMemo dependency list. So for any tree without a SchemaRendererProvider / PageVariablesProvider above it, that memo never hit: the schema was re-cloned and the ExpressionEvaluator re-run on every render, and every child got a new schema identity each time.

For a kind:'react' page that identity is the compile key. New identity → recompile → a new page function → a new element type → React remounts the subtree and the user's useState is gone. And since SchemaRenderer subscribes to the registry, every lazy plugin's first load fired a notify that triggered it.

This is exactly the hazard #2954 described as "not yet observed":

anything that makes the adapter identity unstable would turn this into user-visible state loss with no obvious cause

Both fallbacks are now module constants. The page-variables one is frozen through — sharing one instance means a stray write would leak to every consumer outside a provider instead of being scoped to one render. There is no writer today (the setters are the API, and there they are no-ops); the freeze keeps it that way.

Worth noting the win is not limited to react pages: everySchemaRenderer without those providers was re-cloning its schema and re-running expression evaluation on every single render.

2. The guard itself

packages/components/src/__tests__/react-page-state.test.tsx pins the invariant at the symptom, not the memo internals — the memo is an implementation detail, "the user's input survived" is the contract:

  • state survives a parent re-render;
  • state survives a lazy block finishing its load — the case a plausible-looking ComponentRegistry.subscribe() in react-page.tsx would break, which is precisely what the comment there warns against and nothing enforced;
  • a genuinely changed source still recompiles (stability must not mean staleness).

3. A source that exports nothing now throws instead of rendering blank

normalizeCode inserts the implicit export default only when the source starts with JSX, a function declaration, () or class. So the form authors reach for most:

constPage=()=><p>hi</p>;// exports nothing

…evaluated fine, exported nothing, and generateElement returned null — a blank page, no error in the console, nothing in the page error panel. It now throws with a message naming the fix, which ReactRunner's panel surfaces (reachable since #2976). export default null still means "render nothing"; a default export that is not a component throws too.

This was the open question I flagged before writing docs — documenting the tier required deciding whether that behaviour is contract or bug. It's a bug.

4. PageSchema['kind'] matches @objectstack/spec

The spec has declared full | slotted | html | jsx | react since ADR-0080. @object-ui/types still said 'full' | 'slotted', and page.tsx read the field through (schema as { kind?: string }).kind to dispatch on values the type denied existed. Per AGENTS #0 the type follows the spec: the union now spells all five and the cast is gone.

5. Docs

@object-ui/react-runtime had no README and content/docs had no page on either source-authored kind — the injected block scope, Block, useAdapter, the capability gate and the accepted source shapes existed only in source comments. That is the tier AI-authored pages are written against.

  • content/docs/guide/react-pages.md — choosing between the executed and parsed tiers, the security gate, what's in scope (and why layout containers deliberately are not), flat props and the type/specType collision, Block, useAdapter, source shapes, error handling. Added to the guide nav.
  • packages/react-runtime/README.md — the API, the no-sandbox warning, and the stable-scope-identity requirement (an inline scope={{...}} literal remounts the tree every render — the same trap as Implement visual designer for Object UI schemas #1, one layer up).

Verification

  • Full suite: 697 files passed | 1 skipped, 8201 tests passed | 24 skipped.
  • turbo run type-check across the repo (76/76) — including after removing the kind cast.
  • lint on the touched packages: 0 errors.
  • check-doc-links: the new page's links resolve. (One pre-existing break remains in content/docs/core/enhanced-actions.mdx, untouched here and not gating — that workflow is workflow_dispatch-only.)
  • changeset:check: clean. Changeset included.

🤖 Generated with Claude Code

https://claude.ai/code/session_01N4mrr1ihhwnfEHFSWmGoMp


Generated by Claude Code

…g fails loudly
Writing the regression guard for objectui#2954's "latent hazard" — the note that
an unstable scope identity would turn into user-visible state loss — found it
was already real, on the default path.
`evaluatedSchema` was memoised on values rebuilt every render. SchemaRenderer
fell back to a fresh `{}` when no SchemaRendererProvider sat above it, and
`usePageVariables()` returned a brand-new object literal outside a
PageVariablesProvider. Both feed that memo's dependency list, so for any tree
without those providers it never hit: the schema was re-cloned and the
ExpressionEvaluator re-run on every render, and children got a new schema
identity every time. A kind:'react' page memoises its compiled source on that
identity, so the page was recompiled — a new page function, a new element type —
and React remounted it, silently discarding the user's useState. Every lazy
plugin's first load notified the registry and triggered it. Both fallbacks are
now module constants; the page-variables one is frozen through, since a shared
instance turns a stray write into a cross-consumer leak.
`react-page-state.test.tsx` pins the invariant at the symptom, not the memo:
state survives a parent re-render and survives a lazy block finishing its load
(the case a plausible-looking registry subscription in react-page.tsx would
break), and a genuinely changed source still recompiles.
`generateElement` now throws instead of rendering blank. The implicit
`export default` is only inserted when the source STARTS with JSX, a `function`
declaration, `()` or `class` — so the very common `const Page = () => …`
exported nothing and produced a blank page with no error reported anywhere. It
now throws with a message naming the fix, which ReactRunner's panel surfaces.
`export default null` still means "render nothing"; a non-component default
export throws too.
`PageSchema['kind']` matches @objectstack/spec. It declared 'full' | 'slotted'
while the renderer had shipped 'react' and 'html'/'jsx' since ADR-0080, reading
the field through a cast. The union now spells all five and the cast is gone.
Docs: new content/docs/guide/react-pages.md and a @object-ui/react-runtime
README. The package had neither, while being the tier AI-authored pages target —
the injected block scope, `Block`, `useAdapter`, the capability gate and the
accepted source shapes existed only in source comments.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4mrr1ihhwnfEHFSWmGoMp
@vercel

vercelBot commented Jul 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectuiIgnoredIgnoredJul 30, 2026 7:01am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)27.9 KB350 KB
Entry fileindex-Bv-EYmZU.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)8.20KB2.97KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)7.57KB2.97KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)22.10KB4.37KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.12KB3.41KB
auth (LoginForm.js)17.86KB5.29KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.43KB2.09KB
auth (SocialSignInButtons.js)9.60KB3.89KB
auth (UserMenu.js)3.40KB1.22KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)35.76KB9.11KB
auth (createAuthenticatedFetch.js)4.37KB1.69KB
auth (index.js)2.25KB1.01KB
auth (org-roles.js)6.72KB2.85KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)4.91KB0.87KB
auth (useIsWorkspaceAdmin.js)1.61KB0.85KB
collaboration (CommentThread.js)18.38KB4.49KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)3.65KB1.42KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.25KB0.53KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)450.74KB98.10KB
core (index.js)2.16KB0.78KB
create-plugin (index.js)9.28KB2.98KB
data-objectstack (index.js)134.67KB34.24KB
fields (index.js)221.10KB54.18KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.32KB1.77KB
i18n (index.js)2.46KB0.96KB
i18n (pickLocalized.js)1.70KB0.83KB
i18n (provider.js)5.37KB1.72KB
i18n (useObjectLabel.js)25.17KB5.80KB
i18n (useSafeTranslation.js)3.26KB1.44KB
layout (index.js)38.45KB10.67KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.74KB
mobile (index.js)1.50KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)4.42KB1.27KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.71KB0.42KB
mobile (useResponsiveConfig.js)1.36KB0.63KB
mobile (useSpecGesture.js)1.77KB0.77KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)6.84KB2.42KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)3.67KB1.12KB
permissions (evaluator.js)4.41KB1.44KB
permissions (index.js)0.91KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.52KB
permissions (usePermissions.js)1.55KB0.71KB
plugin-ai (index.js)15.71KB3.79KB
plugin-calendar (index.js)44.90KB12.35KB
plugin-charts (index.js)57.26KB16.24KB
plugin-chatbot (index.js)179.93KB42.67KB
plugin-dashboard (index.js)109.60KB28.33KB
plugin-designer (index.js)210.56KB42.56KB
plugin-detail (index.js)216.52KB53.02KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)103.32KB25.08KB
plugin-gantt (index.js)162.26KB39.53KB
plugin-grid (index.js)179.45KB47.03KB
plugin-kanban (index.js)47.82KB13.18KB
plugin-list (index.js)98.30KB23.23KB
plugin-map (index.js)16.80KB5.24KB
plugin-markdown (index.js)13.65KB4.67KB
plugin-report (index.js)37.77KB10.00KB
plugin-timeline (index.js)25.03KB7.11KB
plugin-tree (index.js)8.36KB2.81KB
plugin-view (index.js)85.47KB20.82KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.55KB0.67KB
providers (UploadProvider.js)11.71KB3.53KB
providers (index.js)0.44KB0.22KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.67KB2.37KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)19.28KB6.38KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.02KB0.55KB
sdui-parser (codegen.js)4.09KB1.74KB
sdui-parser (index.js)3.47KB1.54KB
sdui-parser (parse.js)10.04KB2.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)4.69KB1.48KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)0.77KB0.41KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (index.js)1.92KB0.93KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)0.20KB0.18KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.04KB1.93KB
types (system-fields.js)2.39KB1.17KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)0.75KB0.46KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-zhuang
os-zhuang marked this pull request as ready for review July 30, 2026 07:09
@os-zhuang
os-zhuang merged commit 2374a49 into mainJul 30, 2026
16 checks passed
@os-zhuang
os-zhuang deleted the claude/react-lazy-block-scope-bpvef6 branch July 30, 2026 07:09
os-zhuang added a commit that referenced this pull request Jul 30, 2026
…ts 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.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@os-zhuang@claude