Skip to content

finding(spec): CollapsibleSchema.open is LIVE through the rest-spread — it overrides defaultOpen and leaves the trigger frozen (this card's original "read by no renderer" premise is REFUTED) #8236

Description

@os-justin

⛔ Correction — this card's headline premise was false

Filed 2026-09-07 against f57ca757b; corrected 2026-09-10 against origin/main 72bcd7783.

The original body inferred inert from the absence of a schema.open read in the renderer. That inference is unsound for this renderer, and the repo has already landed the refutation in prose — packages/components/src/__tests__/alert-dialog-footer-keys-liveness-7963.test.tsx:22-23 cites this card as one of two cases where "called unread" was falsified:

objectui#8236 — CollapsibleSchema.open was called unread and turned out to control expansion, override defaultOpen and freeze the trigger.

The title, the "⇒ inert" conclusion, and the framing of both routes were all wrong. Everything below is re-derived on 72bcd7783. Read the "Not measured" section before acting on this card.

The mechanism, re-derived

1 — open is not stripped. packages/react/src/SchemaRenderer.tsx separates schema metadata from what leaves for the DOM with one destructure:

const {
  type: _type, children: _children, body: _body, schema: _schema,
  visible: _visible, visibleWhen: _visibleWhen, visibleOn: _visibleOn, visibility: _visibility,
  hidden: _hidden, hiddenOn: _hiddenOn, disabled: _disabled, disabledOn: _disabledOn,
  dataSource: _dataSource, testId: _testId,
  _hidden: __hidden, _disabled: __disabled, responsiveStyles: _responsiveStyles,
  ...componentProps
} = evaluatedSchema;

open is not on that list — grep -nE '\bopen\b' over the whole destructure block returns 0 hits. So an authored open survives into componentProps, and SchemaRenderer.tsx:1591 spreads it:

...componentProps,  // Spread non-metadata schema properties as props

2 — the renderer forwards it, last. packages/components/src/renderers/disclosure/collapsible.tsx:22-23:

({ schema, className, disabled: hostDisabled, ...props }: { schema: CollapsibleSchema; className?: string; disabled?: boolean; [key: string]: any }) => (
  <Collapsible defaultOpen={schema.defaultOpen} disabled={hostDisabled} className={className} {...props}>

open lands in ...props and is spread onto Radix Collapsibleand {...props} is written last, so props.open beats the explicitly-written defaultOpen. No schema.open read is needed, which is exactly why the original grep found nothing and concluded the opposite of the truth.

3 — the other half cannot be authored. onOpenChange is a runtime slot the zod twin refuses by name (objectui#6124). So an authored open makes the primitive controlled with no way to change it: the trigger stops working.

⇒ The defect is the inverse of the one filed. open is not a key that validates and does nothing. It is a live key that silently takes over the component, and the only way an author discovers it is that their collapsible stops responding to clicks.

What the card got right: the faces disagree

face declares open? where
TS interface ✅ yes packages/types/src/disclosure.ts:119-122/** Controlled open state */ open?: boolean;
zod twin ✅ yes packages/types/src/zod/disclosure.zod.ts:55open: z.boolean().optional().describe('Controlled open state')
registration inputs no collapsible.tsx:35-44 declares only defaultOpen, disabled, trigger, content, className
runtime honours it via the rest-spread above

Still a live instance of objectui#4631 (nothing reconciles the three declared surfaces) — but with the registration face as the odd one out, not the runtime, which is the reverse of how the card read it.

Authorship census (conclusion unchanged, re-measured)

git grep -E "(\"type\"|type)\s*:\s*['\"]collapsible['\"]" at 72bcd7783 returns 8 lines in 6 files; one is disclosure.ts:93's discriminant declaration, not an authored node. 7 authored collapsible nodes, in 5 files:

  • content/docs/components/disclosure/collapsible.mdx
  • examples/schema-catalog/src/schemas/components-disclosure-collapsible/basic-collapsible.json
  • packages/components/src/__tests__/disabled-verdict-one-carrier.test.tsx:182
  • packages/types/src/__tests__/disabled-twin-symmetry-7087.test.ts:205
  • packages/types/src/__tests__/overlay-trigger-union-7081.test.ts:225, :239, :242

Firing control: all 7/7 carry trigger, so the instrument does read sibling keys off these nodes. open: 0/7.

⇒ nothing in the repo trips this today. The defect is latent, waiting for the first author who reads the interface and believes it.

The decision, re-framed

The original card offered "wire it" vs "retire it" as if the starting state were inert. It is not, so both routes mean something different now:

  • A — retire it (ADR-0049 enforce-or-remove). Drop open from the interface and the zod twin. Retirement alone does not stop the behaviour: it converts a silent takeover into a validation error, which is the point — provided the validator is on the path. If it is not, the key keeps riding the spread and retiring the declaration only removes the author's warning while leaving the takeover. ⚠️ Precondition, not measured here: does the zod refusal gate the render path, or only validate?
  • B — wire it properly. Read schema.open, pass it to Radix, add the missing half. Still blocked exactly where the original card said: onOpenChange is refused by name (objectui#6124), so a JSON author cannot supply it. Ends at "controlled open is not authorable in SDUI".
  • C — strip it. Add open to SchemaRenderer's metadata strip list so it cannot ride the spread. Cheapest way to stop the takeover — and almost certainly wrong, because it strips open for every node type, and open is a real live prop on other primitives (dialog, sheet, popover). Recorded so the next reader does not re-derive and re-reject it.

⇒ still a contract decision, not a mechanical fix. Staying pm:queue; priority unchanged.

⚠️ Not measured on this card

Stated plainly so a dev does not inherit a second unsound premise from me:

  1. No DOM probe was run for this correction. Steps 1–3 above are code readings, each with the quoted line, and are checkable by anyone. The behavioural half — controls expansion, overrides defaultOpen, freezes the trigger — is carried from the landed alert-dialog-footer-keys-liveness-7963.test.tsx header, which recorded it as measured. Whoever picks this card should re-measure it through the real SchemaRenderer and the real registry, the way that file re-derived both of its own mechanism halves rather than inheriting them, because line numbers and strip lists move.
  2. Route A's precondition (whether the zod refusal gates the render path) is unmeasured.

Docs

Unchanged decision, changed reason: still deliberately not documented on content/docs/components/disclosure/collapsible.mdx. It is no longer "don't add a row for an inert key" (objectui#8197's trap) — it is "don't document a key whose behaviour is about to change".

Class — corrected

objectui#6158 (RadioGroupSchema.orientation) and objectui#5667 (CalendarViewSchema's 9 of 13) are the same shape as this card was filed, not as it now reads: those are declared-and-unread. This one is declared-and-live-by-accident, which is objectui#8318's shape (16 keys called unread, 9 measured live), and the inverse of CardSchema.variant (reached its primitive through the spread, had no reader at all). Whoever sweeps objectui#4631 should not treat the three as one bucket — and should not trust a schema.KEY grep to sort them.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    domain:specobjectui spec stream: fix lands on packages/types, schema corpus or spec pin coupling — spec lanepm:queuepriority:p3

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions