Skip to content

Dependency modernization: Node 24, Vite, and Storybook 10 #2391

Description

@spaceninja

Dependency modernization: Node 24, Vite, and Storybook 10

Our dependency backlog isn't a queue of independent updates — it's one blocked chain plus a set of genuinely independent upgrades. This issue records what's actually blocking what, and the PR sequence we're using to unwind it.

The core finding: webpack is a dead end for our Storybook

@storybook/html-webpack5 stops at 8.6.14 — there is no stable 9 or 10 release. Storybook 10 for HTML is Vite-only.

This inverts how we've been reading the backlog. The closed PRs for css-loader v7, style-loader v4, mini-css-extract-plugin v2, postcss-loader v8, babel-loader v10, and sass-loader v17 are not blocked work waiting on a webpack 5 upgrade. On the Vite path those packages are deleted, not upgraded. Roughly ten dependencies leave package.json entirely.

We should not spend effort upgrading to webpack 5.

Spike results

A full spike (Storybook 10.5.9 + Vite 8 + Twing 7) builds and renders real Twig output. Findings:

  • The Twig source preview works, including syntax highlighting. This is the issue that killed [Review carefully] Update storybook monorepo to v6.5.16 #2006 — Storybook 10 exposes SyntaxHighlighter.registerLanguage, so registering the Twig Prism grammar is a supported hook rather than the fragile react-syntax-highlighter reach-in that broke between 6.5.9 and 6.5.16.
  • Twing 7 still renders synchronously via createSynchronousEnvironment. Stories stay () => template(args) — no async migration needed.
  • We must import twing/light. The full Twing build pulls in locutussafe-buffer, which crashes under Vite (Cannot read properties of undefined (reading 'prototype')) because Vite does not polyfill Node builtins the way webpack 4 did. All 24 Twig filters our templates use were tested against light — no regressions.
  • The Vite plugin is simpler than what it replaces. twing-loader + source-inputs-loader + val-loader + virtual-bundled-environment collapse into roughly 60 lines. The source preview no longer needs the brittle string-replace against twing-loader's generated return template.render(context); we own the render wrapper, so path and args are recorded directly.
  • ~~The custom Sass glob importer must be rewritten to Sass's modern canonicalize/load API (~40 lines). It's shared with gulp-sass, so gulp-sass v6 moves at the same time.~~ Superseded: the importer was deleted, not rewritten. A modern importer owns a URL namespace — everything reached through it comes back to it to resolve, with no fallback to Sass's own loader — so expanding one glob made us responsible for reimplementing Sass's _partial and index lookup rules by hand. All 57 stylesheets are committed source files, so src/index.scss now lists them explicitly and neither Vite nor gulp-sass needs any Sass configuration. gulp-sass v6 still moved at the same time.

Main cost: all 84 .stories.mdx files must migrate to CSF3 + .mdx. Storybook 10 has no indexer for .stories.mdx at all (No matching indexer found). button.stories.mdx also uses webpack's !!raw-loader! syntax, which becomes ?raw.

Node is separately unblockable

Twing 3's only incompatibility with modern Node is util.isNullOrUndefined, removed in Node 22. With a small shim, both the full test suite and the existing Storybook 6.5 + webpack 4 build pass on Node 24. That decouples the Node upgrade — and roughly 20 rate-limited updates behind it — from the Storybook project.

PR sequence

