Skip to content

Open Service: Fix reactivity on deep signals, fire subscribers on load dependencies#34979

Open
JReinhold wants to merge 9 commits into
claude/wizardly-bartik-cc2705from
jeppe-cursor/docgen-subscription-referential-equality-5a81
Open

Open Service: Fix reactivity on deep signals, fire subscribers on load dependencies#34979
JReinhold wants to merge 9 commits into
claude/wizardly-bartik-cc2705from
jeppe-cursor/docgen-subscription-referential-equality-5a81

Conversation

@JReinhold
Copy link
Copy Markdown
Contributor

@JReinhold JReinhold commented May 29, 2026

Closes #

Works on #34824

What I did

Reworked the open-service reactive core to use per-field deep reactivity instead of one coarse state atom. This fixes docgen-style double emissions on re-navigation when data is unchanged, makes subscriptions precise, and makes a query's async load reactive to its synchronous dependencies.

State and writes

  • Replaced the single alien-signals atom + Immer with deepSignal (deepsignal) backed by @preact/signals-core. Reading ctx.self.state.<field> tracks only that field (including record keys added later).
  • setState((state) => …) mutates the proxy in place inside batch(); only changed fields invalidate dependents.

Subscriptions

  • Optional selector on subscribe (universal-store pattern): subscribe(input, selector, callback). The selector narrows reactive reads so unrelated sibling fields do not re-run the handler.
  • Value-based dedup at emit via es-toolkit isEqual. Emissions are detached plain values; whole-state snapshots use structuredClone, with JSON detach only for proxy slices from selectors.

Validation

  • Output validation runs at pull boundaries (query(), .loaded(), static build, subscription emit), not on the reactive path. Selector subscribers validate untracked so validation cannot broaden the tracked footprint.

Reactive load

  • For active subscriptions, a query's load re-runs when external signals it reads synchronously change (same-service fields or cross-service via getService). Loads are idempotent warming steps; one-shot side effects belong in commands. Superseded runs are epoch-gated so stale writes cannot clobber newer results. Direct query() / .loaded() remain one-shot.

Dependencies

  • Removed alien-signals; added deepsignal and @preact/signals-core.

Demonstrated behavior (docgen-style emulation: provider returns fresh-but-equal payloads on navigation):

  • Re-navigating to an already-loaded component emits once (no spurious second emission).
  • Extracting an unrelated record does not re-fire other subscribers.

Checklist for Contributors

Testing

The changes in this PR are covered in the following automated tests:

  • stories
  • unit tests
  • integration tests
  • end-to-end tests

Manual testing

Caution

This section is mandatory for all contributions. If you believe no manual test is necessary, please state so explicitly. Thanks!

No manual testing is required. Changes are confined to the internal open-service runtime under code/core/src/shared/open-service/. Coverage is in the Vitest suite (yarn test open-service), including fine-grained handler-spy tests, selector tests, equal-value dedup, and reactive load scenarios (same-service and cross-service deps, coalescing, superseding in-flight loads, non-subscription query() one-shot behavior).

Documentation

  • Add or update documentation reflecting your changes
  • If you are deprecating/removing a feature, make sure to update
    MIGRATION.MD

Updated code/core/src/shared/open-service/README.md (state/reactivity model, validation placement, load semantics, subscription flow including selector and reactive load).

Checklist for Maintainers

  • When this PR is ready for testing, make sure to add ci:normal, ci:merged or ci:daily GH label to it to run a specific set of sandboxes. The particular set of sandboxes can be found in code/lib/cli-storybook/src/sandbox-templates.ts

  • Make sure this PR contains one of the labels below:

    Available labels
    • bug: Internal changes that fixes incorrect behavior.
    • maintenance: User-facing maintenance tasks.
    • dependencies: Upgrading (sometimes downgrading) dependencies.
    • build: Internal-facing build tooling & test updates. Will not show up in release changelog.
    • cleanup: Minor cleanup style change. Will not show up in release changelog.
    • documentation: Documentation only changes. Will not show up in release changelog.
    • feature request: Introducing a new feature.
    • BREAKING CHANGE: Changes that break compatibility in some way with current major version.
    • other: Changes that don't fit in the above categories.

🦋 Canary release

This PR does not have a canary release associated. You can request a canary release of this pull request by mentioning the @storybookjs/core team here.

core team members can create a canary release here or locally with gh workflow run --repo storybookjs/storybook publish.yml --field pr=<PR_NUMBER>

Open in Web Open in Cursor 

