fix: make the composite registry observable so saved components resolve - #26
Merged
Conversation
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.
This was referenced Jul 18, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug, reproduced
A composite named "Price Tag" seeded into
localStorageexactly as the app persists it, placed on a page, opened as a shared link:On load the preview rendered:
After one spacebar keystroke in the JSON editor, nothing else changed:
Root cause
compositeRegistryis 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 touchesjsonText, 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/unregisterCompositebumps a version and notifies subscribers, consumed viauseSyncExternalStore.The rejected alternative was adding
compositesto the memo's dep array. That works only because asetStatecurrently 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
custom_price_tag_abc123while 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.binding-diffmirrorsgenerateSchemaFromBindingsinstead of readingrequireddirectly, because the builder seedsdefault: ""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.loadCompositesreports entries it could not read, as a newnoticetype that does not suppress the "Valid" indicator.handleImportCompositesregistered and persisted inside asetStateupdater; under StrictMode that double-registers and now would notify the store mid-render.Verification
masterand pass herebinding-difftests assert each verdict against the real generated schema, so the diff cannot drift from schema generation silentlyAn 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