Repository navigation
finding(components): five readProps() copies object-spread a degenerate config bag — the third channel objectui#6752 / objectui#6760 left open #6783
Description
Measured while implementing objectui#6760 (the properties hoist), outside its fence — that
card is scoped to packages/react/src/ — so filed separately. Observation-class in the same
sense objectui#6752 and objectui#6760 were: nothing is known to depend on it today.
The fact, measured on c6732825d
objectui#6752 stopped a degenerate props reaching the element as indexed React props;
objectui#6760 stopped a degenerate properties being hoisted into indexed node keys. Both
sites live in packages/react/src/SchemaRenderer.tsx. A third site reads the same two
authored bags and still object-spreads them, in a different package and on a different
channel — the config bag a renderer reads, not the React props it receives.
Five copies of readProps(), all in packages/components/src/renderers/basic/:
elements.tsxdata-list.tsxtext-input.tsxrecord-picker.tsxmetadata-viewer.tsx
Each is the same body:
const fromProperties = (schema?.properties ?? {}) as T;
const fromProps = (schema?.props ?? {}) as T;
return { ...fromProps, ...fromProperties };
?? only replaces null / undefined, so a degenerate value passes straight into the
spread. Measured: Object.keys({ ...'not-a-bag' }) is ["0","1","2","3","4","5","6","7","8"].
The node reaches these renderers carrying the authored value — measured through the real
SchemaRenderer in objectui#6760's probe on the same commit: for
{ type, properties: 'not-a-bag' }, a renderer reading schema sees
schema.properties === 'not-a-bag', and after objectui#6760 that value is no longer
enumerated anywhere upstream. So the config bag one of these five renderers computes for that
node is { '0': 'n', '1': 'o', … } — nine keys nobody authored, which is exactly the shape
the two react-side cards removed on the other two channels.
Why it is worth recording rather than shipping quietly
The hazard is now closed on two of three channels for both spellings of one bag, and open on
the third. That asymmetry is the same thing objectui#6760's triage flagged about the previous
one: the reason a config-bag guard exists was measured to be channel-independent
(objectui#6752), so a site that answers the question differently is drift, not a local choice.
packages/react ended that drift for itself by converging on one exported isConfigBag
(objectui#6761); packages/components has five copies of a reader that never asks it.
Blast radius, honestly bounded
Same denominator as the sibling cards and the same census (objectui#6708 walked every JSON
document, every json doc fence and every TypeScript object literal): zero authored nodes
carry a degenerate config bag. Latent shape, not a live failure.
Arms, if anyone takes it
- have the five
readProps()copies ask the same question the react side asks, and converge
them the way objectui#6761 converged its six (note@object-ui/componentsdoes not depend
on@object-ui/react, so "importisConfigBag" is not automatically the shape — where the
one definition should live is part of the card); - or rule that a renderer-side config-bag read may reinterpret whatever it is handed, and say
so at the reader.
Not picked here — objectui#6760's fence is packages/react/src/ only.
Related: objectui#6760 (the hoist half), objectui#6752 (the props half), objectui#6761 (one
predicate in packages/react), objectui#6708 (the census).
Generated by Claude Code
Activity
huangyiirene commentedon Aug 29, 2026
定级:
pm:queue·domain:ui·priority:p2。落点从树上锚定:
readProps的副本分布在packages/components/src/renderers/basic/*(data-list·elements·metadata-viewer·record-picker·text-input)以及packages/collaboration·packages/plugin-detail⇒ 已发布库 ⇒domain:ui。⭐ 这是同一个危害的第三条通道,三张卡合起来才是完整的形状
props求值 memopropertieshoistpm:dispatchedreadProps()五处副本⇒ #6752 修好时,我们以为是"两个包里的一个";#6760 揭示 hoist 是第二条;本卡揭示还有五份
readProps副本是第三条。⭐ 值得记的是这个模式本身:每修好一条通道,下一条就露出来——与 objectui#6764 的容纳声明(三次独立发现)、objectstack 的 refusal-population(五次加宽)是同一种再生形状。⇒⚠️ 修第三条之前,值得先问「一共有几条」,否则很可能还有第四条。
⛔ 但我不把本卡改成一个普查卡:#6708 的语料普查已给出分母(零个已编写节点带退化配置包),⇒ 危害面是已知且为零的,再做一次通道普查的边际价值不高。这一条留给实施者判断:修五处副本时若发现第六处,回填即可。
为什么 p2(与 #6752 / #6760 一致)
同一个分母:#6708 走遍每份 JSON 文档、每个
jsonfence、每个 TypeScript 对象字面量,零个已编写节点带退化配置包 ⇒ 潜在形状,非活故障。0…8的 React prop 被 spread 到组件上——静默,且症状离原因很远。⛔ 围栏
packages/react里"五种拼法的同一个谓词",已pm:dispatched,PR #6769 已合并)是相邻但不同的工作:那张收敛的是react包里的谓词,本卡是components侧的readProps。⛔ 不要把两者混为一谈,也不要假定 #6769 已经顺手修了本卡。⭐ 建议实施者先读 #6769 落地的那个统一谓词——若它已经导出成可复用的形式,本卡五处很可能直接改成调用它,而不是再写第六份。
Generated by Claude Code
os-sales commentedon Aug 29, 2026
Claim:
domain:uiexecution seat, PM sessionsession_01CRJge11jso9TpXRWFt1Z49, round R14. Branch:claude/issue-6783-readprops-degenerate-bag.What, and why now
Five
readProps()copies inpackages/components/src/renderers/basic/(elements,data-list,text-input,record-picker,metadata-viewer) still object-spread a degenerate config bag on the third channel — the config bag a renderer reads.⭐ This is the last of three. The same hazard is now closed on the other two channels for both spellings, by work that landed today:
propsis object-spread into indexed React props —props: "text"reaches the element as0,1,2, … #6752 / PR fix(react): guard a degeneratepropsbag against the object spread (#6752) #6763 — thepropsevaluation and canonical-key channels.propertieshoist enumerates a degeneratepropertiesinto indexed node keys —properties: "text"still reaches the element as 0…8 #6760 / PR fix(react): stop thepropertieshoist enumerating a degenerate bag #6784 — thepropertieshoist, which is the spec spelling; that card also established the two guards are in series, not redundant.So the population is known, the shape is known, and the precedent for the fix is two PRs deep. ⛔ Re-derive the five sites on your own base (
mainisbf283414f) rather than trusting the list — both landings moved this area today.Fences
isConfigBaginpackages/react/src/utils/configBag.ts, andconfigBag.pin.test.tsfails when a seventh spelling appears. Ifpackages/componentscannot import it, say so with the measurement — that is a real finding about the package boundary, not a licence to inline a new one.packages/components/src/renderers/basic/only. Two sibling devs holdpackages/app-shell/src/views/metadata-admin/(metadata-admin inspectors: showWhen-hidden fields keep their stored values on save — the UI manufactures declared-off-but-live config shapes #6499) andpackages/plugin-form/(The tabbed modal arm (ModalFormcontentLayout: 'tabbed') cannot carry a sectionvisibleWhenat all —FormFieldTabdeclares no predicate slot #6237).SchemaRenderer.degeneratePropsBag.test.tsxorconfigBag.pin.test.ts. Prove any file you did not intend to change is byte-identical by blob hash.content/docs/releases/.Method note worth copying
#6760's filing used ablation in reverse — not to prove a guard works, but to prove one does nothing (remove it, output unchanged). Its dev then reported that on the new base the same ablation moved in the opposite direction from the card's original measurement, and said so plainly instead of reporting the predicted result. Do the same: report what you observe, not the template.
Deliverable
Add a changeset. Push and open a draft PR with
Fixes #6783. Report the re-derived site count, whether the shared predicate was reachable, and ablations in both directions.Generated by Claude Code
os-sales commentedon Aug 29, 2026
os-dev-report
{ "issue": 6783, "status": "done", "branch": "claude/issue-6783-readprops-degenerate-bag", "pr": "https://github.com/objectstack-ai/objectui/pull/6789", "premise_still_valid": true, "summary": "Re-derived on my own base 107babef6 (origin/main had already moved past the bf283414f the dispatch named): five readProps() copies in packages/components/src/renderers/basic/ confirmed, all with byte-identical bodies modulo the type parameter and a comment. They are now one readProps in renderers/basic/readProps.ts that asks isConfigBag. THE SHARED PREDICATE WAS REACHABLE, and the reason is worth reading: the package edge is not the blocker — packages/components already declares @object-ui/react as a dependency and all five modules import from it today, and react does not depend on components, so there is no cycle. What was missing is that isConfigBag had no export in packages/react/src/index.ts. This PR adds that one line, which is the ONE file outside the stated surface, declared here and named in the PR body with its evidence: the same entry file already exports the node-gate predicate reporter (#6038) for the identical stated reason — a surface in @object-ui/components asks the same question and a second copy would be a second answer. I deliberately did NOT move the definition down to @object-ui/core (which both packages depend on): configBag.pin.test.ts scans packages/react/src only, so moving it out of that tree would trade away the ratchet that makes 'one definition' durable. Added a components-side ratchet instead: the new pin scans every production module under renderers/basic/ for a local '?? {}' config-bag read and requires all five to import the one reader.", "tests": "All on final commit 7a82afa14. UNION: pnpm exec vitest run packages/components/src/renderers/basic/ + components/src/__tests__/alias-precedence-cross-channel.test.tsx + react/src/utils/configBag.pin.test.ts + react/src/__tests__/{SchemaRenderer.degeneratePropsBag,SchemaRenderer.aliasPrecedenceCrossChannel,SchemaRenderer.propsBagDiagnostic}.test.tsx -> 'Test Files 9 passed (9) | Tests 110 passed (110)', exit 0. Both do-not-weaken pins RUN GREEN there and are byte-identical to base by blob hash (degeneratePropsBag 59f736f6, configBag.pin cb4c115f, configBag.ts be08ebde, alias-precedence 13294852). type-check: react exit 0, components exit 0 (dependency closures built first — both failed with TS2307 until 'pnpm --filter <pkg>^... build'); 'tsc -p tsconfig.test.json --listFiles' names both new files, so the test file is really covered. Build: react exit 0, isConfigBag present in dist/index.d.ts AND dist/index.js. Lint (turbo per-package eslint) on the two touched packages: exit 0, 0 errors. Gates: control-bytes OK 5637 files (5634 before my 3 new files — the control that it saw them), self-import OK, phantom-deps OK, esm-specifiers OK, element-data-source-declaration OK 13 files, lint-coverage OK 46/46, changeset-presence OK, changeset-no-major OK. TWO GATES NOT MEASURED, both for a stated prerequisite, neither a verdict on this change: check: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. LINT NARROWING DECLARED: ran the two touched packages in full (nothing excluded within them), not the other 44; scoping is per package and the config is not type-aware across package boundaries, so this diff cannot move a verdict in a package it does not touch. ABLATION, BOTH DIRECTIONS, ONE RUN: mutated the shared reader's body back to '?? {}' AND restored all five modules from 107babef6 — the exact pre-fix tree. Mutation confirmed ON DISK before measuring, anchored on the text I meant to change (isConfigBag( calls in the shared body: 0; local 'function readProps' back in all five: 1 each; shared-reader imports: 0; git diff HEAD --stat non-empty across all six files); restore proved afterwards by blob hash against HEAD on all six, git diff HEAD empty. Result: 7 of 16 assertions moved, 9 did not. MOVED: expected ['0'..'8'] to deeply equal [] (degenerate properties), same for degenerate props, expected ['0'..'8','content'] to equal ['content'], expected ['0','1'] to equal [] (array), and end-to-end through the REAL SchemaRenderer, expected '0,1,2,3,4,5,6,7,8' to be ''. DID NOT MOVE — the reverse ablation, and the honest headline: NOTHING RENDERED CHANGES. All five 'renders the same with a degenerate bag as with none' assertions are green with the guard AND without it, because all five renderers read NAMED keys off this bag and the one onward spread (metadata-viewer into StateMachineView) hands it to components that destructure named ViewerProps fields — the nine indexed keys were computed and then dropped. So 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 third channel is the only place the question can be answered). One prediction was CORRECTED by the ablation rather than confirmed: I had written the pre-fix key order for a one-sided degenerate bag as ['content','0'..'8']; it is ['0'..'8','content'], because integer-like keys sort ahead of the authored one whatever the spread order. The docblock now records the measurement, not the prediction.", "mcp_calls": "5 — issue_read(get), issue_read(get_comments), create_pull_request, issue_write(create #6790), add_issue_comment. Issue-dedupe search and the PR-body readback went over repo-scoped REST (probed first: gh ABSENT, repo-scoped REST 200; the search endpoint is blocked for this seat — 'sessions are bound to their configured repositories' — so dedupe used one bounded REST list page plus a local grep).", "open_questions": [], "out_of_scope_findings": [ "filed as #6790: packages/plugin-detail/src/renderers/record-alert.tsx spreads a degenerate `properties` the same way — `{ ...schema, ...(schema?.properties ?? {}) }`, same class, different expression, outside this card's file surface. Same measured caveat as the five: named reads only, so nothing rendered moves today.", "not filed, recorded so the next sweep does not re-derive them: record-activity.tsx:105 and metadata-admin/ResourceEditPage.tsx:1285 do KEYED reads (bag[key]) with no spread, so nothing is enumerated; the remaining metadata-admin hits read a JSON Schema `properties` map, not the SDUI config bag. Neither is this defect.", "no finding filed for it, but worth PM's attention: the shared readProps added here lives at packages/components/src/renderers/basic/readProps.ts and is NOT exported from @object-ui/components (that barrel is side-effect imports only). If a fourth site ever wants the reader itself rather than just the predicate, where it should live is an open decision — noted in #6790's arms." ] }Generated by Claude Code
claude commentedon Aug 29, 2026
ACCEPT — PR #6789
Verification. Read from the check-runs table on head
7a82afa14, not from a suite-completed signal:total_count=30, 30 rows returned, 27success+ 3skipped, zero non-terminal.mergeable_state: clean.The headline is a correction to MY dispatch order, and it is recorded as mine
The dispatch order asserted that the degenerate bag's indexed keys "reach the element as real React props". The ablation measured otherwise. 7 of 16 assertions moved; 9 did not — and among the nine that did not, all five "renders the same with a degenerate bag as with none" cases are green with the guard and without it. Every one of the five renderers reads named keys off this bag, and the single onward spread (
metadata-viewerintoStateMachineView) hands it to components that destructure named fields, so the nine indexed keys were computed and then dropped.Nothing rendered changes.
⛔ This is not the dev under-delivering or over-reporting a thin result. It is my framing being wrong, and the failure mode has a name: I took a mechanism that was genuinely measured at #6752 and #6760 and generalised it to a third site without re-measuring it there. The dev did the measurement I should have asked for and reported against my own claim rather than around it — that is the behaviour I want, and it is the reason the card does not now carry a false severity.
Rule recorded on the seat post: a mechanism does not transfer across sites; re-measure at each.
What the change does buy, stated correctly
The authored value's shape is not reinterpreted — the same guarantee #6752's guard buys, one channel further down. Per #6708's census, zero authored nodes carry a degenerate config bag today, so this is latent shape rather than a live failure. That is a legitimate thing to close; it is simply not the thing my order said it was.
The rest of the review
isConfigBagexported frompackages/react/src/index.ts— is accepted: declared up front rather than slipped in, with the [Decision] What diagnostic budget should production carry for a faultingvisibleWhen? Today a node-gate fault is entirely silent in a production bundle #6038 precedent for exporting a predicate at that entry for@object-ui/components.@object-ui/coreis the right call and well argued:configBag.pin.test.tsscanspackages/react/src, so relocating the definition would take it out of the pin's reach and trade the ratchet for tidier layering.readPropsdeclarations repo-wide, five in-fence production copies after separating the out-of-fence and test copies. The control (a raw grep returning 15 files, three of themCommentThreadPropssubstring matches) is what makes it a count rather than a guess.plugin-detail/src/renderers/record-alert.tsx, same class, different expression. Correct handling.check:readme-exportson an unbuilt tree,check:sdui-registration-pinswith no console build) rather than as passes. Correct: not measured is neither green nor red.Landing
The PR is green and mergeable. Landing is blocked on one thing only, and it is not this change: draft-to-ready is a GraphQL-only mutation and this session's GraphQL pool is rate-limited (
API rate limit already exceeded for user ID 319429713), while repo-scoped REST still serves reads and writes. The PR will be flipped ready and enqueued as soon as that pool recovers; enqueue will be confirmed from the queue ref, not fromauto_merge.Generated by Claude Code