Skip to content

fix: make the composite registry observable so saved components resolve - #26

Merged
DCCA merged 1 commit into
masterfrom
feat/composite-library-trust
Jul 18, 2026
Merged

fix: make the composite registry observable so saved components resolve#26
DCCA merged 1 commit into
masterfrom
feat/composite-library-trust

Conversation

@DCCA

@DCCA DCCA commented Jul 18, 2026

Copy link
Copy Markdown
Owner

The bug, reproduced

A composite named "Price Tag" seeded into localStorage exactly as the app persists it, placed on a page, opened as a shared link:

On load the preview rendered:

custom_price_tag_abc123
Unknown component: "custom_price_tag_abc123"

After one spacebar keystroke in the JSON editor, nothing else changed:

Composite Rendered OK

Moment "Unknown component" occurrences
On load 2
After one keystroke 0

Root cause

compositeRegistry is a module-level mutable object React cannot observe, and the single validation memo (app/sandbox/page.tsx) depended only on [jsonText]. Saved composites register in a mount effect that never touches jsonText, so the memo ran on first render against an empty registry, produced "Unknown component", and never recomputed.

Two symptoms, one cause: shared links showing "Unknown component", and editing a placed composite leaving the preview stale.

The fix

Registration itself is now the change signal - every registerComposite/unregisterComposite bumps a version and notifies subscribers, consumed via useSyncExternalStore.

The rejected alternative was adding composites to the memo's dep array. That works only because a setState currently happens to sit beside every register call; any future path that registers without it silently reintroduces the bug, and the failure mode is "your component renders the old version".

Also in this change

  • Picker shows names. Composites appeared as custom_price_tag_abc123 while the name the user typed sat unused. Built-ins now read "Account Header" rather than "AccountHeader" too - the raw key is still in the JSON and the section list.
  • In-use warning. Editing a composite that sections already use shows a live inline banner. binding-diff mirrors generateSchemaFromBindings instead of reading required directly, because the builder seeds default: "" on every binding it creates - which makes a "required" string binding not breaking. It catches unbound props, newly required props, type changes, and narrowed enums.
  • No more silent data loss. loadComposites reports entries it could not read, as a new notice type that does not suppress the "Valid" indicator.
  • Import fix. handleImportComposites registered and persisted inside a setState updater; under StrictMode that double-registers and now would notify the store mid-render.

Verification

  • 260 unit tests (up from 239), 11 e2e (up from 7), typecheck, lint, format, build - all green
  • The four new e2e tests fail against master and pass here
  • binding-diff tests assert each verdict against the real generated schema, so the diff cannot drift from schema generation silently
  • Visually verified in light and dark: composite renders on load, warning banner, picker labels, storage notice

An adversarial review pass found three false negatives in the first cut of binding-diff (optional→required tightening, type changes, narrowed enums) plus a false positive from the seeded default. All are fixed and covered.

Spec: docs/superpowers/specs/2026-07-18-composite-library-trust-design.md

Opening a shared link that used a saved composite rendered
'Unknown component' until the user typed in the editor, and editing a
composite already on the canvas left the preview showing the old version.

Root cause: compositeRegistry is a module-level mutable object React
cannot observe, and the validation memo depended only on [jsonText].
Composites register in a mount effect that never touches jsonText, so the
memo resolved against an empty registry on first render and never
recomputed.

The registry is now a subscribable store - every register/unregister bumps
a version and notifies listeners, consumed via useSyncExternalStore. Both
symptoms resolve through one mechanism, and future readers are correct by
construction rather than by a setState happening to sit beside each
register call.

Also in this change:

- The Add Section picker shows the component's name instead of its raw
  registry key, so composites no longer appear as custom_price_tag_abc123.
- Editing a composite that sections already use shows a live inline
  warning. binding-diff mirrors generateSchemaFromBindings rather than
  reading 'required' directly, because the builder seeds default: '' on
  every binding, which makes a 'required' string binding non-breaking. It
  catches unbound props, newly required props, type changes and narrowed
  enums; its tests assert each verdict against the real generated schema.
- loadComposites reports entries it could not read instead of discarding
  them silently, surfaced as a new 'notice' error type that does not
  suppress the Valid indicator.
- handleImportComposites registers and persists outside the setState
  updater, which under StrictMode would double-register and notify the
  store mid-render.
@DCCA
DCCA merged commit 5bd6c81 into master Jul 18, 2026
2 checks passed
@DCCA
DCCA deleted the feat/composite-library-trust branch July 18, 2026 20:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant