Skip to content

fix(react): stop the properties hoist enumerating a degenerate bag - #6784

Merged
os-sales merged 1 commit into
mainfrom
claude/issue-6760-degenerate-properties-hoist
Aug 29, 2026
Merged

fix(react): stop the properties hoist enumerating a degenerate bag#6784
os-sales merged 1 commit into
mainfrom
claude/issue-6760-degenerate-properties-hoist

Conversation

@claude

@claudeclaudeBot commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Fixes#6760

The properties hoist walked the bag with Object.entries unconditionally, so a
degenerate (non-object) value was enumerated instead of skipped. Measured on
c6732825d, this branch's base, with no part of this card in the tree:

nodeReact props the element received
{ type, properties: 'not-a-bag' }012345678 (one per character)
{ type, properties: ['x', 'y'] }01
{ type, properties: 42 } / truenone — Object.entries was already empty for these

Nobody authored those keys; they are the walk's reading of a string's character indices.

The arm, and the reasoning (which is written into the code)

The card left two arms open and triage delegated the choice to the implementer rather than the
decision box, since both are cheap, reversible, and off the manual floor (the objectui#6708
census found zero authored degenerate config bags):

  • (a) guard the hoist the way the evaluation memo is guarded;
  • (b) rule that the hoist may enumerate whatever it is handed, and say so at the hoist.

Arm (a). The full reasoning lives at the hoist in packages/react/src/SchemaRenderer.tsx;
in short, and in the order it was weighed:

  1. One answer per key, whichever channel reads it — objectui#5123's ruling (maintainer,
    2026-08-18). Arm (b) breaks it in the loudest available place: after objectui#6752,
    props: 'not-a-bag' contributes no keys, so arm (b) would answer the same authored
    mistake two ways depending on which of two spellings of one bag was used — and the
    reinterpreting half would fall on properties, the spec spelling, while the quiet half
    fell on props, its annotated legacy alias. A rule that punishes the canonical spelling is
    not a rule anyone can teach.
  2. The reason for a config-bag guard was measured, and it is channel-independent.
    objectui#6752 established that by ablation rather than by reading a comment. The hoist is a
    third site asking the same question about the same authored value; a third answer is drift,
    which is what isConfigBag exists to end (objectui#6761) — so the guard reads the shared
    predicate rather than inlining a seventh spelling.
  3. The failure it prevents is silent and lands on generated metadata.properties: 'text'
    where a bag belongs is exactly the mistake an author — increasingly, an AI writing metadata
    — makes. Arm (b) turns it into nine plausible-looking props with no warning anywhere; arm
    (a) makes it inert, so the mistake stays legible as the value that was actually written.

The falsified comment — the unconditional half had already landed

Triage's unconditional half was "correct the evaluation memo's falsified comment, whichever arm
you pick". On this base that was already done: PR #6763 (for objectui#6752) landed
3fe64634b at 08:04:45Z, and the triage comment was written at 08:28:38Z against the
pre-merge text. git blame puts the replacement paragraph on that commit. So the sentence
"because that value feeds the hoist" is gone from the tree already, and re-deleting it was
not available as work.

What this PR does instead is keep that corrected paragraph true under the new behaviour, and
fix two further comments this change falsifies:

  • the evaluation memo said non-objects "reach the hoist exactly as they do today" — the
    hoist now refuses them, and the memo now records the measured consequence that the two guards
    are in series rather than redundant (ablation 2 below);
  • propsWithoutCanonicalKeys's doc explained its degenerate-properties carve-out by
    saying "the hoist and readProps() both merely object-spread it". Half of that stopped being
    true here. The carve-out itself is unaffected — it never rested on what the hoist does — and
    the bullet now says so.

Ablations, in both directions

The card's own filing used ablation in reverse — to prove a guard was not responsible for
an output — and asked for the same here. Three legs, each on the committed fix, each proving the
mutation reached disk (blob hash moved) and each restored from HEAD with the restore proven
(git hash-object back to the HEAD blob andgit diff HEAD empty). No rebuild is involved:
the pins import ../SchemaRenderer by relative path, so vitest reads source, never a dist/.

legmutationobserved
1 — forwardhoist guard back to bare if (newSchema.properties)08 return for the string bag, 01 for the array bag; both pins red. This card's guard is what removes them.
2 — reverseevaluation memo's isConfigBag back to bare truthiness, hoist guard keptOn the card's base this changed nothing — that is how the memo's old reason was falsified. On this branch it reads the other way: the indexed keys come back and the properties a renderer reads becomes { '0': 'n', … } instead of 'not-a-bag', because the bare spread manufactures a real bag that the hoist then enumerates legitimately. The two guards are in series.
3 — reversepropsWithoutCanonicalKeys's isConfigBag(propertiesBag) early return back to bare truthinessNothing moves — both pins green. That site decides objectui#5123 precedence between two co-present bags and never fed the indexed keys, so this card must not be credited to it.

What does not move under the fix itself, measured by diffing the full probe reading before
and after: a real object bag hoists byte-identically (including the type/id keys the hoist
has always refused to copy, checked on a page:tabs node with properties.type: 'line'); the
authored properties value still reaches both channels unchanged; properties: 42 and
properties: true are byte-identical, because they were never part of the defect.

Pins

  • New:packages/react/src/__tests__/SchemaRenderer.degeneratePropertiesHoist.test.tsx,
    with a BASE_READING captured on c6732825d (SchemaRenderer.tsx at blob e95eb4372) and
    pasted verbatim, so the "unchanged" legs are a real before/after comparison and the degenerate
    legs pin a shape that was measured to be there before the guard removed it.
  • Ratchet, not a weakening:SchemaRenderer.degeneratePropsBag.test.tsx's properties leg.
    It used to assert the whole properties reading still equalled BASE_READING, and said so
    explicitly as "recorded, not fixed" — it was pinning objectui#6752's known-and-open
    asymmetry. This card closes it, so the equality is now false in the direction of less
    leakage
    , and the assertion tightens with it: from "nine indexed props, exactly as before" to
    "none at all". Everything that file asserts about the props bag is untouched, and the
    pre-fix shape it used to pin now lives as history in the new file.
  • Untouched, proven by blob hash: objectui#6761's configBag.pin.test.ts is identical at
    base, at HEAD and in the worktree (cb4c115f25d81a7bdc171bee82f1bee4988da521), as is
    configBag.ts (be08ebde7e0bd4c0b8c1ab5c8b57b748a2ad93d5). The guard reads the shared
    predicate; no seventh spelling was written.

Verification, on 00042324c (the final commit)

Run from the repo root, serialised through the container's shared heavy-verify lock:

  • pnpm exec vitest run packages/react/Test Files 68 passed (68), Tests 997 passed (997)
  • pnpm --filter @object-ui/react type-check (tsc --noEmit && tsc -p tsconfig.test.json) —
    exit 0. tsc -p tsconfig.test.json --listFiles confirms both pin files and
    SchemaRenderer.tsx are in the checked set, so this is a measurement of the edit and not a
    vacuously clean run over an excluded set.
  • eslint on the three changed files — 0 errors, 16 warnings, all pre-existing
    no-explicit-any in SchemaRenderer.tsx; no added line carries a type-position any.
  • node scripts/check-control-bytes.mjs — OK, 5648 tracked text files.
  • node scripts/check-changeset-no-major.mjs — no changeset declares major.
  • node scripts/check-changeset-presence.mjs — 3 released-source files changed, 1 changeset.

Filed separately, out of scope here

objectui#6783 — five readProps() copies in packages/components/src/renderers/basic/ still
object-spread a degenerate bag on the third channel (the config bag a renderer reads). Outside
this card's fence, which is packages/react/src/ only. Unassigned, for triage.


Generated by Claude Code

The hoist that copies `properties.*` onto a node's top level walked the bag
with `Object.entries` unconditionally, so a non-object value was enumerated
rather than skipped: measured on `c6732825d`, `{ type, properties: 'not-a-bag' }`
reached the element as nine React props named `0` … `8`, and
`properties: ['x', 'y']` as `0`, `1`.
Arm (a) of the two objectui#6760 left open — guard the hoist with the shared
`isConfigBag` predicate (objectui#6761) rather than rule that the hoist may
enumerate anything handed to it. The reasoning is written at the hoist: under
objectui#5123 a key gets one answer whichever channel reads it, and the other
arm would have answered one authored mistake two ways, with the reinterpreting
half falling on `properties` — the spec spelling — while the quiet half fell on
its `props` legacy alias.
Also corrects two comments the change falsifies: the evaluation memo's note
that non-objects "reach the hoist exactly as they do today", and
`propsWithoutCanonicalKeys`' explanation of its degenerate-`properties`
carve-out by what the hoist does with the value.
The objectui#6752 pin's `properties` leg is ratcheted, not weakened: it pinned
that card's known-and-open asymmetry (nine indexed props, recorded not fixed)
and now asserts none.
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, 45 chunks)3174.0 KB3222.7 KB
Main entry chunk (gzip)148.1 KB350 KB
Entry fileindex-DPP5IcFS.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.75KB116.33KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)173.10KB47.96KB
fields (index.js)240.93KB60.76KB
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.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.33KB45.10KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)245.46KB62.46KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.03KB32.64KB
plugin-gantt (index.js)165.23KB40.37KB
plugin-grid (index.js)201.57KB54.55KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.15KB27.59KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)28.94KB8.33KB
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)76.75KB25.49KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)2.40KB1.20KB
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

@os-sales
os-sales marked this pull request as ready for review August 29, 2026 13:58
@os-sales
os-sales added this pull request to the merge queueAug 29, 2026
Merged via the queue into main with commit 31ab372Aug 29, 2026
32 checks passed
@os-sales
os-sales deleted the claude/issue-6760-degenerate-properties-hoist branch August 29, 2026 14:11
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