Revised. The original list was a risk ordering, not a dependency chain — only the Node
bump actually unblocked anything. We reprioritised to take Storybook 10 + Vite early,
since it deletes a large part of the backlog rather than upgrading it. Items 4–6 were
independent of each other and of the work already shipped, so they could land in any
order. Only item 6 is left.

  • 1. Node 16 → 24Upgrade CI and local development to Node 24 #2392. Shipped.
  • 2. Storybook 10 + Vite + Twing 7Upgrade to Storybook 10 + Vite, and Twing 7 #2405. Shipped. Twing 7, a Vite Twig plugin,
    Storybook 6.5 → 10, React 17 → 19, gulp-sass 6, the 84-file MDX migration, and
    deletion of the webpack toolchain (~10 dependencies removed, not upgraded).
  • 3. Library buildUpgrade the library build to Rollup 4 and TypeScript 5 #2406. Shipped. rollup 2→4, rollup-plugin-dts 6,
    @rollup/plugin-terser 1, @rollup/plugin-babel 7, @rollup/plugin-node-resolve 16,
    TypeScript 5. Touched neither Storybook nor Twig. Landing this first let Upgrade to Storybook 10 + Vite, and Twing 7 #2405
    drop the temporary @types/node override instead of carrying it.
  • 4. Lint & formatUpgrade the lint and format toolchain: Prettier 3, ESLint 9, Stylelint 16 #2416. Shipped. Prettier 3, ESLint 8 → 9 via a
    migration from @cloudfour/eslint-plugin to its successor
    @cloudfour/eslint-config (flat config), eslint-plugin-mdx 3, Stylelint 16,
    and @whitespace/storybook-addon-html restored at v9. Two targets in this line
    were wrong: stylelint stops at 16, not 17, and ESLint stops at 9, not 10 —
    both capped by our own shared configs rather than upstream. Prettier 3 did not
    unblock formatting *.mdx; that turned out to be a still-open Prettier bug, so
    the .prettierignore rule stays. See the status comment below.
  • Replace PleasantestReplace Pleasantest with Vitest browser mode #2421, filed as Replace Pleasantest in the component test suite #2417. Shipped. Not numbered in this
    sequence, but taken before item 5 because Jest 30 was in it. Jest, Pleasantest,
    Puppeteer, @types/jest, test-utils.ts and jest.setup.js are all gone; the
    suite runs on Vitest 4, split across a jsdom project and a Chromium one. The CI
    test job is off its ubuntu-22.04 pin. See the status comment below.
  • 5. Misc toolchainUpgrade the misc toolchain: Style Dictionary 5, cssnano 8, sass, js-yaml 5, npm-run-all2 9 #2422. Shipped. style-dictionary 5, cssnano 8, js-yaml 5,
    npm-run-all2 9, sass 1.103. Jest 30 was dropped from this item by Replace Pleasantest with Vitest browser mode #2421, which
    removed Jest rather than upgrading it. Style Dictionary 4 turned out to be a
    migration rather than a bump, and two of its changes were silent rather than
    loud
    color/css stopped matching our CTI tokens and normalised nothing while
    the build stayed green. See the status comment below.
    Let design token tables size their own columns #2424 followed, fixing an unrelated defect the token pages had all along.
  • 6. Remove Gulp — 280 lines across 5 tasks and 6 gulp packages, and
    it is thinner than it looks: build-tokens.js is a 5-line wrapper that shells out
    to npm run preprocess:tokens, and build-scripts.js is already plain Rollup API.
    Deliberately kept out of the Storybook PR to keep that reviewable. One constraint
    to carry across: buildTypes generates an entry that resolves ./src/… relative
    to ts-dist, so whatever replaces it has to keep emitting declarations under
    ts-dist/src. That layout is pinned by rootDir as of Replace Pleasantest with Vitest browser mode #2421 — see the status
    comment below for why it was an accident before.
    Two things to fix rather than port verbatim while rewriting these tasks:
    .svgo.yml uses the old plugin-config format (- removeXMLNS: true), which is
    what produces the not part of preset warning listed below — it travels with the
    svgToTwig task rather than being fixed by dropping gulp; and build-scripts.js
    has the entry-glob bug in Storybook demo args are published as part of the public API #2427
    , which a faithful port would carry across.

Changes forced by the Storybook work

  • @types/node is pinned to 20.19.9 via overrides. Resolved — never landed.
    Webpack 4's dependency tree was holding it at 12.20.6; removing webpack let it float to
    26.2.0, which TypeScript 4.9 could not parse (syntax errors in ffi.d.ts, and
    skipLibCheck does not suppress parse errors). Pinning was the cheaper option at the
    time, but Upgrade the library build to Rollup 4 and TypeScript 5 #2406 landed TypeScript 5.9 first, so Upgrade to Storybook 10 + Vite, and Twing 7 #2405 was rebased onto it and dropped
    the override. There is no overrides block in package.json on main.
  • @babel/preset-typescript is now a direct dependency. babel.config.js used it,
    but it was only present transitively via the Storybook 6 packages.
  • eslint-plugin-mdx 3 moved out of the Storybook PR. It was blocked because MDX 1
    allowed arbitrary top-level JS and .stories.mdx relied on it. Now that the 84 files
    are MDX 3, the upgrade should be straightforward, so it belongs with the lint work.
  • src/**/*.stories.js is excluded from tsconfig. Converting MDX to JS brought
    250+ story functions under checkJs for the first time — 269 errors. Stories are demo
    code, are not published, and were not type-checked as MDX either, so excluding them
    preserves existing coverage rather than expanding the PR. Type-checking stories would
    be a real improvement, but it is its own piece of work.

The PR 5 split idea did not pan out

The earlier note suggested extracting CSF from MDX while still on Storybook 6, to split
the migration in two. Both halves of that turned out to be dead ends:

  • Storybook's own mdx-to-csf codemod cannot read our files. It parses with MDX 3,
    which rejects MDX 1's top-level const declarations — the exact thing every one of
    our story files used. The Storybook 10 CLI has also dropped the codemod entirely; it
    only survives in @storybook/codemod@8. Worth recording so nobody retries this.
  • Migrating the files therefore needed a purpose-written transformer, which was going to
    be the bulk of the work either way. Splitting would have added a throwaway
    intermediate state without reducing the total effort.

Addon decisions needed in PR 5

  • storybook-addon-outline — flagged deprecated with no replacement; outlines are built into Storybook core now, so this can likely just be dropped.
  • storybook-mobile and @whitespace/storybook-addon-html — already commented out in .storybook/main.js "until they are fixed upstream". Drop them, or restore at v9.
  • @storybook/addon-essentials and @storybook/addon-postcss — both removed/obsolete in Storybook 10.
  • @etchteam/storybook-addon-status — needs v8.

Housekeeping

We run both Renovate and Dependabot, which is generating duplicate PRs for the same upgrades (lodash in #2368 and postcss in #2373 both duplicated Renovate security PRs at older versions, which is why they conflicted). Renovate is also configured with :automergeMinor, but branch protection requires an approving review, so its automerge can never fire. Aligning these would keep the backlog from rebuilding.

Split out as #2430, so it outlives this issue. One detail found while filing it: there is no .github/dependabot.yml, so Dependabot is enabled through repository settings rather than config — which is part of why it is easy to forget it is running.

Smaller items noticed along the way

  • gulp-svgmin v4 emits a warning: You are trying to enable removeXMLNS which is not part of preset. The SVGO plugin config needs updating. Folded into item 6 as an explicit deliverable — .svgo.yml uses the old plugin-config format, so it travels with the svgToTwig rewrite rather than being fixed by it.
  • package.json still declares engines.node: ">=12.16.3". This describes what consumers of the published package need, not our dev environment, so it deliberately wasn't changed in PR 1 — but it's stale and deserves its own decision. Split out as Decide what engines.node is for, or remove it #2423, and the framing above was wrong: the field started life in Change how renovate handles node versions #689 as an exact pin of the development Node version, so nobody has actually decided it describes consumers. Raising it is also a breaking change rather than housekeeping — pnpm and Yarn Berry fail installs on an engines mismatch, and nothing we publish runs on Node — so the real options are removing it or leaving it, not bumping it.

Storybook 6 → 10 API moves (for reference)

Collected while doing the migration:

Storybook 6 Storybook 10
docs.transformSource docs.source.transform
docs.inlineStories docs.story.inline
docs.prepareForInline not needed — the HTML renderer inlines natively
viewport.viewports viewport.options
@storybook/client-api storybook/preview-api
@storybook/addons storybook/manager-api
@storybook/theming/create storybook/theming/create
@storybook/addon-viewport storybook/viewport
<Description>{markdown} <Markdown>{markdown}Description now takes of={}
<ArgsTable> <Controls>
ReactSyntaxHighlighter.registerLanguage SyntaxHighlighter.registerLanguage from storybook/internal/components
require.context import.meta.glob
!!raw-loader!x x?raw
@svgr/webpack inline loader vite-plugin-svgr with ?react

Two Twing 7 changes worth knowing: the filesystem loader is built from an injected fs
rather than a path, and path namespaces now include the sigil (@cloudfour, not
cloudfour). Rendering can still be synchronous via createSynchronousEnvironment, so
story functions keep returning strings.

The browser build must import twing/light. The full build pulls in locutus, which
needs Node's Buffer; webpack 4 shimmed that automatically and Vite does not, so the
bundle dies on load. All 24 filters our templates use work in light.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions