Skip to content

fix: keep the @font-face rules inlined during development through hydration - #17378

Draft
xyrolle wants to merge 9 commits into
sveltejs:mainfrom
xyrolle:fix/dev-styles-survive-hydration
Draft

xyrolle wants to merge 9 commits into
sveltejs:mainfrom
xyrolle:fix/dev-styles-survive-hydration

Conversation

@xyrolle

@xyrolle xyrolle commented Oct 8, 2026 •

Copy link
Copy Markdown

closes #17377

During development, SvelteKit inlines the page's CSS in a <style data-sveltekit> to avoid FOUC, and removes it on hydration, after Vite's client has injected the same CSS again in its own <style data-vite-dev-id>. A new stylesheet that declares @font-face creates new font faces, so the page's fonts are requested again (a 304). A font-display: optional font that is still loading at that point falls back for the rest of the page's life (#17377).

Vite's client already adopts styles rendered by the server: at startup it collects every <style data-vite-dev-id> in the document, and when a CSS module runs, updateStyle writes to that element instead of adding a new one. So this PR:

  • renders each stylesheet inlined during development in its own <style data-vite-dev-id="…">, marked with its module's id as the server resolved it, instead of one <style data-sveltekit>;
  • stops removing them on hydration. When the browser runs a stylesheet's module under the same id, Vite's client takes that element over, updates it on HMR and removes it when the module is pruned;
  • collects the inlined stylesheets of the page's route modules in the order the browser evaluates them: static imports depth first, each module once, then the modules imported dynamically, whose order at run time can only be approximated, and may still differ after hydration. They were collected in the order their modules were resolved, which could differ: with page.js importing nested.js, which imports base.css, and then override.css, they came out as override.css, base.css. On main that only lasts until hydration; with the elements kept, it would last.

Production rendering is unchanged.

The font faces are kept only together with vitejs/vite#23697 ("fix(client): skip rewriting unchanged adopted styles", which fixes vitejs/vite#23696 and was released in Vite 8.3.4). Before it, Vite's client wrote the same CSS into the adopted <style> again, and Chrome re-creates the font faces of a <style> whose text is assigned, even when the text is the same. Neither change keeps the font faces on its own. Even with both, Vite only skips the write when the text is identical: with css.devSourcemap: true, the client's CSS has a source map comment that the inlined text doesn't, so the faces are re-created as on main.

Measured

A page that imports a stylesheet with an @font-face (font-display: optional), under vite dev. The request for the stylesheet's module is held until the server-rendered font faces have been recorded, then document.fonts is compared after hydration. 3 loads each, after a warm-up load, in Chrome 155 and in Playwright's Chromium 153, with this PR's head:

Vite 8.3.0 Vite 8.3.4
SvelteKit main re-created; the font is requested again (304) re-created; requested again (304)
this PR re-created; requested again in Chrome 155 kept; requested once

The reproduction from #17377, a full project with a Playwright probe: https://github.com/xyrolle/sveltekit-dev-font-fallback

Open questions

Styles the client never takes over. Without the hydration cleanup, an inlined style whose module never runs in the browser under the same id stays until the page reloads. In the basics app:

  • a stylesheet imported only on the server (if (import.meta.env.SSR) await import('./server-only.css') in a +page.js) stays after hydration and after client-side navigation, where its rules still apply to the next page;
  • a package stylesheet whose exports resolve to node.css on the server and browser.css in the browser leaves both in the document after hydration.

HTML can't carry the \0 that starts a virtual module's id, so an inlined virtual stylesheet would never be taken over either. The one in my probe (a plugin's \0virtual:probe.css) wasn't inlined at all, as on main.

Vite's client doesn't say which adopted styles a module claimed, so SvelteKit can't tell these apart from the ones it took over. Would you rather have a small signal from Vite's client for that, so that SvelteKit removes the unclaimed styles on hydration as it does today, or keep a cleanup in SvelteKit some other way?

Stylesheets only the client imports. Vite's client adds a stylesheet it hasn't adopted after the others, and doesn't move the adopted ones. A stylesheet that hooks.client.js imports (.probe { color: red }) therefore ends up after a page's stylesheet that overrides it (.probe { color: blue }), which the server inlined: in the basics app the text is red after hydration, and blue on main. Keeping the cascade here needs Vite to insert such a style before the adopted ones that come later in module order, or SvelteKit to know these stylesheets on the server.

I'll keep this a draft until both are settled.

Tests

  • basics, client.test.js › CSS › "lets Vite take over the styles inlined during development" (development only): it holds back the request for /css's stylesheet module until it has found the inlined <style>, then checks that this element is still in the document after hydration and that the stylesheet isn't there twice. On main it fails, because the inlined style is removed and replaced. It doesn't check font faces, since SvelteKit's lockfile still has Vite 8.3.0.
  • basics, cross-platform/test.js › CSS › "applies styles in the order they are imported": the nested import above, a +page.js that imports a module importing it back before its own stylesheet, and a stylesheet that load imports dynamically (checked in development and with JavaScript, since a built page doesn't link it from the server-rendered HTML). On main it fails in development without JavaScript; without the ordering changes it also fails with JavaScript.
  • Two existing tests looked for style[data-sveltekit]. options › inlineStyleThreshold › "inlines CSS" now expects the inlined styles to stay in development, with and without JavaScript, and the basics check for a page with ssr = false looks for style[data-vite-dev-id].

On e0e0966, with main (d955d1b) merged in, pnpm lint, pnpm check and pnpm test:kit:unit pass (75 files, 1115 passed, 1 skipped), and so do all the test apps in development (pnpm test:kit:dev, basics: 717 passed), the options app's build tests and the ordering test against a build. I didn't run the other build suites.

This replaces the earlier version of this PR, which kept a second copy of the @font-face rules through hydration, with a CSS tokenizer and a MutationObserver to keep it in step with Vite's.


Please don't delete this checklist! Before submitting the PR, please make sure you do the following:

  • It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs
  • This message body should clearly illustrate what problems it solves.
  • Ideally, include a test that fails without this PR but passes with it.

Tests

  • Run the tests with pnpm test and lint the project with pnpm lint and pnpm check

Changesets

  • If your PR makes a change that should be noted in one or more packages' changelogs, generate a changeset by running pnpm changeset and following the prompts. Changesets that add features should be minor and those that fix bugs should be patch. Please prefix changeset messages with feat:, fix:, or chore:.

Edits

  • Please ensure that 'Allow edits from maintainers' is checked. PRs without this option may be closed.

…ration

During development the page's CSS is inlined to avoid FOUC, and removed
once Vite's client has injected the same CSS again. Removing a stylesheet
that declares `@font-face` makes the browser re-create the page's font
faces, which then load their fonts again. Vite serves those with
`Cache-Control: no-cache`, so the load is a network revalidation, and a
`font-display: optional` font that is still loading when the page renders
falls back for the rest of the page's life.

Inline the top-level `@font-face` rules in a style of their own, and
remove only the rest as before. The stylesheets are read the way browsers
tokenize CSS. If any of them has a `@font-face` anywhere else, an
`@import` or `@namespace`, or something a browser could read differently
once it is split (a bad string or URL, a rule or comment left open at the
end), they are all left whole.

The client keeps that style only while each of its rules is also
declared by one of the styles Vite injected into the head after it. It
checks on hydration, after each HMR update and whenever the children of
the head change (as when the styles of pruned modules are removed), so a
face that the client never imports, or that was edited or removed,
doesn't stay behind.

The browser also re-creates the font faces when it takes in Vite's styles
and the removal in one style update, so read a computed style before
removing the inlined one.
@pkg-svelte-dev

pkg-svelte-dev Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Install the latest version of @sveltejs/kit from e0e0966:

pnpm add https://pkg.svelte.dev/@sveltejs/kit/c/e0e0966891147a66304f42dd674ac99516dc9976

Open in pkg.svelte.dev: https://pkg.svelte.dev/repos/kit/pr/17378

Note

This PR is from a fork. A maintainer must approve approve each commit before it can be built and installed.

@changeset-bot

changeset-bot Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e0e0966

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@sveltejs/kit Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@xyrolle

xyrolle commented Oct 22, 2026

Copy link
Copy Markdown
Author

I've replaced the implementation. The earlier version kept a second copy of the @font-face rules next to Vite's, and two copies of the same rules can disagree, which took a tokenizer and an observer to paper over. Now Vite's client takes over the styles SvelteKit inlines. With vitejs/vite#23697 (Vite 8.3.4), that keeps the font faces through hydration when the client runs the stylesheet's module under the same id and its CSS text matches the inlined one. Two points are open, both about the styles the client handles on its own; they're in the description.

@vercel

vercel Bot commented Oct 22, 2026

Copy link
Copy Markdown
Contributor

@xyrolle is attempting to deploy a commit to the vercelliott Team on Vercel.

A member of the Team first needs to authorize it.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

1 participant