Summary by CodeRabbit

  • New Features

    • Query subscribe supports selector functions to emit only selected slices.
  • Improvements

    • Deep reactive state model with value-deduping and gated/coalesced reactive loads for fewer unnecessary updates.
    • Static builds now use detached state snapshots for more accurate outputs.
  • Documentation

    • Clarified validation, load semantics, and subscription flow in Open Service docs.
  • Tests

    • Expanded tests covering reactive loads, selector behavior, and rebuilt-equality scenarios.
  • Chores

    • Core reactivity infrastructure migrated to a deep-proxy model.

…on re-emit

Co-authored-by: Jeppe Reinhold <JReinhold@users.noreply.github.com>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 29, 2026

Warnings
⚠️

This PR targets claude/wizardly-bartik-cc2705. The default branch for contributions is next. Please make sure you are targeting the correct branch.

Generated by 🚫 dangerJS against 8715ba8

Re-engage alien-signals' identity-based dedup in subscribeToQuery by
memoizing the computed: return the previous reference when the new
handler output is deeply equal. Output-schema validation and Immer both
mint fresh references for unchanged data, which previously re-fired
subscribers on every state write (including writes to unrelated keys and
loads that rewrite a deeply-equal payload).

Co-authored-by: Jeppe Reinhold <JReinhold@users.noreply.github.com>
@cursor cursor Bot changed the title open-service: demonstrate referential-equality subscription re-emit open-service: dedup subscription emissions by value May 29, 2026
…te-only

Replace the single alien-signals state atom + Immer with a deepsignal
deep reactive proxy (backed by @preact/signals-core). State mutates in
place inside a batch; subscriptions track per-field reads so an unrelated
key/field write never re-runs a subscriber's handler.

Schemas now validate shape only and never convert: validation output is
discarded and the original reference is returned. A dev-only check throws
OpenServiceSchemaConversionError when a schema transforms/strips/coerces.

subscribe() gains an optional selector (universal-store pattern): the
callback receives the selected slice and fires only when it changes by
value. Emissions are deduped by value via es-toolkit isEqual; query
results and emissions are detached plain snapshots so the proxy never
escapes the runtime.
@cursor cursor Bot changed the title open-service: dedup subscription emissions by value open-service: fine-grained reactivity with deep signals (drop immer, validate-only) May 29, 2026
Output validation now runs only on pull boundaries (query()/.loaded()/
static build) and on whole-value subscription emissions, never narrowing
a selector subscriber's reactive footprint. Because validation no longer
runs where it would broaden tracking, schemas may transform/coerce again:
reverted the no-conversion contract and removed OpenServiceSchemaConversionError.

With a selector, the computed reads only the selected slice, so an
unselected sibling-field change no longer re-runs the handler (regression
guard added). Snapshotting: structuredClone for the plain whole-state
snapshot; JSON only to strip live proxy slices for selectors.

Renamed the docgen-flavored test fixture to concept-based naming
(rebuiltEqualValueOnLoadServiceDef) to match the existing convention.
@cursor cursor Bot changed the title open-service: fine-grained reactivity with deep signals (drop immer, validate-only) open-service: fine-grained reactivity with deep signals (drop immer) May 30, 2026
…es & tests

- Selector subscriptions now validate output too: validation runs
  untracked so it catches shape errors without broadening the reactive
  footprint (regression-guarded). Fixes output validation being skipped
  when a selector was passed.
- createServiceRuntime no longer clones initialState; the clone moves to
  registerService (the static build already clones), removing a double
  clone while still protecting a definition's shared initialState.
- Remove the false-positive 'different record' subscription test (covered
  better by the handler-spy fine-grained test) and merge the two selector
  tests into one emit+recompute test.
- Drop conversion framing from README/JSDoc; validation is described as it
  always behaved.
The draft naming was an Immer-ism; with the deep-signal proxy the callback
receives the live state, so name it accordingly across the type, fixtures,
docgen service, debug service, tests, and README.
A query's load now re-fires for active subscriptions whenever the
external signals it reads synchronously change (same-service or
cross-service via getService), turning it into a reactive async resource.
Implemented by running the load inside its own effect() so its reads are
ambiently tracked; loads with no external synchronous reads still fire
exactly once, so existing services are unaffected.

Superseding: each run carries an epoch and writes through epoch-gated
commands, so a slow stale load cannot overwrite a newer run's state.
Coalescing: changes batched together produce a single re-load. Direct
query()/.loaded() calls keep one-shot semantics. The load effect is torn
down with the subscription.

Loads are now contractually idempotent warming steps; one-shot side
effects belong in commands.
@JReinhold JReinhold self-assigned this Jun 1, 2026
@JReinhold JReinhold added maintenance User-facing maintenance tasks ci:normal Run our default set of CI jobs (choose this for most PRs). core labels Jun 1, 2026
@JReinhold JReinhold marked this pull request as ready for review June 1, 2026 09:09
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jun 1, 2026

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8d50d25a-7e57-4e44-a0d6-cf187d74e2c3

📥 Commits

Reviewing files that changed from the base of the PR and between 382def9 and 8715ba8.

📒 Files selected for processing (1)
  • code/core/src/shared/open-service/service-runtime.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • code/core/src/shared/open-service/service-runtime.ts

📝 Walkthrough

Walkthrough

Refactors open-service runtime from alien-signals/immer to deepsignal deep reactive proxy; moves output validation to call sites; adds selector-capable subscriptions and gated reactive loads; exposes getStateSnapshot(); renames setState callback parameter (draft → state); updates fixtures, tests, docs, and service handlers.

Changes

Open Service Reactivity and State Model

Layer / File(s) Summary
Type contracts, selector API, and dependencies
code/core/package.json, code/core/src/shared/open-service/types.ts, code/core/src/shared/open-service/README.md
Package dependencies add @preact/signals-core and deepsignal (remove alien-signals); Query.subscribe gains a selector overload; CommandSelf.setState callback param renamed from draftstate; README updated to reference deep signals.
Deep signal state model and runtime initialization
code/core/src/shared/open-service/service-runtime.ts, code/core/src/shared/open-service/service-registration.ts
Runtime now initializes a plain rawState, wraps it with deepSignal(rawState), and exposes getStateSnapshot() (structuredClone) instead of stateSignal; registerService clones initialState before creating the runtime; ServiceRuntime type updated accordingly.
Command self, batching, and state snapshot detachment
code/core/src/shared/open-service/service-runtime.ts
createCommandSelf exposes self.state from the deep proxy; setState mutations run inside batch(); adds detachSnapshot() helper; buildGatedCommands(isCurrent) implemented to gate stale writes.
Query execution refactor: validation moved to call sites
code/core/src/shared/open-service/service-runtime.ts
runHandlerSync() returns handler results without validating; output validation is applied at direct query pulls, .loaded(), static builds, and subscription emission boundaries. runLoadBody reads from deep proxy state.
Reactive load execution and subscription epoch gating
code/core/src/shared/open-service/service-runtime.ts
Adds runReactiveLoad() that runs load inside effect() with an epoch/isCurrent gate; gated commands drop stale writes so reactive re-runs cannot clobber newer state.
Subscription selector support, deduping, and reactive wiring
code/core/src/shared/open-service/service-runtime.ts, code/core/src/shared/open-service/types.ts
createDefaultQuery.subscribe() accepts an optional selector; subscribeToQuery() starts reactive loads when present, validates selector outputs untracked, detaches snapshots before callbacks, dedupes emissions with isEqual, and cleans up effects and loads on unsubscribe.
Documentation of reactive state model and subscription flow
code/core/src/shared/open-service/README.md
README expanded to document reactive load semantics, tracking rules, validation points, deep proxy state behavior, microtask-driven subscription pipeline, and updated example using state parameter.
Fixture updates, new rebuilt-equality fixture, and static snapshot
code/core/src/shared/open-service/fixtures.ts, code/core/src/shared/open-service/server.ts, code/core/src/shared/open-service/service-registration.test.ts
Fixture and registration handlers rename setState param from draftstate; adds RebuiltValue types and rebuiltEqualValueOnLoadServiceDef; buildStaticFiles switched to use getStateSnapshot().
Test suite expansion for reactive load and output validation
code/core/src/shared/open-service/service-runtime.test.ts, code/core/src/shared/open-service/index.test-d.ts, code/core/src/shared/open-service/server.test-d.ts, code/core/src/shared/open-service/server.test.ts
Tests expanded for rebuilt-equality, selector validation on subscriptions, background-load dedupe/unrelated-field/selector behavior, and a comprehensive reactive load suite; multiple test fixtures updated for new setState param and snapshot access.
Application to debug and docgen services
code/.storybook/open-service-debug-service.ts, code/core/src/shared/open-service/services/docgen/server.ts
Debug service handlers (addActivity, syncStoryIndex, recordPreloadVisit) and docgen extractDocgen updated to use state parameter in setState callbacks.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • storybookjs/storybook#34960: Touches similar Open Service code paths including debug service and static snapshot handling referenced by this change.
  • storybookjs/storybook#34875: Modifies build/static snapshot aggregation to use getStateSnapshot(), matching server static-store logic changes.
  • storybookjs/storybook#34860: Prior work on open-service runtime state model and subscription behavior related to this refactor.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@code/core/src/shared/open-service/service-runtime.test.ts`:
- Line 327: Replace fixed sleeps (e.g., the await new Promise(resolve =>
setTimeout(resolve, 50)) calls in service-runtime.test.ts) with deterministic
waits using the test runner's wait utilities (vi.waitFor or similar) that wait
for a concrete condition: check a spy/called count, assert the expected
length/content of the emitted array, or wait for a specific state change. Locate
the instances in the tests where those setTimeout sleeps are used and swap them
for vi.waitFor(() => expect(spy).toHaveBeenCalledTimes(n)) or vi.waitFor(() =>
expect(emitted).toEqual([...])) so the test completes when the observable/spy
reaches the expected state rather than after a fixed time.

In `@code/core/src/shared/open-service/service-runtime.ts`:
- Around line 1024-1027: The selector branch currently calls selector(output) on
raw handler output, bypassing schema validation/coercion; change it to run
validateQueryOutput(refs, queryName, queryDef, output) inside the existing
untracked call, capture the validated/coerced value (e.g., const validated =
validateQueryOutput(...)), then call selector(validated) and return
detachSnapshot(selector(validated)). Keep the untracked wrapper and existing
references (selector, untracked, validateQueryOutput, detachSnapshot, output,
queryName, queryDef, refs) so selector subscribers receive the same
validated/coerced TOutput as query()/ .loaded().
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 436b89f1-058f-47ba-a54b-14de09619f8a

📥 Commits

Reviewing files that changed from the base of the PR and between 88740ec and d0210c4.

⛔ Files ignored due to path filters (1)
  • yarn.lock is excluded by !**/yarn.lock, !**/*.lock
📒 Files selected for processing (14)
  • code/.storybook/open-service-debug-service.ts
  • code/core/package.json
  • code/core/src/shared/open-service/README.md
  • code/core/src/shared/open-service/fixtures.ts
  • code/core/src/shared/open-service/index.test-d.ts
  • code/core/src/shared/open-service/server.test-d.ts
  • code/core/src/shared/open-service/server.test.ts
  • code/core/src/shared/open-service/server.ts
  • code/core/src/shared/open-service/service-registration.test.ts
  • code/core/src/shared/open-service/service-registration.ts
  • code/core/src/shared/open-service/service-runtime.test.ts
  • code/core/src/shared/open-service/service-runtime.ts
  • code/core/src/shared/open-service/services/docgen/server.ts
  • code/core/src/shared/open-service/types.ts

Comment thread code/core/src/shared/open-service/service-runtime.test.ts
Comment thread code/core/src/shared/open-service/service-runtime.ts
@storybook-app-bot
Copy link
Copy Markdown

storybook-app-bot Bot commented Jun 1, 2026

Package Benchmarks

Commit: 8715ba8, ran on 1 June 2026 at 12:41:51 UTC

The following packages have significant changes to their size or dependencies:

storybook

Before After Difference
Dependency count 72 74 🚨 +2 🚨
Self size 20.41 MB 20.38 MB 🎉 -25 KB 🎉
Dependency size 36.11 MB 36.65 MB 🚨 +539 KB 🚨
Bundle Size Analyzer Link Link

@storybook/cli

Before After Difference
Dependency count 203 205 🚨 +2 🚨
Self size 908 KB 908 KB 🎉 -144 B 🎉
Dependency size 88.60 MB 89.11 MB 🚨 +514 KB 🚨
Bundle Size Analyzer Link Link

@storybook/codemod

Before After Difference
Dependency count 196 198 🚨 +2 🚨
Self size 32 KB 32 KB 🚨 +36 B 🚨
Dependency size 87.09 MB 87.60 MB 🚨 +514 KB 🚨
Bundle Size Analyzer Link Link

create-storybook

Before After Difference
Dependency count 73 75 🚨 +2 🚨
Self size 1.08 MB 1.08 MB 0 B
Dependency size 56.52 MB 57.03 MB 🚨 +514 KB 🚨
Bundle Size Analyzer node node

…orybookjs/storybook into jeppe-cursor/docgen-subscription-referential-equality-5a81

# Conflicts:
#	code/core/src/shared/open-service/service-runtime.test.ts
#	code/core/src/shared/open-service/service-runtime.ts
@JReinhold JReinhold changed the title open-service: fine-grained reactivity with deep signals (drop immer) Open Service: Fix reactivity on deep signals, fire subscribers on load dependencies Jun 1, 2026
Selector subscribers now receive schema-validated slices. A tracked
selector(output) read preserves fine-grained proxy dependencies because
validation returns a plain parsed value.
@JReinhold JReinhold requested a review from ndelangen June 1, 2026 12:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci:normal Run our default set of CI jobs (choose this for most PRs). core maintenance User-facing maintenance tasks

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants