Skip to content

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

@yinlianghui

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:204

export const SchemaRenderer = forwardRef< any, { schema: SchemaNode } & Record< string, any > >(({ schema, ...props }, _ref) => {

This is the identical defect #4422 measured and PR #4438 fixed in packages/components, and that #4528 swept out of plugin-dashboard / plugin-list. Record< string, any > puts string into keyof Props exactly as [key: string]: any does, so 'ref' extends keyof Props is always true, React's PropsWithoutRef takes its Omit branch, and Omit over a type carrying a string index signature keeps only the index signature. Every declared prop is erased — here that is schema itself, so the render function reads schema as any.

Why nothing has caught it

Two independent blind spots, and they compound:

  1. 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 src that mentions forwardRef and declares [key: string]: any" — 18 files. Record< string, any > does not match that grep, so this site was never in any candidate list.

  2. The guards' detector is syntactic. hasStringIndexSignature in 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 a ts.isIndexSignatureDeclaration member, resolving type references only through types declared in the same file. Record is a global mapped type, so localTypes.get('Record') misses and the function returns false. The guard reports this site as CLEAN.

Measured on current main, running #4438's collector over every package src (220 forwardRef sites, 18 of them schema-reading):

A offenders — index signature on type arg:
   (none)
A offenders — unannotated param:
   packages/react/src/SchemaRenderer.tsx:204

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

SchemaRenderer is 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 on packages/react immediately (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 teach hasStringIndexSignature about Record< 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

Activity

  1. self-assigned this
    on Aug 13, 2026
  2. yinlianghui commented on Aug 13, 2026

    @yinlianghui
    CollaboratorAuthor

    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:

    1. 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.
    2. 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-check canary 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).
    3. 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.
    4. 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.
    5. 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

  3. yinlianghui commented on Aug 13, 2026

    @yinlianghui
    CollaboratorAuthor

    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 silently any and 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.schema declares BaseSchema | 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 42 case'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

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

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions