Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ exports[`Renders CSF2Secondary story 1`] = `
<!---->
<!---->
<!---->
<!---->
<button
class="storybook-button storybook-button--medium storybook-button--secondary"
style=""
Expand All @@ -20,6 +21,7 @@ exports[`Renders CSF2Secondary story 1`] = `




</div>
</body>
`;
Expand All @@ -32,6 +34,7 @@ exports[`Renders CSF2StoryWithParamsAndDecorator story 1`] = `
<!---->
<!---->
<!---->
<!---->
<div
data-testid="local-decorator"
style="margin: 3em;"
Expand All @@ -56,6 +59,7 @@ exports[`Renders CSF2StoryWithParamsAndDecorator story 1`] = `




</div>
</body>
`;
Expand All @@ -66,6 +70,7 @@ exports[`Renders CSF3Button story 1`] = `
<!---->
<!---->
<!---->
<!---->
<button
class="storybook-button storybook-button--medium storybook-button--secondary"
style=""
Expand All @@ -80,6 +85,7 @@ exports[`Renders CSF3Button story 1`] = `




</div>
</body>
`;
Expand All @@ -90,6 +96,7 @@ exports[`Renders CSF3ButtonWithRender story 1`] = `
<!---->
<!---->
<!---->
<!---->
<div>
<p
data-testid="custom-render"
Expand All @@ -113,6 +120,7 @@ exports[`Renders CSF3ButtonWithRender story 1`] = `




</div>
</body>
`;
Expand All @@ -123,6 +131,7 @@ exports[`Renders CSF3InputFieldFilled story 1`] = `
<!---->
<!---->
<!---->
<!---->
<input
data-testid="input"
/>
Expand All @@ -132,6 +141,7 @@ exports[`Renders CSF3InputFieldFilled story 1`] = `




</div>
</body>
`;
Expand All @@ -142,6 +152,7 @@ exports[`Renders CSF3Primary story 1`] = `
<!---->
<!---->
<!---->
<!---->
<button
class="storybook-button storybook-button--large storybook-button--primary"
style=""
Expand All @@ -156,6 +167,7 @@ exports[`Renders CSF3Primary story 1`] = `




</div>
</body>
`;
Expand All @@ -166,6 +178,7 @@ exports[`Renders LoaderStory story 1`] = `
<!---->
<!---->
<!---->
<!---->
<div>
<div
data-testid="loaded-data"
Expand All @@ -185,6 +198,7 @@ exports[`Renders LoaderStory story 1`] = `




</div>
</body>
`;
Expand All @@ -197,6 +211,7 @@ exports[`Renders NewStory story 1`] = `
<!---->
<!---->
<!---->
<!---->
<div
data-testid="local-decorator"
style="margin: 3em;"
Expand All @@ -221,6 +236,7 @@ exports[`Renders NewStory story 1`] = `




</div>
</body>
`;
58 changes: 57 additions & 1 deletion code/renderers/svelte/static/PreviewRender.svelte
Original file line number Diff line number Diff line change
@@ -1,3 +1,50 @@
<script module>
import { writable } from 'svelte/store';

/**
* HMR cycle counter. Increments on every hot-update so any `$derived`
* that reads it is forced to re-evaluate, and any `{#key}` block keyed
* on it tears down and re-creates its children.
*
* Why this is necessary: Svelte 5's `$.hmr(Component)` returns an
* identity-stable proxy across updates. `$derived.by(() => storyFn())`
* compares the returned `{ Component, props }` by identity, sees the
* same proxy reference, and never re-fires — so the renderer freezes
* on the pre-HMR component code on any non-Vite builder.
*
* This is scoped to webpack/Rspack on purpose. Under Vite,
* `vite-plugin-svelte` papers over the proxy issue with its own
* private coordination channels and performs fine-grained HMR that
* preserves component state. Forcing a full `{#key}` remount there
* would defeat that state preservation, so we deliberately do NOT
* listen to `vite:afterUpdate` and let Vite's native HMR handle it.
*
* The webpack/Rspack status-handler API is feature-detected so a
* single PreviewRender module works on every builder without
* import-time errors.
Comment thread
cgradusov marked this conversation as resolved.
*/
const hmrTick = writable(0);

if (typeof import.meta !== 'undefined') {
const hot = import.meta.webpackHot ?? import.meta.hot;
// webpack / Rspack: status transitions to 'idle' once apply finishes.
// Vite is intentionally excluded (see comment above).
if (hot && typeof hot.addStatusHandler === 'function') {
let inCycle = false;
hot.addStatusHandler((status) => {
if (status === 'check' || status === 'prepare') {
inCycle = true;
return;
}
if (status === 'idle' && inCycle) {
inCycle = false;
hmrTick.update((n) => n + 1);
}
});
}
}
</script>

<script>
/*
! DO NOT change this DecoratorHandler import to a relative path, it will break it.
Expand All @@ -8,11 +55,18 @@

const { name, title, storyFn, showError } = $props();

// Touch `hmrTick` so the derivation re-runs on every HMR cycle. Without
// this, `$derived.by` short-circuits when `storyFn()` returns the same
// (identity-stable) Svelte HMR proxy reference, and new component code
// never reaches the DOM.
const tick = $derived($hmrTick);

let {
/** @type {import('svelte').SvelteComponent} */
Component,
props = {},
} = $derived.by(() => {
void tick;
return storyFn();
});

Expand All @@ -34,5 +88,7 @@
{#snippet pending()}
<div id="sb-pending-async-component-notice">Pending async component...</div>
{/snippet}
<DecoratorHandler {Component} {props} />
{#key tick}
<DecoratorHandler {Component} {props} />
{/key}
Comment on lines +91 to +93

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.

Wouldn't this make Svelte re-render the whole component tree, losing the state in the process?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch — split into two cases.

{#key tick} only re-creates the subtree when tick changes, and tick only changes on an HMR cycle, so normal runtime state is unaffected.

For webpack/Rspack, a remount on hot-update is the intended trade-off: Svelte 5's $.hmr() returns an identity-stable proxy, $derived.by short-circuits on the unchanged reference, and the renderer otherwise freezes on the pre-HMR code. A full repaint beats a stale render there.

You're right that for Vite it would be a regression — vite-plugin-svelte already does fine-grained, state-preserving HMR. I've scoped the tick to the webpack/Rspack addStatusHandler path and dropped the vite:afterUpdate listener, so Vite keeps its native HMR untouched. Pushed as a follow-up commit (f60dccb).

</svelte:boundary>
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ exports[`Renders CSF2Secondary story 1`] = `

<!---->

<!---->


</div>
</body>
Expand Down Expand Up @@ -50,6 +52,8 @@ exports[`Renders CSF2StoryWithParamsAndDecorator story 1`] = `

<!---->

<!---->


</div>
</body>
Expand All @@ -72,6 +76,8 @@ exports[`Renders CSF3Button story 1`] = `

<!---->

<!---->


</div>
</body>
Expand Down Expand Up @@ -103,6 +109,8 @@ exports[`Renders CSF3ButtonWithRender story 1`] = `

<!---->

<!---->


</div>
</body>
Expand All @@ -120,6 +128,8 @@ exports[`Renders CSF3InputFieldFilled story 1`] = `

<!---->

<!---->


</div>
</body>
Expand All @@ -142,6 +152,8 @@ exports[`Renders CSF3Primary story 1`] = `

<!---->

<!---->


</div>
</body>
Expand Down Expand Up @@ -169,6 +181,8 @@ exports[`Renders LoaderStory story 1`] = `

<!---->

<!---->


</div>
</body>
Expand Down Expand Up @@ -202,6 +216,8 @@ exports[`Renders NewStory story 1`] = `

<!---->

<!---->


</div>
</body>
Expand Down