Skip to content

fix(components): one config-bag reader for element renderers, asking the shared predicate - #6789

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-6783-readprops-degenerate-bag
Aug 29, 2026
Merged

fix(components): one config-bag reader for element renderers, asking the shared predicate#6789
os-sales merged 2 commits into
mainfrom
claude/issue-6783-readprops-degenerate-bag

Conversation

@os-sales

Copy link
Copy Markdown
Collaborator

Fixes #6783

Closes the third and last channel of the degenerate-config-bag hazard: the bag an element:* renderer reads, { ...schema.props, ...schema.properties }. ?? only replaces null/undefined, so a non-object bag went into the object spread and came back out as its own character indices.

Verified on 7a82afa14; base 107babef6 (the dispatch named bf283414f, which origin/main had already moved past — every count below is re-derived on my own base).

The site count, with the control that makes it a reading

grep -rn "function readProps" packages returns 8 declarations repo-wide. The five this card names are the ones that survive the control:

where body verdict
renderers/basic/{elements,data-list,text-input,record-picker,metadata-viewer}.tsx { ...(schema?.props ?? {}), ...(schema?.properties ?? {}) } the five — identical modulo the type parameter and a comment
plugin-detail/src/renderers/record-alert.tsx { ...schema, ...(schema?.properties ?? {}) } same class, different expression, outside the fence — filed
react/src/__tests__/{aliasPrecedenceCrossChannel,propertiesExpressions}.test.tsx local re-implementations tests, not runtime answers

A raw grep -rn readProps returns 15 files; three of those are CommentThreadProps matching as a substring. So "five" is the count after separating in-fence production copies from out-of-fence and test copies — not a count of everything named readProps.

The shared predicate WAS reachable — the boundary is not the blocker

The card asked me to say so with the measurement if packages/components could not import isConfigBag. It can, and the interesting part is what was actually in the way:

  • The dependency edge exists and is already live. packages/components/package.json declares "@object-ui/react": "workspace:*", and all five modules already import { … } from '@object-ui/react' today. packages/react does not depend on @object-ui/components, so there is no cycle.
  • The symbol was not public. isConfigBag had no export in packages/react/src/index.ts — one line short of reachable, not a package-graph problem.

