Skip to content

fix(react): canonical properties wins on the React-prop channel too — one answer per key on both channels (#5123) - #5284

Merged
os-support-ai merged 2 commits into
mainfrom
claude/issue-5123-properties-wins-both-channels
Aug 19, 2026
Merged

fix(react): canonical properties wins on the React-prop channel too — one answer per key on both channels (#5123)#5284
os-support-ai merged 2 commits into
mainfrom
claude/issue-5123-properties-wins-both-channels

Conversation

@os-support-ai

@os-support-aios-support-ai commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Fixes#5123

Requirement (1) first: the co-occurrence scan, because the ruling made it a pre-condition

Count of nodes carrying BOTH props and properties in shipped metadata or source: 0.
One co-occurrence site exists in the whole repo, and it is a test fixture
packages/react/src/__tests__/SchemaRenderer.propertiesExpressions.test.tsx (the objectui#4799
pin), which deliberately writes both spellings to pin the config-bag merge. Its rendered output
does not change: it asserts the bag read (properties wins, unchanged) and the two bags'
evaluated values on the schema (both preserved). It passes unmodified — see the full-suite run
below.

So the blast radius of this change on authored metadata is empty, and the honest risk the
dispatch stated ("one of the two currently-working readings changes behaviour") lands on no
existing site
. That is a measurement, not an assumption — here is how it was taken, and the
counter-probe that proves the corpus was actually read.

Method

A walker (JSON.parse + recursive object walk for every .json; a brace-depth, string- and
comment-aware object-literal scan for every .ts/.tsx/.js/.jsx/.mjs/.md/.mdx) over the whole
repo including examples/. Both passes were validated against a positive control — a synthetic
node written both ways — and both detected it.

corpusfiles parsednodes with BOTHnodes with only propsnodes with only properties
examples/ (JSON)432000
whole repo (JSON)546012
whole repo (source literals)3924 scanned1 (the test fixture above)

Counter-probe — why the zeros are readings and not an empty scan

A zero is only worth as much as the probe that shows the same scan finds things it should:

  • examples/: the same walk parsed 2277 "type" keys across 428 files in that tree. The
    corpus is genuinely being opened — it simply authors config flat on the node
    ({ "type": "statistic", "label": "…", "value": "…" }), and contains zero occurrences of
    either "props" or "properties" as a key. The co-occurrence zero there is structural.
  • Whole repo: the scan found 800 files containing props, 344 containing properties, 12
    containing readProps, 3 JSON nodes carrying exactly one of the two spellings, and 28
    non-test source literals declaring a properties: bag against 1 declaring a props: bag.
  • Positive control: a synthetic node carrying both spellings was detected by the JSON pass and
    the source pass alike, so a real site could not have been missed by a dead detector.

The ruling — binding, quoted

Maintainer, 2026-08-18 (verbatim 「其他接受你的建议」):

Ruled: properties wins on BOTH channels — one answer per key. The canonical spelling's
precedence must hold on the React-prop channel too (today ...(evaluatedSchema.props || {})
spreads last and inverts it); the config-bag channel's properties-wins order is already
correct and stays. This restores the declared posture (props is annotated legacy alias)
rather than changing it.

The defect, re-measured on current main rather than trusted from the card

The card is from 2026-08-18 02:22 and the tree moved since, so the spread order was re-read at
97fba31a2. It is unchanged, and the two channels still disagree in opposite directions:

channelprecedence beforesite
config bag (schema.properties.x)properties winsreadProps() = { ...schema.props, ...schema.properties }
React prop (x arriving as a prop)props winscreateElement: ...componentProps first, then ...(evaluatedSchema.props || {}) overwrites

Reproduced through a real SchemaRenderer render of one node carrying both, reading both
channels in the same render:

bagRead(properties.content)="FROM_PROPERTIES" reactPropRead(content)="FROM_PROPS"

The readProps() family was re-verified on current main as the dispatch required. The five
named files are all still present and all still { ...fromProps, ...fromProperties }
(elements.tsx, text-input.tsx, record-picker.tsx, data-list.tsx, metadata-viewer.tsx).
A sixth has appeared and it matters enough to name:
packages/plugin-detail/src/renderers/record-alert.tsx defines its own readProps as
{ ...schema, ...schema.properties } — a different expression of the same properties-wins
order, so it agrees with the ruling and needs no change. Its suite is green (below).

PR #5122 (#4799) is confirmed not to be the cause — it preserved readProps's properties
precedence, and no side effect of that merge was found or looked for beyond that confirmation.

The change

One site, in packages/react/src/SchemaRenderer.tsx. The React-prop channel moves; the
config-bag channel is untouched, exactly as ruled.

The alias spread is narrowed so it no longer overrides a key the canonical bag also declares:

...componentProps,
...propsWithoutCanonicalKeys(evaluatedSchema.props, evaluatedSchema.properties),

Why subtracting from props rather than re-spreading properties after it — this is the
load-bearing implementation choice. properties.* is already on the node via the hoist, so its
values are in componentPropshaving passed the metadata strip. Re-spreading the raw bag
would push stripped schema metadata back out as React props, which is precisely the regression
where a spec-documented dataSource binding shadowed the injected adapter and broke the
component (objectstack#5576). Subtracting adds no key to the outgoing bag at all; it only
decides which of two co-present values a key carries.

Scope is deliberately narrow — a reading moves only where both bags declare the same key:

  • a key only props declares is untouched (the alias keeps working);
  • a key only properties declares is untouched (it already won);
  • type/id are skipped, because the hoist never copies a canonical value up for them, so
    dropping the alias would delete the prop rather than replace it;
  • a degenerate (non-object) properties is left alone — the hoist and readProps() both
    merely object-spread it, and there is no canonical bag to prefer.

Each of those four carve-outs is pinned by a test, so they are decisions on the record rather
than accidents of the implementation.

Requirement (2): the invariant is pinned cross-channel, not per-file

A test covering only the channel this PR edited would let the two drift apart again — which is
the defect class here, not the individual spread order. So both new files assert the same
key through both read paths
, and the central assertion is an equality between the channels
(expect(bagRead).toBe(reactPropRead)), which fails if either side is re-inverted later, even
if someone edits the per-channel expectations to match a new reading.

  • packages/react/src/__tests__/SchemaRenderer.aliasPrecedenceCrossChannel.test.tsx — 8 tests.
    One probe reads the config bag and its own React props in a single render of a single node.
  • packages/components/src/__tests__/alias-precedence-cross-channel.test.tsx — 4 tests, with
    no stand-in for either path: element:text (the real readProps() family) and badge
    (the real spread path) driven through the real SchemaRenderer.

Requirement (3): adjacency to #4795's pending question ②

#4795 question ② asks whether the properties envelope is an official ui:* authoring channel
at all. This PR does not answer that, and takes no position on it. What is settled here is
only precedence between two spellings when both are already present on a node. In particular
the props alias is not retired here — it keeps working wherever the canonical bag does not
claim the same key. That distinction is written into both test files so a later reader cannot
mistake these pins for a blessing of the envelope. #4795 stays open and unaffected.

Verification

All of the following measured at aca492561, the final commit on this branch, re-run after
the last commit so the reported green describes this tree and not an earlier one.

gateresult
pnpm exec vitest run packages/react packages/components (repo root)213 files, 2152 tests, all passed
pnpm exec vitest run packages/plugin-detail packages/layout packages/plugin-designer112 files, 1057 tests, all passed
pnpm --filter @object-ui/react --filter @object-ui/components type-checkpass (script name echoed for both — not a zero-match no-op)
... lintpass, 0 errors (340 / 891 pre-existing warnings, baseline unchanged in kind)
... buildpass
node scripts/check-control-bytes.mjspass (4672 files)
node scripts/check-changeset-presence.mjspass
node scripts/check-changeset-no-major.mjspass
node scripts/check-changeset-fixed.mjspass
node scripts/check-type-check-coverage.mjspass
node scripts/check-lint-coverage.mjspass

Suites were run from the repo root, not via a package-level filter (objectui#3378).
Changeset: patch on @object-ui/react, never major.

Reverse verification — predicted before running

The original SchemaRenderer.tsx was restored from origin/main with the new tests kept, and
the outcome was predicted before the run: 5 of 12 red — the 3 co-occurrence cases in
packages/react, plus the badge spread-path case and the cross-family equality case in
packages/components; the 7 single-spelling and carve-out cases staying green.

Observed: exactly that. 5 failed, 7 passed, and the 5 were the 5 named ones:

× gives ONE answer for the key, and it is the canonical `properties` one
× resolves per key: a key only `props` declares still arrives
× holds for expression values too — both bags evaluate, then canonical wins
× `badge` (spread path) receives the canonical `properties` value as its React prop
× the two families agree on one node — the invariant, stated as an equality
AssertionError: expected 'FROM_PROPS' to be 'FROM_PROPERTIES'

That the 7 stayed green is the half that matters for blast radius: the single-spelling readings
are provably not moved by this change, in either direction.

Is a rebuild in the ablation's resolution path? No — and it cannot hide a stale result. The
root vitest.config.mts aliases @object-ui/react to packages/react/src (line 252), so both
suites resolve the renderer from source, never from dist/. This was not taken on trust:
the ablation was run with dist/still containing the fixed build (verified: 2 occurrences
of the new helper in packages/react/dist/SchemaRenderer.js while src had 0). The tests went
red anyway, which is positive proof that dist is not consulted — had a stale artifact been in
the path, the ablation would have gone green and reported a false negative. The fix was then
restored with git checkout of this branch and confirmed byte-identical to HEAD
(git diff --quiet clean) before the final measurement above.


Generated by Claude Code

A node carrying both `props` (legacy alias) and `properties` (spec spelling)
got a different answer for the same key depending on which channel read it, and
the two channels' precedence was opposite: the config bag (`readProps()` =
`{ ...props, ...properties }`) let `properties` win, while `createElement`
spread `...(evaluatedSchema.props || {})` last and let `props` win.
Maintainer ruling 2026-08-18: `properties` wins on BOTH channels. The config-bag
order was already correct and is untouched; this narrows the alias spread so it
no longer overrides a key the canonical bag also declares.
Closes objectui#5123
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)25.3 KB350 KB
Entry fileindex-CPTBYzIX.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)9.83KB3.70KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)8.92KB3.41KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)29.33KB7.05KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.13KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.64KB2.21KB
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)40.21KB10.79KB
auth (createAuthenticatedFetch.js)6.34KB2.43KB
auth (index.js)2.71KB1.22KB
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.02KB0.88KB
auth (useIsWorkspaceAdmin.js)1.61KB0.85KB
collaboration (CommentThread.js)26.07KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.65KB0.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)506.27KB113.31KB
core (index.js)4.11KB1.62KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)159.80KB44.34KB
fields (index.js)237.07KB59.46KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.42KB1.39KB
i18n (pickLocalized.js)3.69KB1.73KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)29.43KB7.15KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)39.16KB10.97KB
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)6.96KB1.98KB
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)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.35KB3.31KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.42KB1.42KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.91KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.52KB
permissions (usePermissions.js)1.81KB0.83KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.75KB18.37KB
plugin-chatbot (index.js)181.21KB43.14KB
plugin-dashboard (index.js)128.04KB32.75KB
plugin-designer (index.js)212.39KB42.83KB
plugin-detail (index.js)241.46KB60.56KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)123.77KB30.07KB
plugin-gantt (index.js)164.10KB39.87KB
plugin-grid (index.js)198.22KB53.27KB
plugin-kanban (index.js)52.93KB14.60KB
plugin-list (index.js)111.66KB27.13KB
plugin-map (index.js)19.96KB6.56KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)42.84KB11.77KB
plugin-timeline (index.js)26.68KB7.66KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.34KB20.61KB
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.44KB0.22KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)36.10KB12.26KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.33KB0.69KB
react (schema-input.js)1.45KB0.83KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (index.js)4.77KB2.16KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)10.76KB3.17KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)6.92KB2.40KB
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 (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.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-retry.js)4.32KB2.02KB
types (index.js)3.08KB1.53KB
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 (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
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-support-ai
os-support-ai marked this pull request as ready for review August 19, 2026 00:52
@os-support-ai
os-support-ai added this pull request to the merge queueAug 19, 2026
Merged via the queue into main with commit 70a774bAug 19, 2026
22 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-5123-properties-wins-both-channels branch August 19, 2026 00:52
Sign up for freeto 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.

propsproperties 同现时,alias 优先级按「读法」相反 —— 配置袋读到 properties,React prop 读到 props

1 participant

@os-support-ai