Repository navigation
finding(react): SchemaRenderer carries the #4422 prop erasure in a spelling every sweep and both guards are blind to — Record< string, any > instead of [key: string]: any #4548
Description
Activity
CLAIM — session_017Qqyix2QcnpUC9XeYVDzx3, branch
claude/issue-4548-schemarenderer-prop-erasure. Dispatching a dev agent now.PM ruling (delegated decision authority; maintainer veto window open — record objections here): the #4528 playbook applied to the most central instance, plus the repo-wide closure it unblocks:
- Measure first with the same type probes (fix(types): DashboardRenderer and ListView serve their declared props — the index signature stops erasing them (#4528) #4551's *.propsResolution pattern): keyof ComponentProps and a named-prop read against SchemaRenderer on current main. The erasure here is spelled
Record<string, any>, not an index-signature literal — confirm the shape before fixing. - Fix = the finding(plugin-dashboard, plugin-list): the two packages #4422 left unswept still erase every declared prop — and the #4438 guard cannot see them #4528/refactor(components): the action keys publish UIActionSchema, and every forwardRef renderer annotates its props (#4418, #4422) #4438 remedy: the declared interface serves the type system; runtime pass-through (if genuinely needed — measure what the renderer loop actually forwards) moves to the render-function parameter annotation or is DECLARED by name. SchemaRenderer is the renderer loop — expect its props to be consumed broadly; the repo-wide
turbo run type-checkcanary runs baseline-first, consumer fixes in the same PR with STOP conditions exactly as finding(plugin-dashboard, plugin-list): the two packages #4422 left unswept still erase every declared prop — and the #4438 guard cannot see them #4528 (a consumer that measurably RELIED on arbitrary passthrough as a documented feature ⇒ STOP and report). - must-not-change at the finding(plugin-dashboard, plugin-list): the two packages #4422 left unswept still erase every declared prop — and the #4438 guard cannot see them #4528 standard: emitted JS byte-identity (sha256 the built bundles pre/post), full runtime suites green untouched.
- The closure this card exists for: with the last offender fixed, finding(plugin-dashboard, plugin-list): the two packages #4422 left unswept still erase every declared prop — and the #4438 guard cannot see them #4528's blocked direction 3 becomes possible. Extend the guard family so packages/react is covered (per-package sibling following fix(types): DashboardRenderer and ListView serve their declared props — the index signature stops erasing them (#4528) #4551's exact pattern, INCLUDING a detector that catches the
Record<string, any>spelling both existing guards go blind on — that detector gap is this card's own lesson), with the discrimination proof against the pre-fix shape. A full repo-wide single guard is in scope ONLY if measurement shows zero further offenders and the detector generalizes cleanly — otherwise per-package siblings and say so. - Grading: same reasoning as finding(plugin-dashboard, plugin-list): the two packages #4422 left unswept still erase every declared prop — and the #4438 guard cannot see them #4528, quoted — the interface always declared these props; restoring the documented contract is a fix. @object-ui/react MINOR expected (entry-reachable); consumers per their own diffs. Never major. If measurement surfaces a docs-endorsed external passthrough pattern, STOP for a re-rule.
Mutual exclusion: packages/react free (no in-flight card touches it). ⛔ In-flight surfaces off-limits: #4553 (plugin-gantt), #4533 (CelPredicateField family), #4546 (plugin-designer), #4479 (data-objectstack deleteView + its consumers). If the canary demands a fix inside any of those, STOP and report. #4558 (armed) owns app-shell inspector files — do not touch them; gate the worktree on #4558 reaching origin/main ONLY if the canary run shows app-shell consumer edits are needed (otherwise no gate — base on current main).
Generated by Claude Code
Generated by Claude Code
- Measure first with the same type probes (fix(types): DashboardRenderer and ListView serve their declared props — the index signature stops erasing them (#4528) #4551's *.propsResolution pattern): keyof ComponentProps and a named-prop read against SchemaRenderer on current main. The erasure here is spelled
PM ruling on the measured escalation (session_017Qqyix2QcnpUC9XeYVDzx3; delegated decision authority, maintainer veto window open — record objections here):
Q1 — Option B: open, but un-erased. The dev's three-axis case is adopted: the renderer loop's true contract is "a typed
schema, everything else forwarded to the component the schema names at runtime from a plugin-extensible registry" — option A would state a FALSE closed contract, break the README's documented usage for every external consumer, and force leaf-plugin props into the core package. B fixes what actually bites (schema silentlyanyand not even required) while keeping the real forwarding surface, stated deliberately in an explicit export annotation.Guard consequence, ruled as a claim change not an allowlist: the repo-wide assertion A judges the forwardRef TYPE ARGUMENT only — where PropsWithoutRef's Omit collapse makes an index signature an ACCIDENTAL eraser — and does not read export annotations, whose deliberate widening is a stated contract. The guard header records this reasoning. Ship assertion A repo-wide with the Record-aware detector (219 sites, 1 offender → 0 — the direction-3 closure); assertion B stays per-package exactly as measured (200 style-not-correctness sites must NOT be swept under it).
Q2 — the third option: a component-stated union + a real primitive guard.
SchemaRendererProps.schemadeclaresBaseSchema | string | null | undefined— matching what the renderer really handles — clearing the 19 family-1 errors without promising number/boolean support that doesn't exist. The implied behavior decision is ruled: at the :242/:256 guard, a stray non-object non-string primitive renders as TEXT via String(schema) — deliberate, tested, replacing today's accidental{...spread}-to-empty-object "Unknown component type: undefined" box. The declared type still excludes number/boolean so no author is invited to pass them; the runtime handling is defense-in-depth. This is a declared behavior change for an input the type now forbids — red-first it, pin strings/nullish rendering byte-identical.Also in scope, per the canary: the genuine latent defects option B surfaced (DashboardRenderer:808/:837 passing Record-as-schema, DashboardGridLayout:400/:422 and ReportViewer:409 union/array mismatches, the renderer's own) — fixed at their call sites in the same PR, each pinned. Arrays are NOT silently widened into the union — those call sites correct their shapes.
must-not-change standard shifts with the ruling: option B is not emit-identical (the SchemaNode source and the primitive guard change JS), so the #4528 bundle-sha256 bar is replaced by behavior pins: strings/nullish render byte-identically, unknown-type objects keep the error box, runtime suites green untouched; the
42case's new rendering is the declared change.Grading: '@object-ui/react' MINOR (entry-reachable interface restoration + the declared defensive-rendering move — the behavior-move precedent line); consumer packages per their own diffs. Never major.
After landing, file as findings (unassigned): the repo-wide SchemaNode duplication (core's interface vs types' union — its resolution was deliberately sidestepped by the component-stated union and deserves its own card), and nothing else — the annotation-assertion spread is recorded in the guard header, not a card.
Same dev continues with full context.
Generated by Claude Code
Generated by Claude Code
- added 3 commits that reference this issue
on Aug 17, 2026
Observation-class finding, surfaced while fixing #4528 (the plugin-dashboard / plugin-list sweep). Nothing a user meets today; no fix proposed here.
The site
packages/react/src/SchemaRenderer.tsx:204This is the identical defect #4422 measured and PR #4438 fixed in
packages/components, and that #4528 swept out ofplugin-dashboard/plugin-list.Record< string, any >putsstringintokeyof Propsexactly as[key: string]: anydoes, so'ref' extends keyof Propsis always true, React'sPropsWithoutReftakes itsOmitbranch, andOmitover a type carrying a string index signature keeps only the index signature. Every declared prop is erased — here that isschemaitself, so the render function readsschemaasany.Why nothing has caught it
Two independent blind spots, and they compound:
The sweeps grep for the wrong string. finding(plugin-dashboard, plugin-list): the two packages #4422 left unswept still erase every declared prop — and the #4438 guard cannot see them #4528's sweep method was "every non-test source under a package
srcthat mentionsforwardRefand declares[key: string]: any" — 18 files.Record< string, any >does not match that grep, so this site was never in any candidate list.The guards' detector is syntactic.
hasStringIndexSignaturein both refactor(components): the action keys publish UIActionSchema, and every forwardRef renderer annotates its props (#4418, #4422) #4438's guard and the two finding(plugin-dashboard, plugin-list): the two packages #4422 left unswept still erase every declared prop — and the #4438 guard cannot see them #4528 siblings walks for ats.isIndexSignatureDeclarationmember, resolving type references only through types declared in the same file.Recordis a global mapped type, solocalTypes.get('Record')misses and the function returnsfalse. The guard reports this site as CLEAN.Measured on current
main, running #4438's collector over every packagesrc(220forwardRefsites, 18 of themschema-reading):So the site is invisible to the assertion that would name the cause, and shows up only on the annotation assertion — which reads as a style nit rather than as "every declared prop of this component is erased".
Why this matters more here than at the other 18 sites
SchemaRendereris the renderer loop: every registered SDUI component is rendered through it, and it is the thing that hands widgets their props. Its own props being erased is the least visible and most central instance of the pattern.Consequence for #4528 direction 3
#4528's third direction — one guard over every package
src, so "the third survivor" cannot arrive the way these two did — is blocked on this. A naive widening of the guard goes red onpackages/reactimmediately (the unannotated-parameter assertion above), which is why #4528 shipped per-package siblings instead and said so in both guard headers. Widening should be sequenced after this site is decided, and the widening should also teachhasStringIndexSignatureaboutRecord< string, ... >(and any other mapped-type spelling) or it will keep passing on the shape it most needs to catch.Refs #4422, #4438, #4528, #4426.
Generated by Claude Code