So this PR adds that one line, and it is the one file outside the stated surface (packages/components/src/renderers/basic/). Naming it explicitly rather than letting it pass as incidental:

  • The same file already carries the precedent, with the reason written out: the node-gate predicate reporter is exported at that entry ([Decision] What diagnostic budget should production carry for a faulting visibleWhen? Today a node-gate fault is entirely silent in a production bundle #6038) because @object-ui/components — "which depends on this package" — asks the same question, and a second copy would mean a second rate limit. Identical shape here.
  • Why not move the definition down to @object-ui/core instead, which both packages depend on: configBag.pin.test.ts scans packages/react/src. Moving the definition out of that tree would take it out of the scan's reach, so the ratchet that makes "one definition" durable would be traded away for tidier layering. The definition stays where its pin can see it, and the entry publishes it.

Ablation, both directions, from one run

Ablated from the committed fix: the shared reader's body returned to ?? {} and all five modules restored from 107babef6 — the exact pre-fix tree. Mutation confirmed on disk before measuring (isConfigBag( calls in the shared body: 0; local function readProps back in all five: 1 each; shared-reader imports: 0), restore proved afterwards by blob hash against HEAD on all six files. 7 of 16 assertions moved; 9 did not.

Moved (the guard is load-bearing on the bag):

a degenerate `properties` …    expected ["0"…"8"] to deeply equal []
a degenerate `props` …         expected ["0"…"8"] to deeply equal []
one side degenerate …          expected ["0"…"8","content"] to equal ["content"]
an ARRAY is degenerate too …   expected ["0","1"] to deeply equal []
… the bag the renderer computes …  expected '0,1,2,3,4,5,6,7,8' to be ''

The last of those is measured end to end through the real SchemaRenderer, so #6752's and #6760's guards are in force during it.

Did not move — and this is the honest headline. Reverse-ablated the way #6760's filing did: remove the guard, see whether anything downstream changes. Nothing rendered changes. All five "renders the same with a degenerate bag as with none" assertions are green with the guard and without it. All five renderers read named keys off this bag, and the one onward spread — metadata-viewer spreading the bag into StateMachineView — hands it to components that destructure named ViewerProps fields, so the nine indexed keys were computed and then dropped. The dispatch's expectation that they "reach the element as real React props" does not hold on this base; they reach a React component and die there. Also unmoved: #5123 precedence, the null/undefined/number cases (where ?? and the predicate already agreed), and the leg proving the authored 'not-a-bag' still arrives at the renderer — true before and after, which is exactly why this channel is the only place the question can be answered.

What the guard buys is therefore what #6752 measured its guard buys, one channel further down: the authored value's shape is not reinterpreted. Per #6708's census, zero authored nodes carry a degenerate config bag — latent shape, not a live failure.

Not weakened, proved by blob hash

Byte-identical to 107babef6:

59f736f629f02761052a095d201efacda7d85e5e  react/src/__tests__/SchemaRenderer.degeneratePropsBag.test.tsx
cb4c115f25d81a7bdc171bee82f1bee4988da521  react/src/utils/configBag.pin.test.ts
be08ebde7e0bd4c0b8c1ab5c8b57b748a2ad93d5  react/src/utils/configBag.ts
13294852afb3d7f4a1b7ae7d20f28786d7e70ccc  components/src/__tests__/alias-precedence-cross-channel.test.tsx

Both pins also run green in the union below — neither is merely untouched. This PR adds a ratchet rather than relaxing one: the new file scans every production module under renderers/basic/ for a local ?? {} config-bag read (comments stripped first, so the shared reader's docblock quoting the removed lines is not itself reported) and requires all five to import the one reader.

Verification, all on 7a82afa14

pnpm exec vitest run packages/components/src/renderers/basic/ \
  packages/components/src/__tests__/alias-precedence-cross-channel.test.tsx \
  packages/react/src/utils/configBag.pin.test.ts \
  packages/react/src/__tests__/SchemaRenderer.degeneratePropsBag.test.tsx \
  packages/react/src/__tests__/SchemaRenderer.aliasPrecedenceCrossChannel.test.tsx \
  packages/react/src/__tests__/SchemaRenderer.propsBagDiagnostic.test.tsx
  -> Test Files 9 passed (9) | Tests 110 passed (110)

pnpm --filter @object-ui/react type-check       -> exit 0
pnpm --filter @object-ui/components type-check  -> exit 0
pnpm --filter @object-ui/react build            -> exit 0  (isConfigBag present in dist/index.d.ts and dist/index.js)
pnpm --filter @object-ui/components --filter @object-ui/react lint -> exit 0 (0 errors; the 2 warnings on each new
        file are the `no-explicit-any` the replaced copies already carried, on the signature kept verbatim)

check:control-bytes  OK (scanned 5637 tracked text file(s))   [5634 before the 3 new files — the control that it saw them]
check:self-import    OK — no package names itself inside its own src/
check:phantom-deps   OK — every in-scope import is declared by the package that publishes it
check:esm-specifiers OK — no un-ledgered package emits an extensionless relative specifier
check:element-data-source-declaration  OK — 13 gate-consuming file(s) checked
check:lint-coverage  OK — 46/46 packages linted, 0 with outstanding errors
check-changeset-presence  OK — 8 source file(s) of 2 released package(s), 1 changeset
check-changeset-no-major  OK

tsc -p tsconfig.test.json --listFiles names both new files, so the type-check really covers them rather than excluding tests.

Two gates report NOT MEASURED locally, both for a stated prerequisite, neither a verdict on this changecheck:readme-exports ("the population COLLAPSED … packagesRead: found 8, floor is 25 … run pnpm build first") and check:sdui-registration-pins ("No console build to weigh at apps/console/dist/assets … This is exit 2, not a pass"). Both need a full repo/console build, which CI does anyway; recorded as unmeasured rather than as passes.

Lint narrowing, declared. Repo-wide lint is turbo run lint (per-package eslint .). I ran the two packages this diff touches in full, not a file subset — so within each package nothing was excluded — and did not run the other 44. The scoping is per package, not per file, and the config is not type-aware across package boundaries, so this diff cannot move a verdict in a package it does not touch. CI runs all 46.

Out of scope, filed not fixed

A repo-wide sweep for ?? {}/|| {} on a .props/.properties member found one more site of this class: packages/plugin-detail/src/renderers/record-alert.tsx, { ...schema, ...(schema?.properties ?? {}) } — the same defect, a different expression, outside this card's surface. Filed rather than fixed. The other census hits are a different question and are not findings: record-activity.tsx and metadata-admin/ResourceEditPage.tsx do keyed reads (bag[key]) with no spread, so nothing is enumerated, and the remaining metadata-admin hits read a JSON Schema properties map, not the SDUI config bag.


Generated by Claude Code

claude added 2 commits August 29, 2026 13:57
…the shared predicate

Five copies of `readProps()` under `renderers/basic/` object-spread a degenerate
config bag: `??` only replaces `null`/`undefined`, so `properties: 'not-a-bag'`
was re-read as `{ '0': 'n', … '8': 'g' }` — nine keys nobody authored. This is
the third and last channel of the hazard objectui#6752 and objectui#6760 closed
upstream in `SchemaRenderer`; the three are in series, and the authored value
still reaches the renderer intact by design.

The five copies become one `readProps` that asks `isConfigBag`, exported from
`@object-ui/react`'s package entry rather than retold here — objectui#6761's
pin scans `packages/react/src`, so a copy one package over would be a spelling
it cannot see.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49
…iction

The pre-fix key ORDER for a one-sided degenerate bag is ["0" … "8","content"],
not ["content","0" … "8"] — integer-like keys sort ahead of the authored one
whatever the spread order. Measured by ablating the shared reader's body back
to `?? {}` with all five modules restored from 107babe; that run moved 7 of
the 16 assertions and left 9, and every DOM assertion is among the 9.

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

Metric Value Budget
Eager closure (gzip, 45 chunks) 3174.0 KB 3222.7 KB
Main entry chunk (gzip) 148.2 KB 350 KB
Entry file index-oZ2xP41Q.js
Status PASS

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

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 11.89KB 4.50KB
app-shell (runtime-config.js) 20.61KB 7.35KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 5.13KB 2.35KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 511.50KB 116.32KB
core (index.js) 5.30KB 2.13KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 173.10KB 47.96KB
fields (index.js) 240.93KB 60.76KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.44KB 1.39KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 33.40KB 8.71KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.95KB 10.97KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.55KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useResponsiveConfig.js) 1.37KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 9.53KB 3.38KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 4.64KB 1.50KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 1.93KB 0.88KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.92KB 12.93KB
plugin-charts (index.js) 64.68KB 18.35KB
plugin-chatbot (index.js) 190.33KB 45.10KB
plugin-dashboard (index.js) 133.48KB 34.51KB
plugin-designer (index.js) 212.87KB 43.19KB
plugin-detail (index.js) 245.46KB 62.46KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 133.03KB 32.64KB
plugin-gantt (index.js) 165.23KB 40.37KB
plugin-grid (index.js) 201.57KB 54.55KB
plugin-kanban (index.js) 53.14KB 14.64KB
plugin-list (index.js) 113.15KB 27.59KB
plugin-map (index.js) 20.20KB 6.66KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 43.51KB 11.94KB
plugin-timeline (index.js) 28.94KB 8.33KB
plugin-tree (index.js) 9.00KB 3.08KB
plugin-view (index.js) 85.87KB 21.12KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 72.12KB 23.98KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 3.11KB 1.48KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 5.41KB 2.34KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 4.93KB 2.24KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 10.35KB 3.60KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.74KB 1.41KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.72KB 2.24KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

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

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding(components): five readProps() copies object-spread a degenerate config bag — the third channel objectui#6752 / objectui#6760 left open

2 participants