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

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions