Repository navigation
Conversation
…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.
|
Install the latest version of pnpm add https://pkg.svelte.dev/@sveltejs/kit/c/efbe76b8d4c2728a9180abe3534d12a142161387Open in Note This PR is from a fork. A maintainer must approve approve each commit before it can be built and installed. |
🦋 Changeset detectedLatest commit: efbe76b The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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
marked this pull request as draft
October 8, 2026 08:56
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-facemakes the browser re-create the page's font faces, and the new faces load their fonts again with a conditional request. Afont-display: optionalfont that is still loading when the page renders then falls back for the rest of the page's life (#17377).This keeps the
@font-facerules out of the stylesheet that gets removed:@font-facerules 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 beforesplit_font_facesreads a stylesheet the way browsers tokenize CSS (comments, strings,url(), escapes, blocks). If any of the page's stylesheets has a@font-facethat isn't top-level (in@mediaor@layer, say), an@importor@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 onmain. Moving only some of the rules could change which@font-facewins. The result is cached per stylesheet; reading a generated 1.4 MB one (13,554 class rules and as many@mediablocks) takes 8 ms, 16 ms the first time<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 (nomedia, 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 onmain. 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 asyncdisposecallbacks), so an edited or removed@font-facedoesn't stay behind. No other stylesheet counts as a copy, since nothing would notice it going away<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-faceapplies.document.fontslists both copies, as it already does onmainbetween 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 (deleteRuleon a@font-facethrough 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 aMutationObserver, 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):
mainTests:
basics›css/font-face: holds the page's stylesheet back until the page's font faces have been recorded, then checks that the sameFontFaceobjects are still indocument.fontsafter hydration (onmainthey 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-facefrom 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 toprint)basics›css/font-face/media: a@font-faceinside@medialeaves the page's stylesheets wholewrites: 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)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-facemoved), the browsers see the same top-level@font-facerules 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 onparseCss, and 65 to 2,800 with one of three of the scanner's checks removedpnpm lint,pnpm check,pnpm -F @sveltejs/kit test:unitand the integration suites in dev and build pass, apart from twobasicsdev tests that time out in full runs onmainas well and pass on their own on both:Load › permits 3rd party patching of server load fetch requestsandSPA 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:
Tests
pnpm testand lint the project withpnpm lintandpnpm checkChangesets
pnpm changesetand following the prompts. Changesets that add features should beminorand those that fix bugs should bepatch. Please prefix changeset messages withfeat:,fix:, orchore:.Edits