Skip to content

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

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

xyrolle wants to merge 1 commit 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 to avoid FOUC and removes it on hydration, after 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, and the new faces load their fonts again with a conditional request. A font-display: optional font that is still loading when the page renders then falls back for the rest of the page's life (#17377).

This keeps the @font-face rules out of the stylesheet that gets removed:

  • the dev manifest takes the top-level @font-face rules out of each inlined stylesheet. They are rendered in a <style data-sveltekit-font-faces> just before <style data-sveltekit>, which has everything else and is removed as before
  • split_font_faces reads a stylesheet the way browsers tokenize CSS (comments, strings, url(), escapes, blocks). If any of the page's stylesheets has a @font-face that isn't top-level (in @media or @layer, say), an @import or @namespace, or something a browser could read differently once the stylesheet is split (a bad string or URL, a stray } or ;, a rule or comment left open at the end), nothing is moved and the page gets the same styles as on main. Moving only some of the rules could change which @font-face wins. The result is cached per stylesheet; reading a generated 1.4 MB one (13,554 class rules and as many @media blocks) takes 8 ms, 16 ms the first time
  • on hydration, the client keeps <style data-sveltekit-font-faces> only if each of its rules is also declared by one of the styles Vite injected into the <head> after it, and that always applies (no media, not disabled). Otherwise, for example when a stylesheet was imported only on the server or hasn't been imported by the client yet, it is removed along with <style data-sveltekit>, as on main. The client checks again after each HMR update and whenever the children of the <head> change, which covers Vite removing the styles of pruned modules (after any async dispose callbacks), so an edited or removed @font-face doesn't stay behind. No other stylesheet counts as a copy, since nothing would notice it going away
  • when it keeps them, the client reads a computed style before removing <style data-sveltekit>, so that the browser takes in the stylesheets Vite injected first. When it handles the injection and the removal in one style update, it re-creates the font faces even though the removed stylesheet has none (Chrome 154 and Playwright's Chromium 153)

After hydration, each kept rule has an identical copy later in the document, so it doesn't change which @font-face applies. document.fonts lists both copies, as it already does on main between Vite's injection and the removal. Production rendering is unchanged. Two things to know. If code changes one of Vite's injected stylesheets itself without removing it (deleteRule on a @font-face through the CSSOM, say, or disabling it), the kept copy stays until the next HMR update or removal from the <head>. And the changes to the <head> are seen by a MutationObserver, which is only set up once a page has kept such a style through hydration: on a page with an observer, Chromium 153 no longer skips rewriting a <style> with identical text, which re-creates its font faces (Chrome 154 never skips it).

This is a lot of code for a development-only problem. If you'd prefer a different direction, I'm happy to rework it.

Measured with the reproduction from #17377, with a 10 ms delay on the font responses, 16 loads each (loads that end up in the fallback font):

Playwright's Chromium 153 Chrome 154
main 9/16 9/16
this PR 0/16 0/16

Tests:

  • basics › css/font-face: holds the page's stylesheet back until the page's font faces have been recorded, then checks that the same FontFace objects are still in document.fonts after hydration (on main they are re-created), that <style data-sveltekit> was removed and that the styles still apply. Without the computed-style read it only fails in part of the runs (6 of 8 in the last batch, 1 of 8 in the one before), depending on whether the browser updates styles between Vite's injection and the removal. In the reproduction, the faces were re-created without it in 12 and 5 of 16 loads (Chromium 153, Chrome 154)
  • basics › css/font-face/server-only: a @font-face from a stylesheet that only the server imports is gone after hydration, even though the test adds copies of it that mustn't count (a plain <style> in the head, one with Vite's attribute in the body, one with it in the head whose media list is set to print)
  • basics › css/font-face/media: a @font-face inside @media leaves the page's stylesheets whole
  • writes: editing a kept @font-face, and no longer importing its stylesheet, leaves only the faces Vite's styles declare (fails without the check after HMR updates, or without the observer)
  • unit tests for split_font_faces. I also compared it with the parsers of Chrome 154, Chromium 153 and WebKit on 426,000 distinct generated stylesheets and pairs of stylesheets, full of the cases above. In the 70,000 it splits (59,000 with a @font-face moved), the browsers see the same top-level @font-face rules in the moved text and the same other rules in the rest. The same comparison finds 1,600 differences in 50,000 with a splitter based on parseCss, and 65 to 2,800 with one of three of the scanner's checks removed

pnpm lint, pnpm check, pnpm -F @sveltejs/kit test:unit and the integration suites in dev and build pass, apart from two basics dev tests that time out in full runs on main as well and pass on their own on both: Load › permits 3rd party patching of server load fetch requests and SPA mode / no SSR › applies generated component styles (hides announcer). The new tests also pass in WebKit. I couldn't get Playwright's Firefox to launch on this machine, so they haven't run in Firefox.


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 efbe76b:

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

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: efbe76b

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

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

Development

Successfully merging this pull request may close these issues.

font-display: optional fonts can switch to the fallback after hydration in dev

1 participant