@storybook-astro/framework 1.6.0 → 1.7.0-canary.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,155 @@
1
+ import { isAstroComponentFactory, isAstroComponentMarker } from '@storybook-astro/renderer/types';
2
+
3
+ /**
4
+ * Resolves Astro component references that a story passed as props or slot
5
+ * content back into something the Astro Container can render.
6
+ *
7
+ * A component reference arrives either as a serialized marker (the browser /
8
+ * dev / server path, where it crossed a JSON boundary) or as a real component
9
+ * factory (the Vitest / portable-stories path, which imports `.astro` files
10
+ * directly). Both are handled.
11
+ *
12
+ * - **Props**: resolved to the real component factory and passed through, so the
13
+ * parent template renders them with `<Comp />` (the Container supports this).
14
+ * - **Slots**: rendered to an HTML string, because the Container only accepts
15
+ * string slot content — a factory passed as a slot is stringified verbatim.
16
+ *
17
+ * Both callbacks are injected so this is reusable across the dev/server handler
18
+ * (which loads by moduleId and renders via its container) and the testing path
19
+ * (where factories are already in hand).
20
+ */
21
+ type LoadComponent = (moduleId: string) => Promise<unknown>;
22
+ type RenderToHtml = (component: unknown) => Promise<string>;
23
+
24
+ // Guards against pathological/cyclic arg objects. Component references are leaf
25
+ // replacements, so real nesting is shallow; this is just insurance.
26
+ const MAX_DEPTH = 10;
27
+
28
+ /**
29
+ * Resolves component references in **props** back to real component factories,
30
+ * so the parent template renders them with `<Comp />`. Run this before the
31
+ * normal arg processing (image/date/sanitize) — factories pass through those
32
+ * untouched, and a resolved prop must exist before the parent renders.
33
+ */
34
+ export async function reconstructProps(
35
+ args: Record<string, unknown>,
36
+ callbacks: { loadComponent: LoadComponent }
37
+ ): Promise<Record<string, unknown>> {
38
+ return (await resolvePropValue(args, callbacks, 0)) as Record<string, unknown>;
39
+ }
40
+
41
+ /**
42
+ * Resolves component references in **slots** to HTML strings (the Container only
43
+ * accepts string slots). Run this *after* sanitization so a component's rendered
44
+ * markup — trusted, like a prop-rendered component — isn't stripped by the slot
45
+ * HTML allowlist, while plain-string slots still are.
46
+ */
47
+ export async function reconstructSlots(
48
+ slots: Record<string, unknown>,
49
+ callbacks: { loadComponent: LoadComponent; renderToHtml: RenderToHtml }
50
+ ): Promise<Record<string, unknown>> {
51
+ const out: Record<string, unknown> = {};
52
+
53
+ for (const [name, value] of Object.entries(slots)) {
54
+ out[name] = await resolveSlotValue(name, value, callbacks);
55
+ }
56
+
57
+ return out;
58
+ }
59
+
60
+ /**
61
+ * Walks a prop value, replacing component references with real factories.
62
+ * Returns the original reference untouched when nothing changed, so unrelated
63
+ * args keep their identity (e.g. `ImageMetadata` objects the image pipeline
64
+ * later inspects).
65
+ */
66
+ async function resolvePropValue(
67
+ value: unknown,
68
+ callbacks: { loadComponent: LoadComponent },
69
+ depth: number
70
+ ): Promise<unknown> {
71
+ if (isAstroComponentMarker(value)) {
72
+ return callbacks.loadComponent(value.moduleId);
73
+ }
74
+
75
+ // Already a real factory (testing path) — pass it straight through as a prop.
76
+ if (isAstroComponentFactory(value)) {
77
+ return value;
78
+ }
79
+
80
+ if (depth >= MAX_DEPTH) {
81
+ return value;
82
+ }
83
+
84
+ if (Array.isArray(value)) {
85
+ const resolved = await Promise.all(
86
+ value.map((item) => resolvePropValue(item, callbacks, depth + 1))
87
+ );
88
+
89
+ return resolved.some((item, index) => item !== value[index]) ? resolved : value;
90
+ }
91
+
92
+ if (isPlainObject(value)) {
93
+ let changed = false;
94
+ const out: Record<string, unknown> = {};
95
+
96
+ for (const [key, nested] of Object.entries(value)) {
97
+ const resolved = await resolvePropValue(nested, callbacks, depth + 1);
98
+
99
+ if (resolved !== nested) {
100
+ changed = true;
101
+ }
102
+
103
+ out[key] = resolved;
104
+ }
105
+
106
+ return changed ? out : value;
107
+ }
108
+
109
+ return value;
110
+ }
111
+
112
+ async function resolveSlotValue(
113
+ name: string,
114
+ value: unknown,
115
+ callbacks: { loadComponent: LoadComponent; renderToHtml: RenderToHtml }
116
+ ): Promise<unknown> {
117
+ // An array slot (list of components and/or strings) is concatenated into one
118
+ // HTML string, which is what the Container expects for a single slot.
119
+ if (Array.isArray(value)) {
120
+ const parts = await Promise.all(value.map((item) => resolveSlotValue(name, item, callbacks)));
121
+
122
+ return parts.join('');
123
+ }
124
+
125
+ if (isAstroComponentMarker(value)) {
126
+ const component = await callbacks.loadComponent(value.moduleId);
127
+
128
+ return renderSlotComponent(name, component, callbacks);
129
+ }
130
+
131
+ if (isAstroComponentFactory(value)) {
132
+ return renderSlotComponent(name, value, callbacks);
133
+ }
134
+
135
+ // Plain HTML string (or anything else) passes through unchanged.
136
+ return value;
137
+ }
138
+
139
+ async function renderSlotComponent(
140
+ name: string,
141
+ component: unknown,
142
+ callbacks: { renderToHtml: RenderToHtml }
143
+ ): Promise<string> {
144
+ try {
145
+ return await callbacks.renderToHtml(component);
146
+ } catch (error) {
147
+ const message = error instanceof Error ? error.message : String(error);
148
+
149
+ throw new Error(`Failed to render Astro component passed to slot "${name}": ${message}`);
150
+ }
151
+ }
152
+
153
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
154
+ return typeof value === 'object' && value !== null;
155
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Splits a story's `args` into component props and slot content. Slot content
3
+ * lives under the reserved `slots` key (`args.slots.<name>`); everything else is
4
+ * a prop. Returns `{}` for slots when `args.slots` is absent or not an object.
5
+ */
6
+ export function separateStorySlots(storyArgs: Record<string, unknown>): {
7
+ componentArgs: Record<string, unknown>;
8
+ storySlots: Record<string, unknown>;
9
+ } {
10
+ const componentArgs = { ...storyArgs };
11
+ const storySlots = componentArgs.slots;
12
+
13
+ delete componentArgs.slots;
14
+
15
+ if (typeof storySlots !== 'object' || storySlots === null) {
16
+ return { componentArgs, storySlots: {} };
17
+ }
18
+
19
+ return { componentArgs, storySlots: storySlots as Record<string, unknown> };
20
+ }
package/src/preset.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { dirname } from 'node:path';
2
+ import { version as viteVersion } from 'vite';
2
3
  import type { StorybookConfigVite, FrameworkOptions } from './types.ts';
3
4
  import { vitePluginStorybookAstroMiddleware } from './viteStorybookAstroMiddlewarePlugin.ts';
4
5
  import { viteStorybookRendererFallbackPlugin } from './viteStorybookRendererFallbackPlugin.ts';
@@ -205,8 +206,8 @@ export const viteFinal: StorybookConfigVite['viteFinal'] = async (config, storyb
205
206
  }
206
207
  // Mark integration virtual modules as external so the dep bundler doesn't
207
208
  // try to resolve them (they are Vite virtual modules with no real package).
208
- // Set both esbuildOptions (Vite ≤7) and rolldownOptions (Vite 8+, Rolldown)
209
- // so the correct key is populated regardless of Vite version.
209
+ // Vite ≤7 reads these from esbuildOptions; Vite 8+ uses Rolldown and reads
210
+ // them from rolldownOptions. We populate whichever key the running Vite uses.
210
211
  const integrationVirtualModules = [
211
212
  'virtual:@astrojs/vue/app',
212
213
  'virtual:astro:vue-app',
@@ -215,16 +216,21 @@ export const viteFinal: StorybookConfigVite['viteFinal'] = async (config, storyb
215
216
  'astro:toolbar:internal'
216
217
  ];
217
218
 
218
- // Vite ≤7 (esbuild-based optimizer)
219
- if (!finalConfig.optimizeDeps.esbuildOptions) {
220
- finalConfig.optimizeDeps.esbuildOptions = {};
221
- }
222
- if (!finalConfig.optimizeDeps.esbuildOptions.external) {
223
- finalConfig.optimizeDeps.esbuildOptions.external = [];
224
- }
225
- for (const mod of integrationVirtualModules) {
226
- if (!finalConfig.optimizeDeps.esbuildOptions.external.includes(mod)) {
227
- finalConfig.optimizeDeps.esbuildOptions.external.push(mod);
219
+ const viteMajor = Number.parseInt(viteVersion, 10);
220
+
221
+ // Vite ≤7 (esbuild-based optimizer). On Vite 8+ setting esbuildOptions logs a
222
+ // deprecation warning, so only touch it on older Vite.
223
+ if (viteMajor < 8) {
224
+ if (!finalConfig.optimizeDeps.esbuildOptions) {
225
+ finalConfig.optimizeDeps.esbuildOptions = {};
226
+ }
227
+ if (!finalConfig.optimizeDeps.esbuildOptions.external) {
228
+ finalConfig.optimizeDeps.esbuildOptions.external = [];
229
+ }
230
+ for (const mod of integrationVirtualModules) {
231
+ if (!finalConfig.optimizeDeps.esbuildOptions.external.includes(mod)) {
232
+ finalConfig.optimizeDeps.esbuildOptions.external.push(mod);
233
+ }
228
234
  }
229
235
  }
230
236
 
@@ -237,6 +243,48 @@ export const viteFinal: StorybookConfigVite['viteFinal'] = async (config, storyb
237
243
  );
238
244
  optimizeDepsMut.rolldownOptions = rolldownOpts;
239
245
 
246
+ // Vite 8 dev-server compatibility (Astro 7+). Vite ≤7 (Astro 5/6) is unaffected.
247
+ if (configType === 'DEVELOPMENT' && viteMajor >= 8) {
248
+ // 1. Drop @vitejs/plugin-react's Vite 8 native Fast Refresh wrapper. Under
249
+ // Vite 8 the plugin delegates Fast Refresh to a Rolldown builtin
250
+ // (`builtin:vite-react-refresh-wrapper`) that throws
251
+ // "Missing field `moduleType`" while transforming Storybook's iframe.html
252
+ // inline bootstrap script — 500-ing every preview load. There is no
253
+ // config opt-out, so we remove the plugin. React components still render;
254
+ // only Fast Refresh is lost (component edits full-reload instead).
255
+ const stripReactRefreshWrapper = (plugins: unknown[]): unknown[] =>
256
+ plugins
257
+ .map((plugin) => (Array.isArray(plugin) ? stripReactRefreshWrapper(plugin) : plugin))
258
+ .filter(
259
+ (plugin) =>
260
+ !(
261
+ plugin &&
262
+ typeof plugin === 'object' &&
263
+ (plugin as { name?: string }).name === 'vite:react:refresh-wrapper'
264
+ )
265
+ );
266
+
267
+ finalConfig.plugins = stripReactRefreshWrapper(
268
+ finalConfig.plugins ?? []
269
+ ) as typeof finalConfig.plugins;
270
+
271
+ // 2. Exclude the Storybook renderer entry-previews from dependency
272
+ // optimization. Some ship non-JS source (e.g. `@storybook/svelte`'s
273
+ // `.svelte` files) that the esbuild dep scanner cannot load
274
+ // ("No loader is configured for .svelte"), which fails optimization and
275
+ // 504s every renderer entry. Serving them as source lets the framework's
276
+ // own Vite plugins transform them.
277
+ const entryPreviews = integrations
278
+ .map((integration) => integration.storybookEntryPreview)
279
+ .filter((specifier): specifier is string => Boolean(specifier));
280
+
281
+ for (const specifier of entryPreviews) {
282
+ if (!finalConfig.optimizeDeps.exclude.includes(specifier)) {
283
+ finalConfig.optimizeDeps.exclude.push(specifier);
284
+ }
285
+ }
286
+ }
287
+
240
288
  return finalConfig;
241
289
  };
242
290
 
@@ -0,0 +1,97 @@
1
+ import { describe, expect, test, vi } from 'vitest';
2
+ import {
3
+ renderProductionStoryToHtml,
4
+ type ProductionRenderRuntime,
5
+ type ProductionStoryEntry
6
+ } from './productionRenderRuntime.ts';
7
+
8
+ const resolveFrom = '/project';
9
+
10
+ const story: ProductionStoryEntry = {
11
+ id: 'components-button--primary',
12
+ importPath: './src/Button.stories.ts',
13
+ componentPath: './src/Button.astro',
14
+ exportName: 'Primary',
15
+ title: 'Components/Button',
16
+ name: 'Primary'
17
+ };
18
+
19
+ /** A stand-in Astro component factory; only its identity and `function`-ness matter here. */
20
+ function ButtonAstro() {
21
+ return '';
22
+ }
23
+
24
+ function createRuntime(storyModule: Record<string, unknown>): {
25
+ runtime: ProductionRenderRuntime;
26
+ renderAstroStory: ReturnType<typeof vi.fn>;
27
+ } {
28
+ const renderAstroStory = vi.fn(async () => '<button>rendered</button>');
29
+
30
+ return {
31
+ runtime: {
32
+ loadModule: async () => storyModule,
33
+ renderAstroStory,
34
+ close: async () => {}
35
+ },
36
+ renderAstroStory
37
+ };
38
+ }
39
+
40
+ describe('renderProductionStoryToHtml', () => {
41
+ test('CSF3: reads component from the default export and merges meta + story args', async () => {
42
+ const { runtime, renderAstroStory } = createRuntime({
43
+ default: { component: ButtonAstro, args: { label: 'Meta', size: 'lg' } },
44
+ Primary: { args: { label: 'Primary' } }
45
+ });
46
+
47
+ const html = await renderProductionStoryToHtml({ story, runtime, resolveFrom });
48
+
49
+ expect(html).toBe('<button>rendered</button>');
50
+ expect(renderAstroStory).toHaveBeenCalledWith(
51
+ expect.objectContaining({
52
+ component: '/project/src/Button.astro',
53
+ args: { label: 'Primary', size: 'lg' }
54
+ })
55
+ );
56
+ });
57
+
58
+ test('CSF4 factory: reads component and args from the story export meta with no default export', async () => {
59
+ const { runtime, renderAstroStory } = createRuntime({
60
+ // Shape produced by `meta.story()` in Storybook's CSF factories.
61
+ Primary: {
62
+ _tag: 'Story',
63
+ input: { args: { label: 'Primary' } },
64
+ meta: {
65
+ _tag: 'Meta',
66
+ input: { component: ButtonAstro, args: { label: 'Meta', size: 'lg' } }
67
+ }
68
+ }
69
+ });
70
+
71
+ const html = await renderProductionStoryToHtml({ story, runtime, resolveFrom });
72
+
73
+ expect(html).toBe('<button>rendered</button>');
74
+ expect(renderAstroStory).toHaveBeenCalledWith(
75
+ expect.objectContaining({
76
+ component: '/project/src/Button.astro',
77
+ args: { label: 'Primary', size: 'lg' }
78
+ })
79
+ );
80
+ });
81
+
82
+ test('skips stories that override the component with a non-Astro render', async () => {
83
+ function OtherComponent() {
84
+ return '';
85
+ }
86
+
87
+ const { runtime, renderAstroStory } = createRuntime({
88
+ default: { component: ButtonAstro },
89
+ Primary: { component: OtherComponent }
90
+ });
91
+
92
+ const html = await renderProductionStoryToHtml({ story, runtime, resolveFrom });
93
+
94
+ expect(html).toBeUndefined();
95
+ expect(renderAstroStory).not.toHaveBeenCalled();
96
+ });
97
+ });
@@ -1,5 +1,6 @@
1
1
  import { resolve } from 'node:path';
2
2
  import { createAstroRenderHandler, type HandlerProps } from './astroRenderHandler.ts';
3
+ import { separateStorySlots } from './lib/separate-story-slots.ts';
3
4
  import type { Integration } from './integrations/index.ts';
4
5
  import type { SanitizationOptions } from './lib/sanitization.ts';
5
6
  import type { FrameworkOptions } from './types.ts';
@@ -107,25 +108,23 @@ export async function renderProductionStoryToHtml(options: {
107
108
  const storyModulePath = resolveProjectImportPath(options.story.importPath, options.resolveFrom);
108
109
  const componentPath = resolveProjectImportPath(options.story.componentPath, options.resolveFrom);
109
110
  const storyModule = await options.runtime.loadModule(storyModulePath);
110
- const defaultStoryMeta = isRecord(storyModule.default) ? storyModule.default : {};
111
- const rawStoryExport: unknown = storyModule[options.story.exportName];
112
- const selectedStoryExport: Record<string, unknown> = isRecord(rawStoryExport) ? rawStoryExport : {};
111
+ const { metaComponent, metaArgs, storyComponent, storyLevelArgs } = resolveStoryAnnotations(
112
+ storyModule,
113
+ options.story.exportName
114
+ );
113
115
 
114
- if (typeof defaultStoryMeta.component !== 'function') {
116
+ if (typeof metaComponent !== 'function') {
115
117
  throw new Error(
116
- `Unable to prerender story "${options.story.id}". Missing default export component in ${options.story.importPath}.`
118
+ `Unable to prerender story "${options.story.id}". Missing component in ${options.story.importPath}.`
117
119
  );
118
120
  }
119
121
 
120
122
  // Build-time prerender only supports stories that keep the meta-level Astro component.
121
- if (selectedStoryExport.component && selectedStoryExport.component !== defaultStoryMeta.component) {
123
+ if (storyComponent && storyComponent !== metaComponent) {
122
124
  return undefined;
123
125
  }
124
126
 
125
- const storyArgs = mergeMetaArgsWithStoryArgs(
126
- toRecord(defaultStoryMeta.args),
127
- toRecord(selectedStoryExport.args)
128
- );
127
+ const storyArgs = mergeMetaArgsWithStoryArgs(metaArgs, storyLevelArgs);
129
128
  const { componentArgs, storySlots } = separateStorySlots(storyArgs);
130
129
 
131
130
  return options.runtime.renderAstroStory({
@@ -140,6 +139,43 @@ export async function renderProductionStoryToHtml(options: {
140
139
  });
141
140
  }
142
141
 
142
+ /**
143
+ * Reads the meta- and story-level component and args for one story export,
144
+ * supporting both authoring styles:
145
+ *
146
+ * - CSF3: the meta is the module's default export and the named export holds the
147
+ * story's own args/component.
148
+ * - CSF4 factories: there is no default export. The named export is a
149
+ * `{ _tag: 'Story', input, meta }` object produced by `meta.story()`, where the
150
+ * meta-level annotations live on `meta.input`.
151
+ */
152
+ function resolveStoryAnnotations(storyModule: LoadedStoryModule, exportName: string) {
153
+ const rawStoryExport: unknown = storyModule[exportName];
154
+
155
+ if (isRecord(rawStoryExport) && rawStoryExport._tag === 'Story') {
156
+ const storyInput = toRecord(rawStoryExport.input) ?? {};
157
+ const meta = isRecord(rawStoryExport.meta) ? rawStoryExport.meta : {};
158
+ const metaInput = toRecord(meta.input) ?? {};
159
+
160
+ return {
161
+ metaComponent: metaInput.component,
162
+ metaArgs: toRecord(metaInput.args),
163
+ storyComponent: storyInput.component,
164
+ storyLevelArgs: toRecord(storyInput.args)
165
+ };
166
+ }
167
+
168
+ const defaultStoryMeta = isRecord(storyModule.default) ? storyModule.default : {};
169
+ const selectedStoryExport = isRecord(rawStoryExport) ? rawStoryExport : {};
170
+
171
+ return {
172
+ metaComponent: defaultStoryMeta.component,
173
+ metaArgs: toRecord(defaultStoryMeta.args),
174
+ storyComponent: selectedStoryExport.component,
175
+ storyLevelArgs: toRecord(selectedStoryExport.args)
176
+ };
177
+ }
178
+
143
179
  function resolveProjectImportPath(importPath: string, resolveFrom: string) {
144
180
  if (importPath.startsWith('./') || importPath.startsWith('../')) {
145
181
  return resolve(resolveFrom, importPath);
@@ -158,25 +194,6 @@ function mergeMetaArgsWithStoryArgs(
158
194
  };
159
195
  }
160
196
 
161
- function separateStorySlots(storyArgs: Record<string, unknown>) {
162
- const componentArgs = { ...storyArgs };
163
- const storySlots = componentArgs.slots;
164
-
165
- delete componentArgs.slots;
166
-
167
- if (!isRecord(storySlots)) {
168
- return {
169
- componentArgs,
170
- storySlots: {}
171
- };
172
- }
173
-
174
- return {
175
- componentArgs,
176
- storySlots: storySlots as Record<string, string>
177
- };
178
- }
179
-
180
197
  function isRecord(value: unknown): value is Record<string, unknown> {
181
198
  return typeof value === 'object' && value !== null;
182
199
  }
@@ -253,17 +253,34 @@ function isRecord(value: unknown): value is Record<string, unknown> {
253
253
  return typeof value === 'object' && value !== null;
254
254
  }
255
255
 
256
- // Stubs Storybook's browser-only packages so story files that import docs
257
- // blocks (e.g. `import { Controls } from '@storybook/blocks'`) don't
258
- // trigger `document is not defined` during SSR prerendering. We only need
259
- // component/args from story modules docs block components are never called.
256
+ // Stubs Storybook's browser-only docs packages so a project's preview config
257
+ // doesn't crash the SSR prerender. Stories import `@storybook/preview`, which
258
+ // loads `.storybook/preview.ts`, which commonly registers the docs addon
259
+ // (`import addonDocs from '@storybook/addon-docs'`). The docs addon pulls in
260
+ // Storybook's UI kit, which reads `document.documentElement` at module load and
261
+ // throws `document is not defined` under Node. The docs UI never runs during
262
+ // prerendering — we only need each story's component and args — so replacing it
263
+ // with a no-op is safe.
264
+ //
265
+ // `@storybook/addon-docs`'s default export is called as a function in preview
266
+ // config (`addonDocs()`), so the stub exports a callable no-op. `blocks` are the
267
+ // docs block components (used only inside MDX, which is not prerendered), and
268
+ // `@storybook/blocks` is the pre-Storybook-10 path for those same blocks.
260
269
  function createStorybookBrowserStubPlugin(): Plugin {
270
+ const STUBBED_SPECIFIERS = new Set([
271
+ '@storybook/addon-docs',
272
+ '@storybook/addon-docs/blocks',
273
+ '@storybook/blocks'
274
+ ]);
261
275
  const STUB_ID = '\0storybook-astro-browser-stub';
262
276
 
263
277
  return {
264
278
  name: 'storybook-astro:storybook-browser-stubs',
279
+ // Must run before Astro's resolvers, which would otherwise resolve these
280
+ // bare specifiers to their real (browser-only) files before we can stub them.
281
+ enforce: 'pre',
265
282
  resolveId(id: string) {
266
- if (id === '@storybook/blocks') {
283
+ if (STUBBED_SPECIFIERS.has(id)) {
267
284
  return STUB_ID;
268
285
  }
269
286
 
@@ -271,7 +288,7 @@ function createStorybookBrowserStubPlugin(): Plugin {
271
288
  },
272
289
  load(id: string) {
273
290
  if (id === STUB_ID) {
274
- return 'export {};';
291
+ return 'export default () => ({});';
275
292
  }
276
293
 
277
294
  return null;
@@ -6,6 +6,10 @@ import { resolveTestingProjectRoot } from './project-root.ts';
6
6
  import { runWithWorkingDirectory } from './working-directory.ts';
7
7
  import { getComponentModuleId, isAstroComponentFactory, isStorybookAstroClientStub } from './component-utils.ts';
8
8
  import { ssrLoadModuleWithFsFallback } from '../lib/ssr-load-module-with-fs-fallback.ts';
9
+ import { separateStorySlots } from '../lib/separate-story-slots.ts';
10
+ import { reconstructProps, reconstructSlots } from '../lib/reconstruct-component-args.ts';
11
+ import { patchCreateAstroCompat, markRawSlots } from '../astroRenderHandler.ts';
12
+ import { serializeAstroComponentMarkers } from '@storybook-astro/renderer/types';
9
13
  import type { ComposedStory } from './types.ts';
10
14
  import { renderViaTestingRendererDaemon } from './renderer-daemon.ts';
11
15
 
@@ -16,7 +20,13 @@ const astroSsrViteServerPromises = new Map<string, Promise<ViteDevServer>>();
16
20
 
17
21
  const astroSsrHandlerPromises = new Map<
18
22
  string,
19
- Promise<(data: { component: string; args?: Record<string, unknown> }) => Promise<string>>
23
+ Promise<
24
+ (data: {
25
+ component: string;
26
+ args?: Record<string, unknown>;
27
+ slots?: Record<string, unknown>;
28
+ }) => Promise<string>
29
+ >
20
30
  >();
21
31
 
22
32
  const testingIntegrationsCache = new Map<string, StorybookAstroIntegration[]>();
@@ -115,6 +125,14 @@ async function resolveAstroComponent(component: unknown, resolveFrom: string) {
115
125
  return resolvedComponent;
116
126
  }
117
127
 
128
+ function setRenderedHtml(html: string) {
129
+ if (typeof document !== 'undefined') {
130
+ document.body.innerHTML = html;
131
+ }
132
+
133
+ return html;
134
+ }
135
+
118
136
  async function renderAstroComponentToDom(
119
137
  component: unknown,
120
138
  args: Record<string, unknown>,
@@ -122,21 +140,26 @@ async function renderAstroComponentToDom(
122
140
  ) {
123
141
  const moduleId = getComponentModuleId(component);
124
142
 
143
+ // Split slot content from props, then serialize any Astro component passed as
144
+ // a prop or slot into a moduleId marker. The handler reconstructs each marker —
145
+ // loading the real server component by moduleId — so a story can nest Astro
146
+ // components without the unrenderable client stub leaking through.
147
+ const { componentArgs, storySlots } = separateStorySlots(args);
148
+ const serializedArgs = serializeAstroComponentMarkers(componentArgs) as Record<string, unknown>;
149
+ const serializedSlots = serializeAstroComponentMarkers(storySlots) as Record<string, unknown>;
150
+
125
151
  if (moduleId) {
126
152
  try {
127
153
  // Fast path: reuse a single shared SSR daemon instead of spinning SSR in each worker.
128
154
  const html = await renderViaTestingRendererDaemon({
129
155
  resolveFrom,
130
156
  component: moduleId,
131
- args
157
+ args: serializedArgs,
158
+ slots: serializedSlots
132
159
  });
133
160
 
134
161
  if (typeof html === 'string') {
135
- if (typeof document !== 'undefined') {
136
- document.body.innerHTML = html;
137
- }
138
-
139
- return html;
162
+ return setRenderedHtml(html);
140
163
  }
141
164
  } catch {
142
165
  // Fall back to in-worker rendering below when daemon render fails.
@@ -146,14 +169,11 @@ async function renderAstroComponentToDom(
146
169
  const handler = await getAstroSsrHandler(resolveFrom);
147
170
  const html = await handler({
148
171
  component: moduleId,
149
- args
172
+ args: serializedArgs,
173
+ slots: serializedSlots
150
174
  });
151
175
 
152
- if (typeof document !== 'undefined') {
153
- document.body.innerHTML = html;
154
- }
155
-
156
- return html;
176
+ return setRenderedHtml(html);
157
177
  } catch {
158
178
  // Fall back to direct Container rendering below
159
179
  }
@@ -161,20 +181,31 @@ async function renderAstroComponentToDom(
161
181
 
162
182
  const resolvedComponent = await resolveAstroComponent(component, resolveFrom);
163
183
  const container = await getAstroContainer();
164
-
184
+
165
185
  if (!container) {
166
186
  throw new Error('Failed to initialize Astro container for rendering');
167
187
  }
168
-
169
- const html = await container.renderToString(resolvedComponent, {
170
- props: args
188
+
189
+ // The direct fallback has no handler, so reconstruct nested components here:
190
+ // load each marker's real server module by id and render slot markers to HTML.
191
+ const loadComponent = async (id: string) => {
192
+ const viteServer = await getAstroSsrViteServer(resolveFrom);
193
+ const mod = (await ssrLoadModuleWithFsFallback(viteServer, id)) as Record<string, unknown>;
194
+
195
+ return patchCreateAstroCompat(mod.default);
196
+ };
197
+ const reconstructedArgs = await reconstructProps(serializedArgs, { loadComponent });
198
+ const reconstructedSlots = await reconstructSlots(serializedSlots, {
199
+ loadComponent,
200
+ renderToHtml: (child) => container.renderToString(child, {})
171
201
  });
172
202
 
173
- if (typeof document !== 'undefined') {
174
- document.body.innerHTML = html;
175
- }
203
+ const html = await container.renderToString(resolvedComponent, {
204
+ props: reconstructedArgs,
205
+ slots: markRawSlots(reconstructedSlots)
206
+ });
176
207
 
177
- return html;
208
+ return setRenderedHtml(html);
178
209
  }
179
210
 
180
211
  async function renderComposedStory(story: ComposedStory) {
@@ -33,7 +33,12 @@ export function vitePluginAstroComponentMarker(): PluginOption {
33
33
  // Only process main .astro modules (not sub-modules like ?astro&type=style)
34
34
  if (!id.endsWith('.astro')) {return null;}
35
35
 
36
- // Detect the Astro 6 client-side stub pattern
36
+ // Detect the Astro client-side stub pattern. Astro 6's Go compiler and
37
+ // Astro 7's Rust compiler (now the default) both emit this same error
38
+ // string in the browser stub, so the single check covers Astro 5–7. If a
39
+ // future compiler changes this text, components will silently miss the
40
+ // `isAstroComponentFactory = true` marker and render blank — update the
41
+ // string here if that happens.
37
42
  if (!code.includes('Astro components cannot be used in the browser')) {return null;}
38
43
 
39
44
  const moduleId = id;