Skip to content

Channel: Ensure every runtime installs the real channel and stop the mock fallback from poisoning it - #35410

Merged
ndelangen merged 3 commits into
nextfrom
norbert/fix-channel-runtime-install
Jul 8, 2026
Merged

ndelangen merged 3 commits into
nextfrom
norbert/fix-channel-runtime-install

Conversation

@ndelangen

@ndelangen ndelangen commented Jul 8, 2026 •

Copy link
Copy Markdown
Member

Closes #

Stacked on top of #35408 (norbert/fix-root-initial-setstate) — please review/merge that one first.

What I did

The symptom

addons.getChannel() started returning a dummy (mock) channel instead of the real one. Any addon that grabbed the channel — directly, via getService(...), or by awaiting addons.ready() — silently ended up talking to a dead channel, so its events never reached the preview. It was reported as a regression between 10.5.0-alpha.5 and alpha.6.

Why it happened

getChannel() has always had a safety net: if no channel is installed yet, it hands back a throwaway mockChannel() so callers don't crash. Historically that mock lived only on the local addon store and was quietly replaced the moment the real channel arrived — no harm done.

The alpha.5 → alpha.6 channel-management refactor introduced a single shared channel slot (globalThis.__STORYBOOK_ADDONS_CHANNEL__) as the source of truth. As a side effect, the safety net changed: the throwaway mock now gets

  • written into the shared slot, and
  • used to resolve the ready() promise.

Both are permanent. So a single early getChannel() call — one that happens before the runtime finishes installing its real channel — now poisons the entire runtime: the real channel that arrives later is ignored, and every ready() consumer stays bound to the mock forever.

The manager made this easy to trigger because it created and installed its channel inside a deferred setTimeout(…, 0), leaving a window during startup where a read could fall into the safety net. (The preview installs its channel in the builder preamble, and Node/server installs a no-op channel on import, so those runtimes were far less exposed.)

The fix

Two complementary changes so that calling getChannel() in any runtime always returns the right channel:

  1. Install the manager's real channel eagerly — at module load in manager/runtime.tsx, before the deferred render — so there is no startup window left to fall into.
  2. Make the safety net harmless — in both the manager-api and preview-api addon stores, when no channel exists yet, still return a throwaway mock, but don't cache it, don't write it into the shared slot, and don't resolve ready() with it. The real channel installed at the runtime's entry point stays authoritative.

Alternative considered

Doing only change 1 (close the manager timing window) would fix the reported case, but it leaves the sticky-mock foot-gun in place for any other early caller. Doing both removes the window and neutralizes the safety net, which matches the intended contract: "call getChannel(), always get the right thing".

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

Added code/core/src/manager-api/lib/addons.test.ts, which asserts the contract directly: an early getChannel() returns a mock without touching the shared slot or flipping hasChannel(), and a later real setChannel() takes over and resolves ready() with the real channel.

Manual testing

  1. Run a sandbox: yarn task sandbox --template react-vite/default-ts --start-from auto
  2. Open Storybook in the browser.
  3. Confirm addons that rely on the channel work on first load (not just after navigating) — e.g. storybook-addon-tag-badges, and any experimental_setFilter-based filtering applied to the initial story index.
  4. From an addon register callback (or a manager entry), call addons.getChannel() and verify events actually reach the preview, i.e. it is the real browser channel rather than a dummy.

Documentation

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

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

  • Declare whether manual QA will be needed for this PR during the next release, through qa:needed or qa:skip

  • 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>

Summary by CodeRabbit

  • Bug Fixes
    • Improved Storybook startup reliability so the preview and manager communicate correctly during initial load.
    • Fixed an issue where an early placeholder connection could interfere with the real Storybook connection later on.
    • Reduced the chance of addons or preview features failing to initialize when the app loads in a deferred order.

…llback from poisoning it

The shared channel slot introduced in the channel-management refactor made
`addons.getChannel()`'s mock fallback global: a single early read would mirror a
throwaway mock into `__STORYBOOK_ADDONS_CHANNEL__` and permanently resolve
`ready()` to it, so the real channel installed later never took over.

- Install the manager channel at module load (before the deferred render) so
  `getChannel()` returns the real channel instead of a fallback.
- Make the mock fallback in both the manager-api and preview-api AddonStores
  non-poisoning: return a throwaway mock without caching it, mirroring it to the
  shared slot, or resolving `ready()`.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ndelangen ndelangen added bug ci:normal Run our default set of CI jobs (choose this for most PRs). qa:skip Pull Requests that do not need any QA. (e.g. documentation) labels Jul 8, 2026
@ndelangen ndelangen self-assigned this Jul 8, 2026
@Sidnioulz Sidnioulz added the upgrade:10.5 Issues/PRs found during 10.5 upgrade QA and post-release regressions label Jul 8, 2026

@Sidnioulz Sidnioulz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This perfectly matches the symptoms I was seeing. LGTM!

ndelangen and others added 2 commits July 8, 2026 12:05
The Storybook Vitest runtime (browser mode) has no builder preamble to install a
channel, and preview open-services register at import time. This previously
worked only by accident: the mock-fallback in getChannel() installed one. Now
that the fallback no longer poisons the shared slot, install a channel explicitly
at the setup entry point, before preview.tsx evaluates.

Co-authored-by: Cursor <cursoragent@cursor.com>
Align with AddonStore.setChannel's parameter type (Channel from
storybook/internal/channels) to fix a TS2345 nominal mismatch against the
source-module Channel.

Co-authored-by: Cursor <cursoragent@cursor.com>
Base automatically changed from norbert/fix-root-initial-setstate to next July 8, 2026 14:24
@ndelangen
ndelangen marked this pull request as ready for review July 8, 2026 15:04
Copilot AI review requested due to automatic review settings July 8, 2026 15:04
@ndelangen
ndelangen merged commit cbf8e4f into next Jul 8, 2026
154 checks passed
@ndelangen
ndelangen deleted the norbert/fix-channel-runtime-install branch July 8, 2026 15:04
@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 97014b02-84a4-41d7-96e2-75a4777ef660

📥 Commits

Reviewing files that changed from the base of the PR and between e486789 and 53cf5de.

📒 Files selected for processing (6)
  • code/.storybook/ensure-channel.ts
  • code/.storybook/storybook.setup.ts
  • code/core/src/manager-api/lib/addons.test.ts
  • code/core/src/manager-api/lib/addons.ts
  • code/core/src/manager/runtime.tsx
  • code/core/src/preview-api/modules/addons/main.ts

📝 Walkthrough

Walkthrough

AddonStore.getChannel implementations in manager-api and preview-api were changed to avoid caching a mock channel when no real channel is installed yet, preventing it from blocking later real channel installation. Manager runtime channel creation moved to module load time, and a new Storybook ensure-channel setup module was added with tests.

Changes

AddonStore channel fallback fix

Layer / File(s) Summary
AddonStore getChannel fallback rewrite
code/core/src/manager-api/lib/addons.ts, code/core/src/preview-api/modules/addons/main.ts, code/core/src/manager-api/lib/addons.test.ts
getChannel() now returns an uncached throwaway mock channel when no installed channel exists, instead of caching and installing it via setChannel; new tests verify the shared channel slot stays uninitialized until a real channel is set.
Manager runtime channel timing and Storybook setup ordering
code/core/src/manager/runtime.tsx, code/.storybook/ensure-channel.ts, code/.storybook/storybook.setup.ts
Browser channel creation and CHANNEL_CREATED emission move from ReactProvider's constructor to module top-level code using field initializers; a new ensure-channel.ts module is imported before preview.tsx to guarantee a channel exists early in Storybook setup.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant AddonStore
  participant SharedChannelSlot

  Caller->>AddonStore: getChannel() (early call)
  AddonStore->>SharedChannelSlot: readInstalledChannel()
  SharedChannelSlot-->>AddonStore: null
  AddonStore-->>Caller: mockChannel() (uncached, ready not resolved)
  Caller->>AddonStore: setChannel(realChannel)
  AddonStore->>SharedChannelSlot: install realChannel
  Caller->>AddonStore: getChannel() (later call)
  AddonStore-->>Caller: realChannel (cached, ready resolved)
Loading
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

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

@github-actions github-actions Bot mentioned this pull request Jul 8, 2026
2 tasks done

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Fixes a regression where an early addons.getChannel() call could permanently install a mock channel into the shared global slot, causing addons to talk to a dead channel and addons.ready() to resolve incorrectly.

Changes:

  • Updated manager-api and preview-api addon stores so the “no channel yet” fallback returns a throwaway mock without caching/mirroring/resolving ready() with it.
  • Made the manager runtime install its channel earlier (module load) to reduce the chance of any early reads seeing a missing channel.
  • Added unit coverage for the “early getChannel fallback must not poison the shared slot” contract, plus a Storybook Vitest setup hook to ensure a channel exists in that environment.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
code/core/src/preview-api/modules/addons/main.ts Stops getChannel() from caching/mirroring the mock fallback into the shared channel slot.
code/core/src/manager/runtime.tsx Installs the manager browser channel at module load (before deferred render).
code/core/src/manager-api/lib/addons.ts Aligns manager-api addon store behavior with the new non-poisoning fallback contract.
code/core/src/manager-api/lib/addons.test.ts Adds tests asserting early getChannel() doesn’t poison the shared slot and setChannel() takes over correctly.
code/.storybook/storybook.setup.ts Ensures a channel exists before preview side-effect modules run in Storybook Vitest runtime.
code/.storybook/ensure-channel.ts Installs a noop channel for Storybook Vitest browser-mode runs that lack a builder preamble.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +32 to +37
// Install the manager channel at module load, before the deferred render below and before any
// manager entry can read it. This guarantees `addons.getChannel()` returns the real channel in the
// manager runtime instead of falling back to a throwaway mock.
const channel = createBrowserChannel({ page: 'manager' });
addons.setChannel(channel);
channel.emit(CHANNEL_CREATED);
Comment on lines +1 to +12
import { Channel, getChannel, setChannel } from 'storybook/internal/channels';

// The Storybook Vitest runtime (browser mode) has no builder preamble to install an addons channel,
// yet preview side-effect modules (open services) call `registerService()` at import time. Install a
// channel here so those registrations have one to bind to.
//
// This lives in its own module and is imported before `./preview.tsx` in the setup file: ES modules
// evaluate their imports in source order, so an inline statement would run *after* the preview import
// (and its service registrations), too late to help.
if (!getChannel()) {
setChannel(new Channel({}));
}
This was referenced Jul 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug ci:normal Run our default set of CI jobs (choose this for most PRs). qa:skip Pull Requests that do not need any QA. (e.g. documentation) upgrade:10.5 Issues/PRs found during 10.5 upgrade QA and post-release regressions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants