Skip to content

feat(react): name a props config bag that no schema-reading renderer sees (#6708) - #6753

Merged
os-sales merged 1 commit into
mainfrom
claude/issue-6708-props-bag-component-renderer-diagnostic
Aug 29, 2026
Merged

feat(react): name a props config bag that no schema-reading renderer sees (#6708)#6753
os-sales merged 1 commit into
mainfrom
claude/issue-6708-props-bag-component-renderer-diagnostic

Conversation

@claude

@claudeclaudeBot commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Fixes#6708

Executes the maintainer ruling of 2026-08-29 (verbatim 「同意」), option 2 — the
SchemaRenderer-tier diagnostic
. One seam, every renderer family covered, zero behaviour
change. Option 1 (hoisting props to parity with properties) was refused at ruling and is
not implemented here; option 3 stays blocked on the .passthrough() ceiling.

The defect

SchemaRenderer HOISTS every properties.* value onto the node, so a key written under
properties is a real value on the node under that key name by the time a renderer destructures it. props
— the annotated legacy alias of the same bag — is NOT hoisted: it is evaluated and then spread
as React props on the created element. A renderer declared as ({ schema }), which is the
normal shape for the component renderers, therefore never sees it. The element:* family is
the exception, because its readProps() merges { ...schema.props, ...schema.properties }.

Every gate accepts the spelling — BaseSchema is .passthrough() with [key: string]: any
and the docs call props a supported alias, so nothing between the author and the screen said
a word.

Reproduced on this base before anything was changed

The card was filed off a reading taken on 5967be095. Re-measured on faac0d935 through
the real SchemaRenderer with a probe renderer that records both channels:

nodeReact prop dataschema.data
props: { data: "${data.customers}" }the evaluated arrayabsent
properties: { data: "${data.customers}" }the evaluated arraythe array

Same key, same value, one envelope apart. Note the first column: the expression is
evaluated on both legs, so this is a dropped value, not an unevaluated one. The card's own
four-leg reading through a real data-table (#6665's pin file) still reproduces unchanged —
No results found against the two rows — and that file stays green with no edits.

The census — the ruling's precondition, run before the level was chosen

The ruling fixed the order: measure component-level props usage across the in-repo corpus
first, so the diagnostic's level and dedupe inform rather than flood.

Method. Three scanners over the whole repo at faac0d935, one classifier:

  • every .json document, parsed and walked for any object carrying both type and props;
  • every json / json5 / jsonc fence in every .md / .mdx, parsed and walked the same
    way, with a textual probe over the fences that fail to parse so a fragment could not hide;
  • every .ts / .tsx file, walked with the TypeScript compiler API for object literals
    carrying both a type and a props property — an AST walk rather than a regex, so a hit is
    a real sibling pair rather than two nearby lines.

How a component-renderer node was told from an element:* one. By the registry, not by
guesswork: every readProps() in this repo that merges both bags belongs to a component
registered with namespace: 'element' (all five files read on this commit —
elements.tsx, data-list.tsx, text-input.tsx, record-picker.tsx, metadata-viewer.tsx).
A repo-wide grep for reads of schema.props returns that family plus exactly one other type,
view:simple, whose SimpleViewRenderer reads schema.props.columns. Everything else is a
component-renderer node.

Result.

bucketcount
nodes carrying both type and props39
— on element:* or a measured props reader13
type not statically knowable4
on a component-renderer type22
of those 22: in *.test.tsx fixtures exercising the shape on purpose19
of those 22: authored, non-test3
plus, found by the textual probe in doc fences that do not parse as JSON2

The 5 authored, non-test occurrences, named.

filenodekeysverdict
packages/types/examples/data-display-examples.jsonflexdirection, gaplive mis-authoring
skills/objectui/guides/page-builder.mdstatisticlabel, valuelive mis-authoring
skills/objectui/rules/protocol.mdcardtitledeliberate counter-example
skills/objectui/guides/schema-expressions.mdcardtitledeliberate counter-example
skills/objectui/guides/schema-expressions.mdtextvaluedeliberate counter-example

So the answer is not zero: two published artifacts teach the broken spelling as if it were
correct. Both are filed as #6751 and not touched here — this PR changes no authored
metadata. The other three are already marked wrong where they stand and must stay.

What could not be scanned, and why it is a bound rather than a hole. 70 .json files
failed to parse; every one is a tsconfig*.json (JSONC with comments), none of which is UI
metadata. Two markdown fences failed to parse; both were recovered by the textual probe and are
in the table above. The AST scan requires type and props to be siblings in ONE object
literal, so a fixture assembled by spreading a base object would be missed — that affects test
files only, and the diagnostic's behaviour on them is measured directly by the run below rather
than inferred. PageDesigner writes a props bag onto every canvas component it creates, but
DesignerComponent is a canvas model with position / locked / zIndex and is never handed
to SchemaRenderer, so it is not a node in this sense today.

What the number bought. Nothing floods, so the level is not softened for volume. But the
failure the census makes plausible is a metadata generator emitting one wrong envelope across
many nodes — those are distinct schema objects, so the object-keyed WeakSet its neighbour
uses would print one line per node for one bug. The dedupe is therefore keyed on the message,
which collapses that to one line while still giving two genuinely different nodes two lines.
Both directions are pinned.

What this adds

packages/react/src/utils/propsBagDiagnostic.ts — a pure predicate, a pure message builder and
a reporter, matching the shape of the two diagnostics next to it. SchemaRenderer calls it once
per node under __DEV__, beside its #4795 neighbour.

Channel and level: console.warn. Two conventions cross here and the choice follows the
SHAPE, not the tier. unevaluatedExpression.ts sits at this same tier and uses console.error,
but its subject is a raw ${...} placed verbatim in front of a user. Nothing is placed
here; a value is dropped — which is exactly #6575's and #6665's subject, and both of those emit
console.warn. The card names this the third instance of that shape, so it joins that family.
The message anatomy follows those two as well: address, the keys, the mechanism, the
consequence, the way out, the card number.

One computation, two readers.propsWithoutCanonicalKeys(...) was hoisted out of the
createElement call into a local that both the spread and the diagnostic read. Same pure call,
same arguments, same object, same spread position — but the diagnostic can no longer name a set
of keys that is not the set actually handed to the component. It also gets the #5123 precedence
for free: a key BOTH bags declare has already been subtracted, so the author is getting the
canonical answer and the diagnostic correctly says nothing.

One deliberate silence, with its cost stated.view:simple is excluded because
SimpleViewRenderer really does read schema.props.columns. It reads only that one key, so a
different props key on a view:simple node IS dropped and is NOT diagnosed. Silence there was
preferred to a message asserting a drop it had not checked — the same trade #6665's
describeIgnoredBind makes. The exclusion is a measurement of this tree, and the docblock says
so.

Acceptance — the ruling's pins, both directions

#pintest
1props bag on a component-renderer node ⇒ fires, naming the node and pointing at propertiesPIN 1 …
2element:* node carrying propssilentPIN 2 …
3plain properties node ⇒ silentPIN 3 …
4zero behaviour changesee below

Plus: silent on a node with neither bag, on an empty props: {}, when properties already
declares every props key, on view:simple, and on a degenerate non-object props; names only
the key properties does not also declare; one line for one bug repeated across nodes; two
lines for two different nodes; no repeat across a re-render.

Pin 4 is measured, not asserted. The test's BASE_READING constant is not a snapshot the
file wrote for itself. Seven node shapes were rendered on faac0d935 with
packages/react/src/SchemaRenderer.tsx reverted to its committed blob 57c0beb3f — the tree
with no diagnostic in it — and the capture was pasted in verbatim. Re-running the identical
capture with the diagnostic present produced a file with the same SHA-256
(387e04a9… both times, over the richer first capture) and an empty diff. #5123's precedence
is separately pinned in the same reading: for a key both bags declare, properties still wins.

Ablation

Against the committed implementation (aac3e65bc), collectDroppedPropsKeys(...) was replaced
with a null literal. The mutation was confirmed on disk before the run — injected marker
grep -c = 1, removed anchor grep -c = 0, blob moved d54ef23d… to b478043… — and the
anchor replacement asserts its own hit count, so a no-op edit fails loudly instead of going
green. No rebuild is involved: this repo's vitest config aliases @object-ui/react to src, so
the mutated source is what runs, and the red below is the proof it was read.

legresult
mutated5 failed, 35 passed — and the 5 are exactly the pins that assert the diagnostic FIRES
restored40 passed

The 35 that stayed green under ablation are the whole behaviour surface, BASE_READING
included. That is pin 4 from the other side: delete the diagnostic entirely and nothing about
what renderers receive moves.

Restore proven rather than assumed: git diff HEAD empty, restored blob d54ef23d… equal to
the path's HEAD blob, ablation marker count back to 0. The mutation ran under a
trap … EXIT INT TERM with absolute paths throughout.

Verification — union re-run on aac3e65bc, after the final commit

checkverdict line
vitest, packages/react + all 11 census-flagged at-risk suitesTest Files 74 passed (74) · Tests 1259 passed (1259)
pnpm --filter @object-ui/react run type-checkexit 0 (tsc --noEmit && tsc -p tsconfig.test.json)
pnpm --filter @object-ui/react run lint380 problems (0 errors, 380 warnings) — all 380 pre-existing no-explicit-any; none on a changed line
check-changeset-presence1 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-no-majorNo changeset declares a major bump
check-control-bytesOK (scanned 5604 tracked text file(s))
check-package-self-importNo package names itself inside its own src/
check-lint-coverage46/46 packages linted, 0 with outstanding errors
check-vi-mock-specifiersOK
check-shell-escape-residueOK

The 11 at-risk suites are every test file the census showed authoring props on a
component-renderer node — the files most likely to be disturbed by a new console line, including
#6665's own four-leg pin file and the domProps / DOM-leak sweeps that spy on the console. All
green, unedited.

Both new files were confirmed to be genuinely inside the typecheck's file set with
tsc --listFiles, rather than assumed: the package's type-check covers src and
tsconfig.test.json, and both appear.

NOT MEASURED locally, reported as such rather than as a pass or a failure:

  • check:readme-exports — exits 1 with type entry ./dist/index.d.ts is not on disk — run pnpm build first for 330 bindings across ~32 unbuilt packages. A full-workspace build
    prerequisite, not a finding. This diff adds no export to any package entry and touches no
    README, so it cannot move this gate's answer. CI builds and runs it.
  • check:sdui-registration-pins — exits 2 with Build the console first. Same class.
  • Repo-wide pnpm lint (turbo run lint) — the per-package task for the only package this diff
    touches was run instead, and is green. CI runs the farm.

Scope

No behaviour change; no hoisting; #6665's does NOT reach into the props envelope fence is
untouched and still green; content/docs/releases/ not touched; no skills/** file touched
(0 files), so the published-skills budget clause does not apply. Nothing is added to the
published surface: the predicate, message builder, prefix constant and test-only reset are
module-internal and are not re-exported from the package entry, matching #6575's own symbols.
Changeset added (patch, @object-ui/react).

Filed out of scope by the census, and deliberately not fixed here: #6751 (the two authored nodes
that teach the broken spelling) and #6752 (a non-object props is object-spread into indexed
React props — measured while writing the degenerate-input pin).

Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49


Generated by Claude Code

…rer sees
`SchemaRenderer` hoists `properties.*` onto the node; `props` — the annotated
legacy alias of the same bag — is evaluated and then spread as React props
instead. A renderer declared as `({ schema })`, the normal component-renderer
shape, therefore never sees it, and every gate accepts the spelling because
`BaseSchema` is `.passthrough()`. The `element:*` family is the exception: its
`readProps()` merges both bags.
Emit a `console.warn` at the SchemaRenderer tier naming the node and the
dropped keys and pointing at `properties`. Silent for the `element:*` family
and for `view:simple`, the one non-element type measured to read the raw bag.
Zero behaviour change, pinned against a reading captured on the tree before
the diagnostic existed. Level and dedupe follow the ruling's census
precondition.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3178.6 KB3222.7 KB
Main entry chunk (gzip)148.2 KB350 KB
Entry fileindex-BnZPPO3j.js
StatusPASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)11.89KB4.50KB
app-shell (runtime-config.js)20.61KB7.35KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
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)511.66KB116.30KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)173.10KB47.96KB
fields (index.js)239.31KB60.18KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.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)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.89KB12.91KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)190.33KB45.10KB
plugin-dashboard (index.js)133.44KB34.48KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)245.43KB62.45KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)132.96KB32.63KB
plugin-gantt (index.js)165.20KB40.37KB
plugin-grid (index.js)201.53KB54.54KB
plugin-kanban (index.js)53.11KB14.62KB
plugin-list (index.js)113.11KB27.58KB
plugin-map (index.js)20.17KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)26.44KB7.59KB
plugin-tree (index.js)9.00KB3.08KB
plugin-view (index.js)85.87KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)69.47KB23.06KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)2.44KB1.21KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
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)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
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 (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
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.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
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

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants

@os-sales@claude