Skip to content

Commit cbf8e4f

Browse files
authored
Merge pull request storybookjs#35410 from storybookjs/norbert/fix-channel-runtime-install
Channel: Ensure every runtime installs the real channel and stop the mock fallback from poisoning it
2 parents d21d1cd + 53cf5de commit cbf8e4f

6 files changed

Lines changed: 95 additions & 37 deletions

File tree

code/.storybook/ensure-channel.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { Channel, getChannel, setChannel } from 'storybook/internal/channels';
2+
3+
// The Storybook Vitest runtime (browser mode) has no builder preamble to install an addons channel,
4+
// yet preview side-effect modules (open services) call `registerService()` at import time. Install a
5+
// channel here so those registrations have one to bind to.
6+
//
7+
// This lives in its own module and is imported before `./preview.tsx` in the setup file: ES modules
8+
// evaluate their imports in source order, so an inline statement would run *after* the preview import
9+
// (and its service registrations), too late to help.
10+
if (!getChannel()) {
11+
setChannel(new Channel({}));
12+
}

code/.storybook/storybook.setup.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import { userEvent as storybookEvent, expect as storybookExpect } from 'storyboo
66

77
import '../core/src/shared/utils/toHaveLiveRegion.ts';
88

9+
// Must be imported before `./preview.tsx` so a channel exists when preview open-services register.
10+
import './ensure-channel.ts';
11+
912
import preview from './preview.tsx';
1013

1114
vi.spyOn(console, 'warn').mockImplementation((...args) => console.log(...args));
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// @vitest-environment happy-dom
2+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
3+
4+
import { Channel } from 'storybook/internal/channels';
5+
6+
import { clearChannel, getChannel as readInstalledChannel } from '../../channels/channel-slot.ts';
7+
import { AddonStore } from './addons.ts';
8+
9+
describe('AddonStore channel', () => {
10+
beforeEach(() => {
11+
clearChannel();
12+
});
13+
14+
afterEach(() => {
15+
clearChannel();
16+
});
17+
18+
it('returns a throwaway mock before a channel is installed, without poisoning the shared slot', () => {
19+
const store = new AddonStore();
20+
21+
// Reading early must not crash...
22+
expect(store.getChannel()).toBeDefined();
23+
24+
// ...but it must not install anything into the shared slot, so the real channel installed later
25+
// at the runtime entry point can still take over.
26+
expect(readInstalledChannel()).toBeNull();
27+
expect(store.hasChannel()).toBe(false);
28+
});
29+
30+
it('lets a real setChannel() take over after an early getChannel() fallback', async () => {
31+
const store = new AddonStore();
32+
33+
// Simulate an errant early read (e.g. at manager module-eval before the channel is installed).
34+
store.getChannel();
35+
36+
const real = new Channel({});
37+
store.setChannel(real);
38+
39+
expect(store.getChannel()).toBe(real);
40+
expect(readInstalledChannel()).toBe(real);
41+
await expect(store.ready()).resolves.toBe(real);
42+
});
43+
});

code/core/src/manager-api/lib/addons.ts

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -50,18 +50,22 @@ export class AddonStore {
5050
private resolve: any;
5151

5252
getChannel = (): Channel => {
53-
if (!this.channel) {
54-
const installed = readInstalledChannel();
55-
if (installed) {
56-
this.channel = installed as Channel;
57-
this.resolve();
58-
return this.channel;
59-
}
60-
61-
this.setChannel(mockChannel() as unknown as Channel);
53+
if (this.channel) {
54+
return this.channel;
6255
}
6356

64-
return this.channel!;
57+
const installed = readInstalledChannel();
58+
if (installed) {
59+
this.channel = installed as Channel;
60+
this.resolve();
61+
return this.channel;
62+
}
63+
64+
// No channel has been installed in this runtime yet. Return a throwaway mock so callers do not
65+
// crash, but do NOT cache it, mirror it to the shared channel slot, or resolve `ready()` with
66+
// it. Otherwise a single early read would poison the whole runtime, and the real channel
67+
// installed later (at the runtime entry point) would never take over.
68+
return mockChannel() as unknown as Channel;
6569
};
6670

6771
ready = (): Promise<Channel> => this.promise;

code/core/src/manager/runtime.tsx

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -29,26 +29,20 @@ addons.register(TOOLBAR_ID, () =>
2929
})
3030
);
3131

32+
// Install the manager channel at module load, before the deferred render below and before any
33+
// manager entry can read it. This guarantees `addons.getChannel()` returns the real channel in the
34+
// manager runtime instead of falling back to a throwaway mock.
35+
const channel = createBrowserChannel({ page: 'manager' });
36+
addons.setChannel(channel);
37+
channel.emit(CHANNEL_CREATED);
38+
3239
class ReactProvider extends Provider {
33-
addons: AddonStore;
40+
addons: AddonStore = addons;
3441

35-
channel: Channel;
42+
channel: Channel = channel;
3643

3744
wsDisconnected = false;
3845

39-
constructor() {
40-
super();
41-
42-
const channel = createBrowserChannel({ page: 'manager' });
43-
44-
addons.setChannel(channel);
45-
46-
channel.emit(CHANNEL_CREATED);
47-
48-
this.addons = addons;
49-
this.channel = channel;
50-
}
51-
5246
getElements(type: Addon_Types) {
5347
return this.addons.getElements(type);
5448
}

code/core/src/preview-api/modules/addons/main.ts

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,20 +19,22 @@ export class AddonStore {
1919
private resolve: any;
2020

2121
getChannel = (): Channel => {
22-
if (!this.channel) {
23-
const installed = readInstalledChannel();
24-
if (installed) {
25-
this.channel = installed as Channel;
26-
this.resolve();
27-
return this.channel;
28-
}
29-
30-
const channel = mockChannel() as unknown as Channel;
31-
this.setChannel(channel);
32-
return channel;
22+
if (this.channel) {
23+
return this.channel;
3324
}
3425

35-
return this.channel;
26+
const installed = readInstalledChannel();
27+
if (installed) {
28+
this.channel = installed as Channel;
29+
this.resolve();
30+
return this.channel;
31+
}
32+
33+
// No channel has been installed in this runtime yet. Return a throwaway mock so callers do not
34+
// crash, but do NOT cache it, mirror it to the shared channel slot, or resolve `ready()` with
35+
// it. Otherwise a single early read would poison the whole runtime, and the real channel
36+
// installed later (at the runtime entry point) would never take over.
37+
return mockChannel() as unknown as Channel;
3638
};
3739

3840
ready = (): Promise<Channel> => this.promise;

0 commit comments

Comments
 (0)