Skip to content

Svelte: Repaint PreviewRender on non-Vite builder HMR#34810

Open
cgradusov wants to merge 5 commits into
storybookjs:nextfrom
cgradusov:fix/svelte-renderer-hmr-non-vite
Open

Svelte: Repaint PreviewRender on non-Vite builder HMR#34810
cgradusov wants to merge 5 commits into
storybookjs:nextfrom
cgradusov:fix/svelte-renderer-hmr-non-vite

Conversation

@cgradusov

@cgradusov cgradusov commented May 15, 2026

Copy link
Copy Markdown

What I did

PreviewRender.svelte's $derived.by(() => storyFn()) compared the
returned { Component, props } by identity. Svelte 5's
$.hmr(Component) wrapper is identity-stable across hot-updates —
its internal current reactive source swaps the implementation, but
the proxy reference itself never changes. So the derivation never re-
fired, <DecoratorHandler> was never invalidated, and the renderer
froze on the pre-HMR component code on any non-Vite builder
(webpack5, Rspack). State was preserved on the mounted instance, but
the new code never reached the DOM.

Under Vite this worked because vite-plugin-svelte papers over the
gap with private coordination; under webpack/Rspack there is no such
bridge.

How

Module-scope hmrTick store, incremented from the webpack/Rspack
HMR status handler. Vite is intentionally left out:
vite-plugin-svelte already does fine-grained, state-preserving HMR,
so forcing a {#key} remount there would be a regression. The HMR
object is feature-detected (import.meta.webpackHot ?? import.meta.hot)
so a single PreviewRender.svelte works on every builder without
import-time errors.

Builder Trigger
Vite — native vite-plugin-svelte HMR; tick never advances
webpack 5 / Rspack hot.addStatusHandler → tick on idle after a real cycle

$derived.by voids the tick to take it as a dependency, and
{#key tick} tears down + re-creates the <DecoratorHandler> subtree
when the tick advances — so under webpack/Rspack the inner story
subtree is rebuilt at the end of each HMR cycle and the fresh
Component reference flows through. Under Vite the tick stays at 0,
the {#key} block is inert, and Vite's own HMR is untouched.

<script module>
  import { writable } from 'svelte/store';
  const hmrTick = writable(0);

  if (typeof import.meta !== 'undefined') {
    // webpack 5 exposes import.meta.webpackHot; Rspack/others use import.meta.hot.
    // Vite is intentionally excluded — its plugin already does state-preserving HMR.
    const hot = import.meta.webpackHot ?? import.meta.hot;
    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>

inCycle gating prevents firing on the initial 'idle' status that
webpack/Rspack emit at module load — we only want to tick once a real
HMR cycle has run end-to-end.

Why webpackHot ?? hot

webpack 5's ESM HMR API is exposed as import.meta.webpackHot, not
import.meta.hot (see
code/builders/builder-webpack5/templates/virtualModuleModernEntry.js);
Rspack and other builders expose import.meta.hot. Reading
import.meta.webpackHot ?? import.meta.hot and guarding the
addStatusHandler call keeps the module loading cleanly on every
builder — and under Vite, where neither path exposes
addStatusHandler, the block is simply a no-op.

Manual testing

  1. Vite (regression check)code/sandbox/svelte-vite/default-ts:
    edit a $state-rune component template while the story is open;
    expect the text to update live and rune state to be preserved
    (Vite's fine-grained HMR path, {#key} must stay inert here).
  2. webpack/Rspack (the fix) — in the repro
    github.com/cgradusov/rsbuild-svelte5-storybook (examples/basic,
    with the framework's location.reload() fallback removed): edit a
    component template; expect the preview to repaint with the new code
    instead of freezing on the pre-HMR version.
  3. Unit/snapshotyarn nx test renderers-svelte: green after the
    Svelte renderer snapshots are updated for the added {#key} anchor.

Breaking changes

None. hmrTick is module-local; no public API additions or
signature changes.

Cross-refs

  • Renderer-side issue this fixes: <— fill in your storybookjs/storybook issue number here
  • Related downstream tracking: rspack-contrib/storybook-rsbuild#

Summary by CodeRabbit

  • Bug Fixes
    • Hot module replacement now reliably forces preview components to remount on updates, ensuring UI changes appear immediately during development.
    • Fixes cases where identity-stable component proxies prevented live updates from taking effect.
    • Improved preview update compatibility across different build tools so live edits produce consistent, accurate refreshes of rendered previews.

Review Change Stack

Svelte 5's `$.hmr(Component)` returns an identity-stable proxy across
hot-updates. PreviewRender's `$derived.by(() => storyFn())` compared
the returned `{ Component, props }` by identity, saw the same proxy
reference, and never re-fired — so on any non-Vite builder
(webpack5, Rspack) the renderer froze on the pre-HMR component code.
State was preserved on the mounted instance, but the new code never
reached the DOM.

Track HMR cycles in a `<script module>` store fed by both
`vite:afterUpdate` (Vite) and `addStatusHandler('idle')`
(webpack/Rspack), feature-detected so the module stays builder-
agnostic. Touch the tick inside `$derived.by` and wrap the
DecoratorHandler in `{#key tick}` so the inner story subtree is torn
down and re-created at the end of each HMR cycle.

No public API changes; the store is module-local.
@coderabbitai

coderabbitai Bot commented May 15, 2026

Copy link
Copy Markdown
Contributor

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: 3b14c95b-ac44-4a91-b917-f19bc84b46eb

📥 Commits

Reviewing files that changed from the base of the PR and between f60dccb and fdc33df.

📒 Files selected for processing (1)
  • code/renderers/svelte/static/PreviewRender.svelte
🚧 Files skipped from review as they are similar to previous changes (1)
  • code/renderers/svelte/static/PreviewRender.svelte

📝 Walkthrough

Walkthrough

Adds a module-scoped HMR tick store that increments on webpack/Rspack hot-update cycles, derives a reactive tick from it in the instance script, and keys the DecoratorHandler subtree so it remounts on each HMR tick.

Changes

HMR Tick and Component Remount

Layer / File(s) Summary
HMR tick store with builder-specific detection
code/renderers/svelte/static/PreviewRender.svelte
Module-level hmrTick store increments on HMR cycles by feature-detecting import.meta.hot.addStatusHandler and observing status transitions to idle (webpack/Rspack path).
Component reactivity and keyed remount
code/renderers/svelte/static/PreviewRender.svelte
Derives tick from $hmrTick, references it in the story $derived.by callback to force re-evaluation on HMR cycles, and wraps DecoratorHandler in a {#key tick} block to remount the subtree on each tick change.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related issues


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@valentinpalkovic

Copy link
Copy Markdown
Contributor

@JReinhold This needs your judgment

@valentinpalkovic valentinpalkovic moved this from Empathy Queue (prioritized) to On Hold in Core Team Projects May 21, 2026
Comment on lines +89 to +91
{#key tick}
<DecoratorHandler {Component} {props} />
{/key}

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).

@JReinhold JReinhold added the ci:merged Run the CI jobs that normally run when merged. label May 21, 2026
@storybook-app-bot

storybook-app-bot Bot commented May 21, 2026

Copy link
Copy Markdown

Package Benchmarks

Commit: 1c2a718, ran on 29 May 2026 at 01:14:02 UTC

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

@storybook/addon-docs

Before After Difference
Dependency count 18 18 0
Self size 1.28 MB 1.26 MB 🎉 -21 KB 🎉
Dependency size 9.27 MB 9.27 MB 0 B
Bundle Size Analyzer Link Link

storybook

Before After Difference
Dependency count 72 72 0
Self size 20.31 MB 20.28 MB 🎉 -28 KB 🎉
Dependency size 36.11 MB 36.11 MB 0 B
Bundle Size Analyzer Link Link

@storybook/cli

Before After Difference
Dependency count 203 203 0
Self size 908 KB 908 KB 🎉 -144 B 🎉
Dependency size 88.50 MB 88.47 MB 🎉 -28 KB 🎉
Bundle Size Analyzer Link Link

@storybook/codemod

Before After Difference
Dependency count 196 196 0
Self size 32 KB 32 KB 0 B
Dependency size 86.99 MB 86.96 MB 🎉 -28 KB 🎉
Bundle Size Analyzer Link Link

create-storybook

Before After Difference
Dependency count 73 73 0
Self size 1.08 MB 1.08 MB 0 B
Dependency size 56.42 MB 56.39 MB 🎉 -28 KB 🎉
Bundle Size Analyzer node node

The previous commit fed the `hmrTick` store from both `vite:afterUpdate`
(Vite) and `addStatusHandler('idle')` (webpack/Rspack). Under Vite this
is both unnecessary and harmful: `vite-plugin-svelte` already performs
fine-grained, state-preserving HMR, and forcing a full `{#key tick}`
remount on every `vite:afterUpdate` tears down the subtree and discards
that preserved state.

Drop the `vite:afterUpdate` listener and keep the tick only on the
webpack/Rspack status-handler path — the builders that actually freeze
on the identity-stable `$.hmr()` proxy. Vite now keeps its native HMR
behavior untouched; webpack/Rspack still get a full repaint at the end
of each hot-update cycle.
@Sidnioulz Sidnioulz requested a review from Copilot May 26, 2026 08:11
@Sidnioulz Sidnioulz changed the title fix(svelte): repaint PreviewRender on non-Vite builder HMR Svelte: Repaint PreviewRender on non-Vite builder HMR May 26, 2026
@Sidnioulz Sidnioulz requested a review from JReinhold May 26, 2026 08:12

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 Svelte 5 HMR edge case where storyFn() can return an identity-stable $.hmr(Component) proxy, preventing $derived.by from re-running and leaving the preview stuck on pre-HMR code on non-Vite builders.

Changes:

  • Add a module-scoped hmrTick store and advance it on builder HMR status transitions.
  • Force re-evaluation of the story derivation by touching hmrTick in $derived.by.
  • Key the <DecoratorHandler> subtree by the tick to trigger remount on each HMR cycle.

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

Comment thread code/renderers/svelte/static/PreviewRender.svelte Outdated
Comment thread code/renderers/svelte/static/PreviewRender.svelte
cgradusov and others added 2 commits May 27, 2026 05:37
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
@cgradusov

Copy link
Copy Markdown
Author

@JReinhold no rush 🙏 just a heads-up on what changed since your review: I synced the PR description with the code and added the renderer + portable-stories snapshots for the {#key} anchor, so CI is green now (only red left is an unrelated portable-vitest3 flake). Happy to add a dedicated PreviewRender test if you'd like one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent-scan:human bug ci:merged Run the CI jobs that normally run when merged. svelte

Projects

Status: On Hold

Development

Successfully merging this pull request may close these issues.

5 participants