From efbe76b8d4c2728a9180abe3534d12a142161387 Mon Sep 17 00:00:00 2001 From: Serghei <43180231+xyrolle@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:45:36 +0300 Subject: [PATCH 1/7] fix: keep the @font-face rules inlined during development through hydration 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. --- .changeset/keep-dev-styles-on-hydration.md | 5 + .../src/exports/vite/dev/generate_manifest.js | 20 +- packages/kit/src/runtime/client/client.js | 72 +++- .../kit/src/runtime/server/page/render.js | 40 ++- packages/kit/src/types/internal.d.ts | 15 +- packages/kit/src/utils/css.js | 307 ++++++++++++++++++ packages/kit/src/utils/css.spec.js | 55 +++- .../src/routes/css/font-face/+page.svelte | 5 + .../routes/css/font-face/media/+page.svelte | 6 + .../src/routes/css/font-face/media/media.css | 6 + .../routes/css/font-face/server-only/+page.js | 5 + .../css/font-face/server-only/+page.svelte | 5 + .../css/font-face/server-only/server-only.css | 4 + .../src/routes/css/font-face/styles.css | 9 + .../test/apps/basics/test/playwright/test.js | 116 +++++++ .../writes/src/routes/font-face/+page.svelte | 5 + .../apps/writes/src/routes/font-face/fonts.js | 1 + .../writes/src/routes/font-face/styles.css | 5 + packages/kit/test/apps/writes/test/test.js | 48 +++ 19 files changed, 715 insertions(+), 14 deletions(-) create mode 100644 .changeset/keep-dev-styles-on-hydration.md create mode 100644 packages/kit/test/apps/basics/src/routes/css/font-face/+page.svelte create mode 100644 packages/kit/test/apps/basics/src/routes/css/font-face/media/+page.svelte create mode 100644 packages/kit/test/apps/basics/src/routes/css/font-face/media/media.css create mode 100644 packages/kit/test/apps/basics/src/routes/css/font-face/server-only/+page.js create mode 100644 packages/kit/test/apps/basics/src/routes/css/font-face/server-only/+page.svelte create mode 100644 packages/kit/test/apps/basics/src/routes/css/font-face/server-only/server-only.css create mode 100644 packages/kit/test/apps/basics/src/routes/css/font-face/styles.css create mode 100644 packages/kit/test/apps/writes/src/routes/font-face/+page.svelte create mode 100644 packages/kit/test/apps/writes/src/routes/font-face/fonts.js create mode 100644 packages/kit/test/apps/writes/src/routes/font-face/styles.css diff --git a/.changeset/keep-dev-styles-on-hydration.md b/.changeset/keep-dev-styles-on-hydration.md new file mode 100644 index 000000000000..9f1834eba5c5 --- /dev/null +++ b/.changeset/keep-dev-styles-on-hydration.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +fix: keep the top-level `@font-face` rules of the styles inlined during development through hydration while Vite's styles declare the same rules, so that their fonts aren't loaded again diff --git a/packages/kit/src/exports/vite/dev/generate_manifest.js b/packages/kit/src/exports/vite/dev/generate_manifest.js index 5d1c68d98bf9..aedec811fd75 100644 --- a/packages/kit/src/exports/vite/dev/generate_manifest.js +++ b/packages/kit/src/exports/vite/dev/generate_manifest.js @@ -6,6 +6,7 @@ import path from 'node:path'; import { get_mime_lookup, get_runtime_base } from '../../../core/utils.js'; import { from_fs, to_fs } from '../../../utils/vite.js'; import { compact } from '../../../utils/array.js'; +import { split_font_faces } from '../../../utils/css.js'; import * as e from '../../../messages/build-errors.js'; import { styleText } from 'node:util'; @@ -31,6 +32,9 @@ export function generate_manifest( root, get_remotes ) { + /** @type {Map }>} */ + const splits = new Map(); + return { app_dir: svelte_config.appDir, app_path: svelte_config.appDir, @@ -132,7 +136,7 @@ export function generate_manifest( // in dev we inline all styles to avoid FOUC. this gets populated lazily so that // components/stylesheets loaded via import() during `load` are included - result.inline_styles = async () => { + result.dev_styles = async () => { /** @type {Set} */ const deps = new Set(); @@ -140,8 +144,8 @@ export function generate_manifest( await find_deps(vite_dev_server, module_node, deps); } - /** @type {Record} */ - const styles = {}; + /** @type {Awaited>>} */ + const styles = []; for (const dep of deps) { if (vite.isCSSRequest(dep.url) && !vite_css_query_regex.test(dep.url)) { @@ -150,7 +154,15 @@ export function generate_manifest( : dep.url + '?inline'; try { const mod = await runner.import(inlineCssUrl); - styles[dep.url] = mod.default; + const css = /** @type {string} */ (mod.default); + + let cached = splits.get(dep.url); + if (cached?.css !== css) { + cached = { css, split: split_font_faces(css) }; + splits.set(dep.url, cached); + } + + styles.push({ url: dep.url, css, split: cached.split }); } catch { // this can happen with dynamically imported modules, I think // because the Vite module graph doesn't distinguish between diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index b8ad78e8fb79..7adffd0bff18 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -897,7 +897,20 @@ async function initialize(result, target, should_hydrate) { // Removes the style node we used to avoid FOUC during development if (__SVELTEKIT_DEV__) { const style = document.querySelector('style[data-sveltekit]'); - if (style) style.remove(); + const font_faces = style?.previousElementSibling; + + if (font_faces?.hasAttribute('data-sveltekit-font-faces')) { + if (declared_later(font_faces)) { + // Apply the styles Vite injected first. Taking in the removal at the same + // time makes the browser re-create the font faces that were kept + void getComputedStyle(document.documentElement).color; + watch_dev_font_faces(font_faces); + } else { + font_faces.remove(); + } + } + + style?.remove(); } apply_navigation_result(result); @@ -3793,6 +3806,63 @@ if (DEV) { } } +/** @type {Set} */ +const dev_font_faces = new Set(); + +/** @type {MutationObserver | undefined} */ +let dev_font_faces_observer; + +/** + * Watches `@font-face` rules that were kept from the styles inlined during development (see + * `render_response`). They go when Vite's styles stop declaring them: updates rewrite those, + * and they leave the head when their module is pruned + * @param {Element} style + */ +function watch_dev_font_faces(style) { + dev_font_faces.add(style); + + if (dev_font_faces_observer) return; + + dev_font_faces_observer = new MutationObserver(check_dev_font_faces); + dev_font_faces_observer.observe(document.head, { childList: true }); + + if (import.meta.hot) { + import.meta.hot.on('vite:afterUpdate', check_dev_font_faces); + } +} + +function check_dev_font_faces() { + for (const style of dev_font_faces) { + if (declared_later(style)) continue; + + style.remove(); + dev_font_faces.delete(style); + } +} + +/** + * Whether each rule of a stylesheet is also declared by one of the styles that Vite injected + * after it and that always apply. They are children of the head, which is what's observed + * @param {Element} style + */ +function declared_later(style) { + /** @type {Set} */ + const declared = new Set(); + + for (const node of document.head.children) { + if (!(node instanceof HTMLStyleElement) || !node.hasAttribute('data-vite-dev-id')) continue; + if (!node.sheet || node.sheet.disabled || node.sheet.media.length > 0) continue; + if (!(style.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_FOLLOWING)) continue; + + for (const rule of node.sheet.cssRules) { + if (rule instanceof CSSFontFaceRule) declared.add(rule.cssText); + } + } + + const rules = /** @type {HTMLStyleElement} */ (style).sheet?.cssRules; + return !!rules?.length && Array.from(rules).every((rule) => declared.has(rule.cssText)); +} + /** * @param {NavigationFinished} result */ diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js index 0a8042eb03fa..22e3eef7cf7f 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -97,6 +97,9 @@ export async function render_response({ // TODO if we add a client entry point one day, we will need to include inline_styles with the entry, otherwise stylesheets will be linked even if they are below inlineStyleThreshold const inline_styles = new Map(); + /** @type {Map} */ + const dev_styles = new Map(); + /** @type {Omit} */ let rendered; @@ -261,6 +264,10 @@ export async function render_response({ for (const url of node.stylesheets) stylesheets.add(url); for (const font of node.fonts) fonts.set(font.file, font); + if (__SVELTEKIT_DEV__ && node.dev_styles) { + for (const { url, ...style } of await node.dev_styles()) dev_styles.set(url, style); + } + if (node.inline_styles && !client?.inline) { Object.entries(await node.inline_styles()).forEach(([filename, css]) => { if (typeof css === 'string') { @@ -279,18 +286,45 @@ export async function render_response({ /** @param {string} path */ const prefixed = (path) => client_path(path, { base, assets }); + let font_faces = ''; + + if (__SVELTEKIT_DEV__ && dev_styles.size > 0) { + // Moving the `@font-face` rules out of only some stylesheets could change which one wins + const split = Array.from(dev_styles.values()).every((style) => style.split); + + for (const [url, style] of dev_styles) { + if (split && style.split) { + if (style.split.font_faces) font_faces += (font_faces && '\n') + style.split.font_faces; + inline_styles.set(url, style.split.rest); + } else { + inline_styles.set(url, style.css); + } + } + } + const style = client?.inline ? client.inline?.style : Array.from(inline_styles.values()).join('\n'); - if (style) { + if (font_faces) { + // The client keeps these after hydration while Vite's styles declare the same ones. + // Removing a stylesheet that declares them makes the browser re-create the page's + // font faces and load their fonts again + const attributes = ['data-sveltekit-font-faces']; + if (csp.style_needs_nonce) attributes.push(`nonce="${csp.nonce}"`); + await csp.add_style(font_faces); + head.add_style(font_faces, attributes); + } + + // the client finds the `@font-face` rules above next to this, so it's there even if it's empty + if (style || font_faces) { // We always inline all styles to avoid FOUC during development. // Once that's accomplished, we find and remove the style node using the // `data-sveltekit` attribute once CSR kicks in const attributes = __SVELTEKIT_DEV__ ? ['data-sveltekit'] : []; if (csp.style_needs_nonce) attributes.push(`nonce="${csp.nonce}"`); - await csp.add_style(style); - head.add_style(style, attributes); + await csp.add_style(style ?? ''); + head.add_style(style ?? '', attributes); } /** diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index 1d49f5c97775..2d4be54fc85e 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -515,15 +515,20 @@ export interface SSRNode { server_id?: string; /** - * During development, all styles are inlined for the page to avoid FOUC. - * But in production, this stores styles that are below the inline threshold. - * It returns a Promise during development because Vite needs to load the - * modules on demand. But in production, the contents have been precomputed - * during the build, so it can return synchronously. + * In production, this stores styles that are below the inline threshold. */ inline_styles?(): MaybePromise< Record string)> >; + /** + * During development, all styles are inlined for the page to avoid FOUC. + * `split` holds their top-level `@font-face` rules separately, so that they + * can stay in the page after hydration, or is `null` if they can't be moved. + * It returns a Promise because Vite needs to load the modules on demand. + */ + dev_styles?(): Promise< + Array<{ url: string; css: string; split: { font_faces: string; rest: string } | null }> + >; /** Svelte component */ component?: SSRComponentLoader; /** +page.js or +layout.js */ diff --git a/packages/kit/src/utils/css.js b/packages/kit/src/utils/css.js index b6c691b62d3e..9454653f1a93 100644 --- a/packages/kit/src/utils/css.js +++ b/packages/kit/src/utils/css.js @@ -124,6 +124,313 @@ export function fix_css_urls({ return s.toString(); } +/** + * Moves the top-level `@font-face` rules out of a stylesheet, reading it the way browsers tokenize + * CSS (https://drafts.csswg.org/css-syntax-3/#tokenization). Returns `null` if that could change + * what the stylesheet means: if it has a `@font-face` anywhere else (in `@media` or `@layer`, say), + * an `@import` or `@namespace`, a bad string or URL, or anything left open at the end, which would + * carry on into the next stylesheet when they're concatenated + * @param {string} css + * @returns {{ font_faces: string; rest: string } | null} + */ +export function split_font_faces(css) { + /** @type {number[]} the closing bracket of each open block */ + const closers = []; + + /** @type {Array<[number, number]>} */ + const font_faces = []; + + // where the current top-level rule starts (-1 between rules), and what it is + let start = -1; + let at_rule = false; + let font_face = false; + + let i = 0; + + while (i < css.length) { + const c = css.charCodeAt(i); + + if (is_whitespace(c)) { + i += 1; + continue; + } + + if (c === 0x2f && css.charCodeAt(i + 1) === 0x2a) { + const end = css.indexOf('*/', i + 2); + if (end === -1) return null; + i = end + 2; + continue; + } + + const top = closers.length === 0; + const begins = top && start === -1; + if (begins) { + start = i; + at_rule = false; + font_face = false; + } + + if (c === 0x22 || c === 0x27) { + i = skip_string(css, i); + if (i === -1) return null; + } else if (c === 0x40 && starts_ident(css, i + 1)) { + const end = skip_ident(css, i + 1); + const name = decode_ident(css.slice(i + 1, end)); + if (name === 'import' || name === 'namespace') return null; + if (name === 'font-face') { + if (!begins) return null; + font_face = true; + } + if (begins) at_rule = true; + i = end; + } else if (starts_number(css, i)) { + i = skip_number(css, i); + } else if (c === 0x23 && (is_ident_char(css.charCodeAt(i + 1)) || is_escape(css, i + 1))) { + i = skip_ident(css, i + 1); + } else if (starts_ident(css, i)) { + const end = skip_ident(css, i); + const name = css.charCodeAt(end) === 0x28 ? decode_ident(css.slice(i, end)) : null; + i = end; + + if (name === 'url') { + let j = i + 1; + while (is_whitespace(css.charCodeAt(j))) j += 1; + + const next = css.charCodeAt(j); + if (next === 0x22 || next === 0x27) { + closers.push(0x29); + i = j; + } else { + i = skip_url(css, j); + if (i === -1) return null; + } + } else if (name !== null) { + closers.push(0x29); + i += 1; + } + } else if (c === 0x7b || c === 0x5b || c === 0x28) { + closers.push(c === 0x7b ? 0x7d : c === 0x5b ? 0x5d : 0x29); + i += 1; + } else if (c === 0x7d || c === 0x5d || c === 0x29) { + if (top) return null; + + if (c === closers[closers.length - 1]) { + closers.pop(); + + if (closers.length === 0 && c === 0x7d) { + if (font_face) font_faces.push([start, i + 1]); + start = -1; + } + } + + i += 1; + } else if (c === 0x3b && top) { + if (!at_rule || font_face) return null; + start = -1; + i += 1; + } else { + i += 1; + } + } + + if (closers.length > 0 || start !== -1) return null; + + let rest = ''; + let last = 0; + + for (const [from, to] of font_faces) { + rest += css.slice(last, from); + last = to; + } + + return { + font_faces: font_faces.map(([from, to]) => css.slice(from, to)).join('\n'), + rest: rest + css.slice(last) + }; +} + +/** @param {number} c */ +function is_whitespace(c) { + return c === 0x20 || c === 0x09 || is_newline(c); +} + +/** @param {number} c */ +function is_newline(c) { + return c === 0x0a || c === 0x0d || c === 0x0c; +} + +/** @param {number} c */ +function is_hex(c) { + return (c >= 0x30 && c <= 0x39) || (c >= 0x41 && c <= 0x46) || (c >= 0x61 && c <= 0x66); +} + +/** @param {number} c */ +function is_digit(c) { + return c >= 0x30 && c <= 0x39; +} + +/** @param {number} c */ +function is_ident_start(c) { + // NULL is read as U+FFFD + return (c >= 0x41 && c <= 0x5a) || (c >= 0x61 && c <= 0x7a) || c === 0x5f || c >= 0x80 || c === 0; +} + +/** @param {number} c */ +function is_ident_char(c) { + return is_ident_start(c) || is_digit(c) || c === 0x2d; +} + +/** + * Whether a valid escape starts at `i` + * @param {string} css + * @param {number} i + */ +function is_escape(css, i) { + return css.charCodeAt(i) === 0x5c && !is_newline(css.charCodeAt(i + 1)); +} + +/** + * @param {string} css + * @param {number} i + */ +function starts_ident(css, i) { + const c = css.charCodeAt(i); + if (c === 0x2d) { + const next = css.charCodeAt(i + 1); + return is_ident_start(next) || next === 0x2d || is_escape(css, i + 1); + } + return is_ident_start(c) || is_escape(css, i); +} + +/** + * @param {string} css + * @param {number} i + */ +function starts_number(css, i) { + if (css.charCodeAt(i) === 0x2b || css.charCodeAt(i) === 0x2d) i += 1; + if (css.charCodeAt(i) === 0x2e) i += 1; + return is_digit(css.charCodeAt(i)); +} + +/** + * Returns the end of the escape whose backslash is at `i` + * @param {string} css + * @param {number} i + */ +function skip_escape(css, i) { + let j = i + 1; + while (j - i <= 6 && is_hex(css.charCodeAt(j))) j += 1; + if (j === i + 1) return j + 1; + if (css.charCodeAt(j) === 0x0d && css.charCodeAt(j + 1) === 0x0a) return j + 2; + return is_whitespace(css.charCodeAt(j)) ? j + 1 : j; +} + +/** + * @param {string} css + * @param {number} i + */ +function skip_ident(css, i) { + while (i < css.length) { + if (is_ident_char(css.charCodeAt(i))) i += 1; + else if (is_escape(css, i)) i = skip_escape(css, i); + else break; + } + return i; +} + +/** + * Returns an ident's value, with escapes decoded and ASCII letters lowercased + * @param {string} ident + */ +function decode_ident(ident) { + return ident + .replace(/\\(?:([0-9a-fA-F]{1,6})(?:\r\n|[ \t\n\r\f])?|([^]))/g, (_, hex, char) => { + if (char !== undefined) return char; + const code = parseInt(hex, 16); + return code === 0 || (code >= 0xd800 && code <= 0xdfff) || code > 0x10ffff + ? '�' + : String.fromCodePoint(code); + }) + .replace(/[A-Z]/g, (char) => char.toLowerCase()); +} + +/** + * Returns the end of the number (with its unit, if any) at `i` + * @param {string} css + * @param {number} i + */ +function skip_number(css, i) { + if (css.charCodeAt(i) === 0x2b || css.charCodeAt(i) === 0x2d) i += 1; + while (is_digit(css.charCodeAt(i))) i += 1; + if (css.charCodeAt(i) === 0x2e && is_digit(css.charCodeAt(i + 1))) { + i += 1; + while (is_digit(css.charCodeAt(i))) i += 1; + } + + const e = css.charCodeAt(i); + if (e === 0x45 || e === 0x65) { + const sign = css.charCodeAt(i + 1) === 0x2b || css.charCodeAt(i + 1) === 0x2d ? 1 : 0; + if (is_digit(css.charCodeAt(i + 1 + sign))) { + i += 1 + sign; + while (is_digit(css.charCodeAt(i))) i += 1; + } + } + + if (starts_ident(css, i)) return skip_ident(css, i); + return css.charCodeAt(i) === 0x25 ? i + 1 : i; +} + +/** + * Returns the end of the string whose quote is at `i`, or -1 if it's a bad or unclosed string + * @param {string} css + * @param {number} i + */ +function skip_string(css, i) { + const quote = css.charCodeAt(i); + i += 1; + + while (i < css.length) { + const c = css.charCodeAt(i); + if (c === quote) return i + 1; + if (is_newline(c)) return -1; + + if (c !== 0x5c) i += 1; + else if (css.charCodeAt(i + 1) === 0x0d && css.charCodeAt(i + 2) === 0x0a) i += 3; + else if (is_newline(css.charCodeAt(i + 1))) i += 2; + else i = skip_escape(css, i); + } + + return -1; +} + +/** + * Returns the end of the unquoted `url(` whose contents start at `i`, or -1 if it's a bad or + * unclosed URL + * @param {string} css + * @param {number} i + */ +function skip_url(css, i) { + while (i < css.length) { + const c = css.charCodeAt(i); + if (c === 0x29) return i + 1; + + if (is_whitespace(c)) { + while (is_whitespace(css.charCodeAt(i))) i += 1; + return css.charCodeAt(i) === 0x29 ? i + 1 : -1; + } + + // quotes, `(` and non-printable characters make it a bad URL (NULL is read as U+FFFD) + if (c === 0x22 || c === 0x27 || c === 0x28 || (c > 0 && c <= 0x08)) return -1; + if (c === 0x0b || (c >= 0x0e && c <= 0x1f) || c === 0x7f) return -1; + + if (c !== 0x5c) i += 1; + else if (is_escape(css, i)) i = skip_escape(css, i); + else return -1; + } + + return -1; +} + /** * @param {StyleSheetChildren[0]} rule * @param {(declaration: Declaration) => void} callback diff --git a/packages/kit/src/utils/css.spec.js b/packages/kit/src/utils/css.spec.js index c840187931cd..dd242e28e315 100644 --- a/packages/kit/src/utils/css.spec.js +++ b/packages/kit/src/utils/css.spec.js @@ -1,5 +1,5 @@ import { assert, describe, test } from 'vitest'; -import { fix_css_urls, tippex_comments_and_strings } from './css.js'; +import { fix_css_urls, split_font_faces, tippex_comments_and_strings } from './css.js'; describe('fix_css_urls', () => { const cdn_assets = 'https://cdn.example.com/_app/immutable/assets'; @@ -266,3 +266,56 @@ describe('tippex_comments_and_strings', () => { assert.equal(result.length, input.length, 'output length must equal input length'); }); }); + +describe('split_font_faces', () => { + test('leaves stylesheets without font faces alone', () => { + const css = '.a { color: red; }\n@media (min-width: 1px) { .b { color: blue; } }'; + assert.deepEqual(split_font_faces(css), { font_faces: '', rest: css }); + }); + + test('moves top-level font faces out', () => { + assert.deepEqual( + split_font_faces( + "@font-face { font-family: 'A'; src: url(a.woff2); }\n.a { font-family: 'A'; }" + ), + { + font_faces: "@font-face { font-family: 'A'; src: url(a.woff2); }", + rest: "\n.a { font-family: 'A'; }" + } + ); + }); + + test('recognises the at-rule however it is written', () => { + const css = + '@FONT-FACE{font-family:A}@font\\2d face{font-family:B}@\\66ont-face{font-family:C}'; + assert.deepEqual(split_font_faces(css), { font_faces: css.replace(/\}@/g, '}\n@'), rest: '' }); + }); + + test('reads comments, strings, URLs and escapes like a browser', () => { + const font_face = '@font-face { src: url(a}b.woff2), url("c}d.woff2"); font-family: "\\"}" }'; + const css = `/* @font-face { */ .a\\{ { content: "}" } .b { c: 1url(}) }\n${font_face}`; + assert.deepEqual(split_font_faces(css), { + font_faces: font_face, + rest: '/* @font-face { */ .a\\{ { content: "}" } .b { c: 1url(}) }\n' + }); + }); + + test.each([ + ['@media print { @font-face { font-family: A; } }'], + ['@layer base { @font-face { font-family: A; } }'], + ['.a { @font-face { font-family: A; } }'], + ['@import url(https://example.com/fonts.css);\n@font-face { font-family: A; }'], + ['@font-face { font-family: A; }\n@namespace svg url(http://www.w3.org/2000/svg);'], + [''], + ['.a { content: "b\n}" } @font-face { font-family: A; }'], + ['.a { b: url(c"d) } @font-face { font-family: A; }'], + ['#url(}) @font-face { font-family: A; }'], + ['} @font-face { font-family: A; }'], + ['@font-face;'], + ['@font-face { font-family: A; } .a { color: red'], + ['@font-face { font-family: A; } /* b'], + ['@font-face { font-family: A; } @layer b'] + ])('gives up on %j', (css) => { + assert.equal(split_font_faces(css), null); + }); +}); diff --git a/packages/kit/test/apps/basics/src/routes/css/font-face/+page.svelte b/packages/kit/test/apps/basics/src/routes/css/font-face/+page.svelte new file mode 100644 index 000000000000..3585a993073c --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/css/font-face/+page.svelte @@ -0,0 +1,5 @@ + + +

this text is green

diff --git a/packages/kit/test/apps/basics/src/routes/css/font-face/media/+page.svelte b/packages/kit/test/apps/basics/src/routes/css/font-face/media/+page.svelte new file mode 100644 index 000000000000..23e32ef705b2 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/css/font-face/media/+page.svelte @@ -0,0 +1,6 @@ + + +

this text is green

diff --git a/packages/kit/test/apps/basics/src/routes/css/font-face/media/media.css b/packages/kit/test/apps/basics/src/routes/css/font-face/media/media.css new file mode 100644 index 000000000000..addb645e8558 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/css/font-face/media/media.css @@ -0,0 +1,6 @@ +@media (min-width: 1px) { + @font-face { + font-family: 'Font Face In Media'; + src: url('../../../asset-preload/shlop.woff2') format('woff2'); + } +} diff --git a/packages/kit/test/apps/basics/src/routes/css/font-face/server-only/+page.js b/packages/kit/test/apps/basics/src/routes/css/font-face/server-only/+page.js new file mode 100644 index 000000000000..99dfc67d3f8c --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/css/font-face/server-only/+page.js @@ -0,0 +1,5 @@ +import { browser } from '$app/env'; + +export async function load() { + if (!browser) await import('./server-only.css'); +} diff --git a/packages/kit/test/apps/basics/src/routes/css/font-face/server-only/+page.svelte b/packages/kit/test/apps/basics/src/routes/css/font-face/server-only/+page.svelte new file mode 100644 index 000000000000..be0f42a6d0ca --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/css/font-face/server-only/+page.svelte @@ -0,0 +1,5 @@ + + +

this text is green

diff --git a/packages/kit/test/apps/basics/src/routes/css/font-face/server-only/server-only.css b/packages/kit/test/apps/basics/src/routes/css/font-face/server-only/server-only.css new file mode 100644 index 000000000000..640065e5281a --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/css/font-face/server-only/server-only.css @@ -0,0 +1,4 @@ +@font-face { + font-family: 'Server Only'; + src: url('../../../asset-preload/shlop.woff2') format('woff2'); +} diff --git a/packages/kit/test/apps/basics/src/routes/css/font-face/styles.css b/packages/kit/test/apps/basics/src/routes/css/font-face/styles.css new file mode 100644 index 000000000000..cfcb69e01e60 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/css/font-face/styles.css @@ -0,0 +1,9 @@ +@font-face { + font-family: 'Font Face'; + src: url('../../asset-preload/shlop.woff2') format('woff2'); +} + +.font-face { + color: green; + font-family: 'Font Face', sans-serif; +} diff --git a/packages/kit/test/apps/basics/test/playwright/test.js b/packages/kit/test/apps/basics/test/playwright/test.js index d7149aa3695d..4d30662add99 100644 --- a/packages/kit/test/apps/basics/test/playwright/test.js +++ b/packages/kit/test/apps/basics/test/playwright/test.js @@ -719,6 +719,122 @@ test.describe('Nested layouts', () => { }); }); +test.describe('CSS', () => { + test('keeps the inlined @font-face rules through hydration in dev', async ({ + page, + javaScriptEnabled + }) => { + test.skip(!process.env.DEV || !javaScriptEnabled, 'styles are only inlined in dev'); + + // hold the page's stylesheet back until the font faces from the inlined styles have been + // recorded. This also makes Vite inject it in the same task as hydration + /** @type {() => void} */ + let start = () => {}; + const started = new Promise((fulfil) => (start = /** @type {() => void} */ (fulfil))); + await page.route(/\/css\/font-face\/styles\.css/, async (route) => { + await started; + await route.continue(); + }); + + await page.goto('/css/font-face', { waitUntil: 'domcontentloaded', wait_for_started: false }); + await page.evaluate(() => { + // @ts-expect-error + window.ssr_faces = Array.from(document.fonts).filter((face) => + face.family.includes('Font Face') + ); + }); + + start(); + await page.locator('body.started').waitFor(); + + expect( + await page.evaluate(() => ({ + // @ts-expect-error + faces: window.ssr_faces.length, + // re-created font faces would load their fonts again + // @ts-expect-error + kept: window.ssr_faces.every((face) => Array.from(document.fonts).includes(face)), + removed: document.querySelector('style[data-sveltekit]') === null, + color: getComputedStyle(/** @type {Element} */ (document.querySelector('.font-face'))).color + })) + ).toEqual({ faces: 1, kept: true, removed: true, color: 'rgb(0, 128, 0)' }); + }); + + test('removes the inlined @font-face rules the client does not declare in dev', async ({ + page, + javaScriptEnabled + }) => { + test.skip(!process.env.DEV || !javaScriptEnabled, 'styles are only inlined in dev'); + + // hold the client back to look at the server-rendered styles + /** @type {() => void} */ + let start = () => {}; + const started = new Promise((fulfil) => (start = /** @type {() => void} */ (fulfil))); + await page.route(/\/runtime\/client\/entry\.js/, async (route) => { + await started; + await route.continue(); + }); + + await page.goto('/css/font-face/server-only', { + waitUntil: 'domcontentloaded', + wait_for_started: false + }); + + // only the styles that Vite injects into the head, and that always apply, count as copies + // of the server-only face + expect( + await page.evaluate(() => { + const kept = /** @type {HTMLStyleElement} */ ( + document.querySelector('style[data-sveltekit-font-faces]') + ); + const font_face = Array.from(kept.sheet?.cssRules ?? []).find((rule) => + rule.cssText.includes('Server Only') + ); + + /** + * @param {HTMLElement} parent + * @param {string} [id] + */ + const copy = (parent, id) => { + const style = document.createElement('style'); + if (id) style.setAttribute('data-vite-dev-id', id); + style.textContent = font_face?.cssText ?? ''; + parent.append(style); + return style; + }; + + copy(document.head); + copy(document.body, 'body'); + + const print = copy(document.head, 'print'); + if (print.sheet) print.sheet.media.mediaText = 'print'; + + return font_face !== undefined; + }) + ).toBe(true); + + start(); + await page.locator('body.started').waitFor(); + + expect( + await page.evaluate(() => ({ + kept: document.querySelector('style[data-sveltekit-font-faces]') !== null, + color: getComputedStyle(/** @type {Element} */ (document.querySelector('.font-face'))).color + })) + ).toEqual({ kept: false, color: 'rgb(0, 128, 0)' }); + }); + + test('leaves the inlined styles whole if some @font-face rules cannot be moved in dev', async ({ + request + }) => { + test.skip(!process.env.DEV, 'styles are only inlined in dev'); + + const html = await (await request.get('/css/font-face/media')).text(); + expect(html).toContain('Font Face In Media'); + expect(html).not.toContain('data-sveltekit-font-faces'); + }); +}); + test.describe('Page options', () => { test('does not include + +

font face

diff --git a/packages/kit/test/apps/writes/src/routes/font-face/fonts.js b/packages/kit/test/apps/writes/src/routes/font-face/fonts.js new file mode 100644 index 000000000000..46dea4530b1c --- /dev/null +++ b/packages/kit/test/apps/writes/src/routes/font-face/fonts.js @@ -0,0 +1 @@ +import './styles.css'; diff --git a/packages/kit/test/apps/writes/src/routes/font-face/styles.css b/packages/kit/test/apps/writes/src/routes/font-face/styles.css new file mode 100644 index 000000000000..d1343961db97 --- /dev/null +++ b/packages/kit/test/apps/writes/src/routes/font-face/styles.css @@ -0,0 +1,5 @@ +@font-face { + font-family: 'Hot Face'; + src: url('/missing.woff2') format('woff2'); + unicode-range: U+0-FF; +} diff --git a/packages/kit/test/apps/writes/test/test.js b/packages/kit/test/apps/writes/test/test.js index 06947b31e430..7054b2bf5e0c 100644 --- a/packages/kit/test/apps/writes/test/test.js +++ b/packages/kit/test/apps/writes/test/test.js @@ -60,6 +60,54 @@ test.describe('Filesystem updates', () => { } }); + test('Font faces kept from the inlined styles follow HMR updates', async ({ + page, + javaScriptEnabled + }) => { + test.skip(!process.env.DEV || !javaScriptEnabled); + + const fonts_file = fileURLToPath(new URL('../src/routes/font-face/fonts.js', import.meta.url)); + const styles_file = fileURLToPath( + new URL('../src/routes/font-face/styles.css', import.meta.url) + ); + const fonts_contents = fs.readFileSync(fonts_file, 'utf-8'); + const styles_contents = fs.readFileSync(styles_file, 'utf-8'); + + const ranges = () => + page.evaluate(() => + Array.from(document.fonts) + .filter((face) => face.family.includes('Hot Face')) + .map((face) => face.unicodeRange) + ); + + try { + await page.goto('/font-face'); + expect(await page.locator('style[data-sveltekit-font-faces]').count()).toBe(1); + + // an edited face replaces the kept one + fs.writeFileSync(styles_file, styles_contents.replace('U+0-FF', 'U+41')); + await expect.poll(ranges).toEqual(['U+41']); + + fs.writeFileSync(styles_file, styles_contents); + await page.waitForTimeout(500); // this is the rare time we actually need waitForTimeout; we have no visibility into whether the module graph has been invalidated + await page.reload(); + expect(await page.locator('style[data-sveltekit-font-faces]').count()).toBe(1); + + // so does a stylesheet that is no longer imported, whatever comes after the kept + // style (here, the styles inlined for another app that doesn't hydrate) + await page.evaluate(() => { + const style = document.createElement('style'); + style.setAttribute('data-sveltekit', ''); + document.querySelector('style[data-sveltekit-font-faces]')?.after(style); + }); + fs.writeFileSync(fonts_file, 'export {};\n'); + await expect.poll(ranges).toEqual([]); + } finally { + fs.writeFileSync(fonts_file, fonts_contents); + fs.writeFileSync(styles_file, styles_contents); + } + }); + test('Universal node is updated when page options change', async ({ page, javaScriptEnabled, From 675d6ef76a75b4ddbc94943320ef8ee6704f4a6a Mon Sep 17 00:00:00 2001 From: Serghei <43180231+xyrolle@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:32:37 +0300 Subject: [PATCH 2/7] chore: remove the separate copy of the dev @font-face rules --- .../src/exports/vite/dev/generate_manifest.js | 20 +- packages/kit/src/runtime/client/client.js | 72 +--- .../kit/src/runtime/server/page/render.js | 40 +-- packages/kit/src/types/internal.d.ts | 15 +- packages/kit/src/utils/css.js | 307 ------------------ packages/kit/src/utils/css.spec.js | 55 +--- .../src/routes/css/font-face/+page.svelte | 5 - .../routes/css/font-face/media/+page.svelte | 6 - .../src/routes/css/font-face/media/media.css | 6 - .../routes/css/font-face/server-only/+page.js | 5 - .../css/font-face/server-only/+page.svelte | 5 - .../css/font-face/server-only/server-only.css | 4 - .../src/routes/css/font-face/styles.css | 9 - .../test/apps/basics/test/playwright/test.js | 116 ------- .../writes/src/routes/font-face/+page.svelte | 5 - .../apps/writes/src/routes/font-face/fonts.js | 1 - .../writes/src/routes/font-face/styles.css | 5 - packages/kit/test/apps/writes/test/test.js | 48 --- 18 files changed, 14 insertions(+), 710 deletions(-) delete mode 100644 packages/kit/test/apps/basics/src/routes/css/font-face/+page.svelte delete mode 100644 packages/kit/test/apps/basics/src/routes/css/font-face/media/+page.svelte delete mode 100644 packages/kit/test/apps/basics/src/routes/css/font-face/media/media.css delete mode 100644 packages/kit/test/apps/basics/src/routes/css/font-face/server-only/+page.js delete mode 100644 packages/kit/test/apps/basics/src/routes/css/font-face/server-only/+page.svelte delete mode 100644 packages/kit/test/apps/basics/src/routes/css/font-face/server-only/server-only.css delete mode 100644 packages/kit/test/apps/basics/src/routes/css/font-face/styles.css delete mode 100644 packages/kit/test/apps/writes/src/routes/font-face/+page.svelte delete mode 100644 packages/kit/test/apps/writes/src/routes/font-face/fonts.js delete mode 100644 packages/kit/test/apps/writes/src/routes/font-face/styles.css diff --git a/packages/kit/src/exports/vite/dev/generate_manifest.js b/packages/kit/src/exports/vite/dev/generate_manifest.js index aedec811fd75..5d1c68d98bf9 100644 --- a/packages/kit/src/exports/vite/dev/generate_manifest.js +++ b/packages/kit/src/exports/vite/dev/generate_manifest.js @@ -6,7 +6,6 @@ import path from 'node:path'; import { get_mime_lookup, get_runtime_base } from '../../../core/utils.js'; import { from_fs, to_fs } from '../../../utils/vite.js'; import { compact } from '../../../utils/array.js'; -import { split_font_faces } from '../../../utils/css.js'; import * as e from '../../../messages/build-errors.js'; import { styleText } from 'node:util'; @@ -32,9 +31,6 @@ export function generate_manifest( root, get_remotes ) { - /** @type {Map }>} */ - const splits = new Map(); - return { app_dir: svelte_config.appDir, app_path: svelte_config.appDir, @@ -136,7 +132,7 @@ export function generate_manifest( // in dev we inline all styles to avoid FOUC. this gets populated lazily so that // components/stylesheets loaded via import() during `load` are included - result.dev_styles = async () => { + result.inline_styles = async () => { /** @type {Set} */ const deps = new Set(); @@ -144,8 +140,8 @@ export function generate_manifest( await find_deps(vite_dev_server, module_node, deps); } - /** @type {Awaited>>} */ - const styles = []; + /** @type {Record} */ + const styles = {}; for (const dep of deps) { if (vite.isCSSRequest(dep.url) && !vite_css_query_regex.test(dep.url)) { @@ -154,15 +150,7 @@ export function generate_manifest( : dep.url + '?inline'; try { const mod = await runner.import(inlineCssUrl); - const css = /** @type {string} */ (mod.default); - - let cached = splits.get(dep.url); - if (cached?.css !== css) { - cached = { css, split: split_font_faces(css) }; - splits.set(dep.url, cached); - } - - styles.push({ url: dep.url, css, split: cached.split }); + styles[dep.url] = mod.default; } catch { // this can happen with dynamically imported modules, I think // because the Vite module graph doesn't distinguish between diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 7dd560622847..0003277ba19d 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -901,20 +901,7 @@ async function initialize(result, target, should_hydrate) { // Removes the style node we used to avoid FOUC during development if (__SVELTEKIT_DEV__) { const style = document.querySelector('style[data-sveltekit]'); - const font_faces = style?.previousElementSibling; - - if (font_faces?.hasAttribute('data-sveltekit-font-faces')) { - if (declared_later(font_faces)) { - // Apply the styles Vite injected first. Taking in the removal at the same - // time makes the browser re-create the font faces that were kept - void getComputedStyle(document.documentElement).color; - watch_dev_font_faces(font_faces); - } else { - font_faces.remove(); - } - } - - style?.remove(); + if (style) style.remove(); } apply_navigation_result(result); @@ -3814,63 +3801,6 @@ if (DEV) { } } -/** @type {Set} */ -const dev_font_faces = new Set(); - -/** @type {MutationObserver | undefined} */ -let dev_font_faces_observer; - -/** - * Watches `@font-face` rules that were kept from the styles inlined during development (see - * `render_response`). They go when Vite's styles stop declaring them: updates rewrite those, - * and they leave the head when their module is pruned - * @param {Element} style - */ -function watch_dev_font_faces(style) { - dev_font_faces.add(style); - - if (dev_font_faces_observer) return; - - dev_font_faces_observer = new MutationObserver(check_dev_font_faces); - dev_font_faces_observer.observe(document.head, { childList: true }); - - if (import.meta.hot) { - import.meta.hot.on('vite:afterUpdate', check_dev_font_faces); - } -} - -function check_dev_font_faces() { - for (const style of dev_font_faces) { - if (declared_later(style)) continue; - - style.remove(); - dev_font_faces.delete(style); - } -} - -/** - * Whether each rule of a stylesheet is also declared by one of the styles that Vite injected - * after it and that always apply. They are children of the head, which is what's observed - * @param {Element} style - */ -function declared_later(style) { - /** @type {Set} */ - const declared = new Set(); - - for (const node of document.head.children) { - if (!(node instanceof HTMLStyleElement) || !node.hasAttribute('data-vite-dev-id')) continue; - if (!node.sheet || node.sheet.disabled || node.sheet.media.length > 0) continue; - if (!(style.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_FOLLOWING)) continue; - - for (const rule of node.sheet.cssRules) { - if (rule instanceof CSSFontFaceRule) declared.add(rule.cssText); - } - } - - const rules = /** @type {HTMLStyleElement} */ (style).sheet?.cssRules; - return !!rules?.length && Array.from(rules).every((rule) => declared.has(rule.cssText)); -} - /** * @param {NavigationFinished} result */ diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js index 22e3eef7cf7f..0a8042eb03fa 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -97,9 +97,6 @@ export async function render_response({ // TODO if we add a client entry point one day, we will need to include inline_styles with the entry, otherwise stylesheets will be linked even if they are below inlineStyleThreshold const inline_styles = new Map(); - /** @type {Map} */ - const dev_styles = new Map(); - /** @type {Omit} */ let rendered; @@ -264,10 +261,6 @@ export async function render_response({ for (const url of node.stylesheets) stylesheets.add(url); for (const font of node.fonts) fonts.set(font.file, font); - if (__SVELTEKIT_DEV__ && node.dev_styles) { - for (const { url, ...style } of await node.dev_styles()) dev_styles.set(url, style); - } - if (node.inline_styles && !client?.inline) { Object.entries(await node.inline_styles()).forEach(([filename, css]) => { if (typeof css === 'string') { @@ -286,45 +279,18 @@ export async function render_response({ /** @param {string} path */ const prefixed = (path) => client_path(path, { base, assets }); - let font_faces = ''; - - if (__SVELTEKIT_DEV__ && dev_styles.size > 0) { - // Moving the `@font-face` rules out of only some stylesheets could change which one wins - const split = Array.from(dev_styles.values()).every((style) => style.split); - - for (const [url, style] of dev_styles) { - if (split && style.split) { - if (style.split.font_faces) font_faces += (font_faces && '\n') + style.split.font_faces; - inline_styles.set(url, style.split.rest); - } else { - inline_styles.set(url, style.css); - } - } - } - const style = client?.inline ? client.inline?.style : Array.from(inline_styles.values()).join('\n'); - if (font_faces) { - // The client keeps these after hydration while Vite's styles declare the same ones. - // Removing a stylesheet that declares them makes the browser re-create the page's - // font faces and load their fonts again - const attributes = ['data-sveltekit-font-faces']; - if (csp.style_needs_nonce) attributes.push(`nonce="${csp.nonce}"`); - await csp.add_style(font_faces); - head.add_style(font_faces, attributes); - } - - // the client finds the `@font-face` rules above next to this, so it's there even if it's empty - if (style || font_faces) { + if (style) { // We always inline all styles to avoid FOUC during development. // Once that's accomplished, we find and remove the style node using the // `data-sveltekit` attribute once CSR kicks in const attributes = __SVELTEKIT_DEV__ ? ['data-sveltekit'] : []; if (csp.style_needs_nonce) attributes.push(`nonce="${csp.nonce}"`); - await csp.add_style(style ?? ''); - head.add_style(style ?? '', attributes); + await csp.add_style(style); + head.add_style(style, attributes); } /** diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index 9bd4579277a3..a5db4416a2dd 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -515,20 +515,15 @@ export interface SSRNode { server_id?: string; /** - * In production, this stores styles that are below the inline threshold. + * During development, all styles are inlined for the page to avoid FOUC. + * But in production, this stores styles that are below the inline threshold. + * It returns a Promise during development because Vite needs to load the + * modules on demand. But in production, the contents have been precomputed + * during the build, so it can return synchronously. */ inline_styles?(): MaybePromise< Record string)> >; - /** - * During development, all styles are inlined for the page to avoid FOUC. - * `split` holds their top-level `@font-face` rules separately, so that they - * can stay in the page after hydration, or is `null` if they can't be moved. - * It returns a Promise because Vite needs to load the modules on demand. - */ - dev_styles?(): Promise< - Array<{ url: string; css: string; split: { font_faces: string; rest: string } | null }> - >; /** Svelte component */ component?: SSRComponentLoader; /** +page.js or +layout.js */ diff --git a/packages/kit/src/utils/css.js b/packages/kit/src/utils/css.js index 9454653f1a93..b6c691b62d3e 100644 --- a/packages/kit/src/utils/css.js +++ b/packages/kit/src/utils/css.js @@ -124,313 +124,6 @@ export function fix_css_urls({ return s.toString(); } -/** - * Moves the top-level `@font-face` rules out of a stylesheet, reading it the way browsers tokenize - * CSS (https://drafts.csswg.org/css-syntax-3/#tokenization). Returns `null` if that could change - * what the stylesheet means: if it has a `@font-face` anywhere else (in `@media` or `@layer`, say), - * an `@import` or `@namespace`, a bad string or URL, or anything left open at the end, which would - * carry on into the next stylesheet when they're concatenated - * @param {string} css - * @returns {{ font_faces: string; rest: string } | null} - */ -export function split_font_faces(css) { - /** @type {number[]} the closing bracket of each open block */ - const closers = []; - - /** @type {Array<[number, number]>} */ - const font_faces = []; - - // where the current top-level rule starts (-1 between rules), and what it is - let start = -1; - let at_rule = false; - let font_face = false; - - let i = 0; - - while (i < css.length) { - const c = css.charCodeAt(i); - - if (is_whitespace(c)) { - i += 1; - continue; - } - - if (c === 0x2f && css.charCodeAt(i + 1) === 0x2a) { - const end = css.indexOf('*/', i + 2); - if (end === -1) return null; - i = end + 2; - continue; - } - - const top = closers.length === 0; - const begins = top && start === -1; - if (begins) { - start = i; - at_rule = false; - font_face = false; - } - - if (c === 0x22 || c === 0x27) { - i = skip_string(css, i); - if (i === -1) return null; - } else if (c === 0x40 && starts_ident(css, i + 1)) { - const end = skip_ident(css, i + 1); - const name = decode_ident(css.slice(i + 1, end)); - if (name === 'import' || name === 'namespace') return null; - if (name === 'font-face') { - if (!begins) return null; - font_face = true; - } - if (begins) at_rule = true; - i = end; - } else if (starts_number(css, i)) { - i = skip_number(css, i); - } else if (c === 0x23 && (is_ident_char(css.charCodeAt(i + 1)) || is_escape(css, i + 1))) { - i = skip_ident(css, i + 1); - } else if (starts_ident(css, i)) { - const end = skip_ident(css, i); - const name = css.charCodeAt(end) === 0x28 ? decode_ident(css.slice(i, end)) : null; - i = end; - - if (name === 'url') { - let j = i + 1; - while (is_whitespace(css.charCodeAt(j))) j += 1; - - const next = css.charCodeAt(j); - if (next === 0x22 || next === 0x27) { - closers.push(0x29); - i = j; - } else { - i = skip_url(css, j); - if (i === -1) return null; - } - } else if (name !== null) { - closers.push(0x29); - i += 1; - } - } else if (c === 0x7b || c === 0x5b || c === 0x28) { - closers.push(c === 0x7b ? 0x7d : c === 0x5b ? 0x5d : 0x29); - i += 1; - } else if (c === 0x7d || c === 0x5d || c === 0x29) { - if (top) return null; - - if (c === closers[closers.length - 1]) { - closers.pop(); - - if (closers.length === 0 && c === 0x7d) { - if (font_face) font_faces.push([start, i + 1]); - start = -1; - } - } - - i += 1; - } else if (c === 0x3b && top) { - if (!at_rule || font_face) return null; - start = -1; - i += 1; - } else { - i += 1; - } - } - - if (closers.length > 0 || start !== -1) return null; - - let rest = ''; - let last = 0; - - for (const [from, to] of font_faces) { - rest += css.slice(last, from); - last = to; - } - - return { - font_faces: font_faces.map(([from, to]) => css.slice(from, to)).join('\n'), - rest: rest + css.slice(last) - }; -} - -/** @param {number} c */ -function is_whitespace(c) { - return c === 0x20 || c === 0x09 || is_newline(c); -} - -/** @param {number} c */ -function is_newline(c) { - return c === 0x0a || c === 0x0d || c === 0x0c; -} - -/** @param {number} c */ -function is_hex(c) { - return (c >= 0x30 && c <= 0x39) || (c >= 0x41 && c <= 0x46) || (c >= 0x61 && c <= 0x66); -} - -/** @param {number} c */ -function is_digit(c) { - return c >= 0x30 && c <= 0x39; -} - -/** @param {number} c */ -function is_ident_start(c) { - // NULL is read as U+FFFD - return (c >= 0x41 && c <= 0x5a) || (c >= 0x61 && c <= 0x7a) || c === 0x5f || c >= 0x80 || c === 0; -} - -/** @param {number} c */ -function is_ident_char(c) { - return is_ident_start(c) || is_digit(c) || c === 0x2d; -} - -/** - * Whether a valid escape starts at `i` - * @param {string} css - * @param {number} i - */ -function is_escape(css, i) { - return css.charCodeAt(i) === 0x5c && !is_newline(css.charCodeAt(i + 1)); -} - -/** - * @param {string} css - * @param {number} i - */ -function starts_ident(css, i) { - const c = css.charCodeAt(i); - if (c === 0x2d) { - const next = css.charCodeAt(i + 1); - return is_ident_start(next) || next === 0x2d || is_escape(css, i + 1); - } - return is_ident_start(c) || is_escape(css, i); -} - -/** - * @param {string} css - * @param {number} i - */ -function starts_number(css, i) { - if (css.charCodeAt(i) === 0x2b || css.charCodeAt(i) === 0x2d) i += 1; - if (css.charCodeAt(i) === 0x2e) i += 1; - return is_digit(css.charCodeAt(i)); -} - -/** - * Returns the end of the escape whose backslash is at `i` - * @param {string} css - * @param {number} i - */ -function skip_escape(css, i) { - let j = i + 1; - while (j - i <= 6 && is_hex(css.charCodeAt(j))) j += 1; - if (j === i + 1) return j + 1; - if (css.charCodeAt(j) === 0x0d && css.charCodeAt(j + 1) === 0x0a) return j + 2; - return is_whitespace(css.charCodeAt(j)) ? j + 1 : j; -} - -/** - * @param {string} css - * @param {number} i - */ -function skip_ident(css, i) { - while (i < css.length) { - if (is_ident_char(css.charCodeAt(i))) i += 1; - else if (is_escape(css, i)) i = skip_escape(css, i); - else break; - } - return i; -} - -/** - * Returns an ident's value, with escapes decoded and ASCII letters lowercased - * @param {string} ident - */ -function decode_ident(ident) { - return ident - .replace(/\\(?:([0-9a-fA-F]{1,6})(?:\r\n|[ \t\n\r\f])?|([^]))/g, (_, hex, char) => { - if (char !== undefined) return char; - const code = parseInt(hex, 16); - return code === 0 || (code >= 0xd800 && code <= 0xdfff) || code > 0x10ffff - ? '�' - : String.fromCodePoint(code); - }) - .replace(/[A-Z]/g, (char) => char.toLowerCase()); -} - -/** - * Returns the end of the number (with its unit, if any) at `i` - * @param {string} css - * @param {number} i - */ -function skip_number(css, i) { - if (css.charCodeAt(i) === 0x2b || css.charCodeAt(i) === 0x2d) i += 1; - while (is_digit(css.charCodeAt(i))) i += 1; - if (css.charCodeAt(i) === 0x2e && is_digit(css.charCodeAt(i + 1))) { - i += 1; - while (is_digit(css.charCodeAt(i))) i += 1; - } - - const e = css.charCodeAt(i); - if (e === 0x45 || e === 0x65) { - const sign = css.charCodeAt(i + 1) === 0x2b || css.charCodeAt(i + 1) === 0x2d ? 1 : 0; - if (is_digit(css.charCodeAt(i + 1 + sign))) { - i += 1 + sign; - while (is_digit(css.charCodeAt(i))) i += 1; - } - } - - if (starts_ident(css, i)) return skip_ident(css, i); - return css.charCodeAt(i) === 0x25 ? i + 1 : i; -} - -/** - * Returns the end of the string whose quote is at `i`, or -1 if it's a bad or unclosed string - * @param {string} css - * @param {number} i - */ -function skip_string(css, i) { - const quote = css.charCodeAt(i); - i += 1; - - while (i < css.length) { - const c = css.charCodeAt(i); - if (c === quote) return i + 1; - if (is_newline(c)) return -1; - - if (c !== 0x5c) i += 1; - else if (css.charCodeAt(i + 1) === 0x0d && css.charCodeAt(i + 2) === 0x0a) i += 3; - else if (is_newline(css.charCodeAt(i + 1))) i += 2; - else i = skip_escape(css, i); - } - - return -1; -} - -/** - * Returns the end of the unquoted `url(` whose contents start at `i`, or -1 if it's a bad or - * unclosed URL - * @param {string} css - * @param {number} i - */ -function skip_url(css, i) { - while (i < css.length) { - const c = css.charCodeAt(i); - if (c === 0x29) return i + 1; - - if (is_whitespace(c)) { - while (is_whitespace(css.charCodeAt(i))) i += 1; - return css.charCodeAt(i) === 0x29 ? i + 1 : -1; - } - - // quotes, `(` and non-printable characters make it a bad URL (NULL is read as U+FFFD) - if (c === 0x22 || c === 0x27 || c === 0x28 || (c > 0 && c <= 0x08)) return -1; - if (c === 0x0b || (c >= 0x0e && c <= 0x1f) || c === 0x7f) return -1; - - if (c !== 0x5c) i += 1; - else if (is_escape(css, i)) i = skip_escape(css, i); - else return -1; - } - - return -1; -} - /** * @param {StyleSheetChildren[0]} rule * @param {(declaration: Declaration) => void} callback diff --git a/packages/kit/src/utils/css.spec.js b/packages/kit/src/utils/css.spec.js index dd242e28e315..c840187931cd 100644 --- a/packages/kit/src/utils/css.spec.js +++ b/packages/kit/src/utils/css.spec.js @@ -1,5 +1,5 @@ import { assert, describe, test } from 'vitest'; -import { fix_css_urls, split_font_faces, tippex_comments_and_strings } from './css.js'; +import { fix_css_urls, tippex_comments_and_strings } from './css.js'; describe('fix_css_urls', () => { const cdn_assets = 'https://cdn.example.com/_app/immutable/assets'; @@ -266,56 +266,3 @@ describe('tippex_comments_and_strings', () => { assert.equal(result.length, input.length, 'output length must equal input length'); }); }); - -describe('split_font_faces', () => { - test('leaves stylesheets without font faces alone', () => { - const css = '.a { color: red; }\n@media (min-width: 1px) { .b { color: blue; } }'; - assert.deepEqual(split_font_faces(css), { font_faces: '', rest: css }); - }); - - test('moves top-level font faces out', () => { - assert.deepEqual( - split_font_faces( - "@font-face { font-family: 'A'; src: url(a.woff2); }\n.a { font-family: 'A'; }" - ), - { - font_faces: "@font-face { font-family: 'A'; src: url(a.woff2); }", - rest: "\n.a { font-family: 'A'; }" - } - ); - }); - - test('recognises the at-rule however it is written', () => { - const css = - '@FONT-FACE{font-family:A}@font\\2d face{font-family:B}@\\66ont-face{font-family:C}'; - assert.deepEqual(split_font_faces(css), { font_faces: css.replace(/\}@/g, '}\n@'), rest: '' }); - }); - - test('reads comments, strings, URLs and escapes like a browser', () => { - const font_face = '@font-face { src: url(a}b.woff2), url("c}d.woff2"); font-family: "\\"}" }'; - const css = `/* @font-face { */ .a\\{ { content: "}" } .b { c: 1url(}) }\n${font_face}`; - assert.deepEqual(split_font_faces(css), { - font_faces: font_face, - rest: '/* @font-face { */ .a\\{ { content: "}" } .b { c: 1url(}) }\n' - }); - }); - - test.each([ - ['@media print { @font-face { font-family: A; } }'], - ['@layer base { @font-face { font-family: A; } }'], - ['.a { @font-face { font-family: A; } }'], - ['@import url(https://example.com/fonts.css);\n@font-face { font-family: A; }'], - ['@font-face { font-family: A; }\n@namespace svg url(http://www.w3.org/2000/svg);'], - [''], - ['.a { content: "b\n}" } @font-face { font-family: A; }'], - ['.a { b: url(c"d) } @font-face { font-family: A; }'], - ['#url(}) @font-face { font-family: A; }'], - ['} @font-face { font-family: A; }'], - ['@font-face;'], - ['@font-face { font-family: A; } .a { color: red'], - ['@font-face { font-family: A; } /* b'], - ['@font-face { font-family: A; } @layer b'] - ])('gives up on %j', (css) => { - assert.equal(split_font_faces(css), null); - }); -}); diff --git a/packages/kit/test/apps/basics/src/routes/css/font-face/+page.svelte b/packages/kit/test/apps/basics/src/routes/css/font-face/+page.svelte deleted file mode 100644 index 3585a993073c..000000000000 --- a/packages/kit/test/apps/basics/src/routes/css/font-face/+page.svelte +++ /dev/null @@ -1,5 +0,0 @@ - - -

this text is green

diff --git a/packages/kit/test/apps/basics/src/routes/css/font-face/media/+page.svelte b/packages/kit/test/apps/basics/src/routes/css/font-face/media/+page.svelte deleted file mode 100644 index 23e32ef705b2..000000000000 --- a/packages/kit/test/apps/basics/src/routes/css/font-face/media/+page.svelte +++ /dev/null @@ -1,6 +0,0 @@ - - -

this text is green

diff --git a/packages/kit/test/apps/basics/src/routes/css/font-face/media/media.css b/packages/kit/test/apps/basics/src/routes/css/font-face/media/media.css deleted file mode 100644 index addb645e8558..000000000000 --- a/packages/kit/test/apps/basics/src/routes/css/font-face/media/media.css +++ /dev/null @@ -1,6 +0,0 @@ -@media (min-width: 1px) { - @font-face { - font-family: 'Font Face In Media'; - src: url('../../../asset-preload/shlop.woff2') format('woff2'); - } -} diff --git a/packages/kit/test/apps/basics/src/routes/css/font-face/server-only/+page.js b/packages/kit/test/apps/basics/src/routes/css/font-face/server-only/+page.js deleted file mode 100644 index 99dfc67d3f8c..000000000000 --- a/packages/kit/test/apps/basics/src/routes/css/font-face/server-only/+page.js +++ /dev/null @@ -1,5 +0,0 @@ -import { browser } from '$app/env'; - -export async function load() { - if (!browser) await import('./server-only.css'); -} diff --git a/packages/kit/test/apps/basics/src/routes/css/font-face/server-only/+page.svelte b/packages/kit/test/apps/basics/src/routes/css/font-face/server-only/+page.svelte deleted file mode 100644 index be0f42a6d0ca..000000000000 --- a/packages/kit/test/apps/basics/src/routes/css/font-face/server-only/+page.svelte +++ /dev/null @@ -1,5 +0,0 @@ - - -

this text is green

diff --git a/packages/kit/test/apps/basics/src/routes/css/font-face/server-only/server-only.css b/packages/kit/test/apps/basics/src/routes/css/font-face/server-only/server-only.css deleted file mode 100644 index 640065e5281a..000000000000 --- a/packages/kit/test/apps/basics/src/routes/css/font-face/server-only/server-only.css +++ /dev/null @@ -1,4 +0,0 @@ -@font-face { - font-family: 'Server Only'; - src: url('../../../asset-preload/shlop.woff2') format('woff2'); -} diff --git a/packages/kit/test/apps/basics/src/routes/css/font-face/styles.css b/packages/kit/test/apps/basics/src/routes/css/font-face/styles.css deleted file mode 100644 index cfcb69e01e60..000000000000 --- a/packages/kit/test/apps/basics/src/routes/css/font-face/styles.css +++ /dev/null @@ -1,9 +0,0 @@ -@font-face { - font-family: 'Font Face'; - src: url('../../asset-preload/shlop.woff2') format('woff2'); -} - -.font-face { - color: green; - font-family: 'Font Face', sans-serif; -} diff --git a/packages/kit/test/apps/basics/test/playwright/test.js b/packages/kit/test/apps/basics/test/playwright/test.js index 4d30662add99..d7149aa3695d 100644 --- a/packages/kit/test/apps/basics/test/playwright/test.js +++ b/packages/kit/test/apps/basics/test/playwright/test.js @@ -719,122 +719,6 @@ test.describe('Nested layouts', () => { }); }); -test.describe('CSS', () => { - test('keeps the inlined @font-face rules through hydration in dev', async ({ - page, - javaScriptEnabled - }) => { - test.skip(!process.env.DEV || !javaScriptEnabled, 'styles are only inlined in dev'); - - // hold the page's stylesheet back until the font faces from the inlined styles have been - // recorded. This also makes Vite inject it in the same task as hydration - /** @type {() => void} */ - let start = () => {}; - const started = new Promise((fulfil) => (start = /** @type {() => void} */ (fulfil))); - await page.route(/\/css\/font-face\/styles\.css/, async (route) => { - await started; - await route.continue(); - }); - - await page.goto('/css/font-face', { waitUntil: 'domcontentloaded', wait_for_started: false }); - await page.evaluate(() => { - // @ts-expect-error - window.ssr_faces = Array.from(document.fonts).filter((face) => - face.family.includes('Font Face') - ); - }); - - start(); - await page.locator('body.started').waitFor(); - - expect( - await page.evaluate(() => ({ - // @ts-expect-error - faces: window.ssr_faces.length, - // re-created font faces would load their fonts again - // @ts-expect-error - kept: window.ssr_faces.every((face) => Array.from(document.fonts).includes(face)), - removed: document.querySelector('style[data-sveltekit]') === null, - color: getComputedStyle(/** @type {Element} */ (document.querySelector('.font-face'))).color - })) - ).toEqual({ faces: 1, kept: true, removed: true, color: 'rgb(0, 128, 0)' }); - }); - - test('removes the inlined @font-face rules the client does not declare in dev', async ({ - page, - javaScriptEnabled - }) => { - test.skip(!process.env.DEV || !javaScriptEnabled, 'styles are only inlined in dev'); - - // hold the client back to look at the server-rendered styles - /** @type {() => void} */ - let start = () => {}; - const started = new Promise((fulfil) => (start = /** @type {() => void} */ (fulfil))); - await page.route(/\/runtime\/client\/entry\.js/, async (route) => { - await started; - await route.continue(); - }); - - await page.goto('/css/font-face/server-only', { - waitUntil: 'domcontentloaded', - wait_for_started: false - }); - - // only the styles that Vite injects into the head, and that always apply, count as copies - // of the server-only face - expect( - await page.evaluate(() => { - const kept = /** @type {HTMLStyleElement} */ ( - document.querySelector('style[data-sveltekit-font-faces]') - ); - const font_face = Array.from(kept.sheet?.cssRules ?? []).find((rule) => - rule.cssText.includes('Server Only') - ); - - /** - * @param {HTMLElement} parent - * @param {string} [id] - */ - const copy = (parent, id) => { - const style = document.createElement('style'); - if (id) style.setAttribute('data-vite-dev-id', id); - style.textContent = font_face?.cssText ?? ''; - parent.append(style); - return style; - }; - - copy(document.head); - copy(document.body, 'body'); - - const print = copy(document.head, 'print'); - if (print.sheet) print.sheet.media.mediaText = 'print'; - - return font_face !== undefined; - }) - ).toBe(true); - - start(); - await page.locator('body.started').waitFor(); - - expect( - await page.evaluate(() => ({ - kept: document.querySelector('style[data-sveltekit-font-faces]') !== null, - color: getComputedStyle(/** @type {Element} */ (document.querySelector('.font-face'))).color - })) - ).toEqual({ kept: false, color: 'rgb(0, 128, 0)' }); - }); - - test('leaves the inlined styles whole if some @font-face rules cannot be moved in dev', async ({ - request - }) => { - test.skip(!process.env.DEV, 'styles are only inlined in dev'); - - const html = await (await request.get('/css/font-face/media')).text(); - expect(html).toContain('Font Face In Media'); - expect(html).not.toContain('data-sveltekit-font-faces'); - }); -}); - test.describe('Page options', () => { test('does not include - -

font face

diff --git a/packages/kit/test/apps/writes/src/routes/font-face/fonts.js b/packages/kit/test/apps/writes/src/routes/font-face/fonts.js deleted file mode 100644 index 46dea4530b1c..000000000000 --- a/packages/kit/test/apps/writes/src/routes/font-face/fonts.js +++ /dev/null @@ -1 +0,0 @@ -import './styles.css'; diff --git a/packages/kit/test/apps/writes/src/routes/font-face/styles.css b/packages/kit/test/apps/writes/src/routes/font-face/styles.css deleted file mode 100644 index d1343961db97..000000000000 --- a/packages/kit/test/apps/writes/src/routes/font-face/styles.css +++ /dev/null @@ -1,5 +0,0 @@ -@font-face { - font-family: 'Hot Face'; - src: url('/missing.woff2') format('woff2'); - unicode-range: U+0-FF; -} diff --git a/packages/kit/test/apps/writes/test/test.js b/packages/kit/test/apps/writes/test/test.js index 7054b2bf5e0c..06947b31e430 100644 --- a/packages/kit/test/apps/writes/test/test.js +++ b/packages/kit/test/apps/writes/test/test.js @@ -60,54 +60,6 @@ test.describe('Filesystem updates', () => { } }); - test('Font faces kept from the inlined styles follow HMR updates', async ({ - page, - javaScriptEnabled - }) => { - test.skip(!process.env.DEV || !javaScriptEnabled); - - const fonts_file = fileURLToPath(new URL('../src/routes/font-face/fonts.js', import.meta.url)); - const styles_file = fileURLToPath( - new URL('../src/routes/font-face/styles.css', import.meta.url) - ); - const fonts_contents = fs.readFileSync(fonts_file, 'utf-8'); - const styles_contents = fs.readFileSync(styles_file, 'utf-8'); - - const ranges = () => - page.evaluate(() => - Array.from(document.fonts) - .filter((face) => face.family.includes('Hot Face')) - .map((face) => face.unicodeRange) - ); - - try { - await page.goto('/font-face'); - expect(await page.locator('style[data-sveltekit-font-faces]').count()).toBe(1); - - // an edited face replaces the kept one - fs.writeFileSync(styles_file, styles_contents.replace('U+0-FF', 'U+41')); - await expect.poll(ranges).toEqual(['U+41']); - - fs.writeFileSync(styles_file, styles_contents); - await page.waitForTimeout(500); // this is the rare time we actually need waitForTimeout; we have no visibility into whether the module graph has been invalidated - await page.reload(); - expect(await page.locator('style[data-sveltekit-font-faces]').count()).toBe(1); - - // so does a stylesheet that is no longer imported, whatever comes after the kept - // style (here, the styles inlined for another app that doesn't hydrate) - await page.evaluate(() => { - const style = document.createElement('style'); - style.setAttribute('data-sveltekit', ''); - document.querySelector('style[data-sveltekit-font-faces]')?.after(style); - }); - fs.writeFileSync(fonts_file, 'export {};\n'); - await expect.poll(ranges).toEqual([]); - } finally { - fs.writeFileSync(fonts_file, fonts_contents); - fs.writeFileSync(styles_file, styles_contents); - } - }); - test('Universal node is updated when page options change', async ({ page, javaScriptEnabled, From 39e959896918a0df4fde2a63d1e823f553c0210d Mon Sep 17 00:00:00 2001 From: Serghei <43180231+xyrolle@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:37:15 +0300 Subject: [PATCH 3/7] fix: let Vite's client take over the styles inlined during development --- .changeset/keep-dev-styles-on-hydration.md | 2 +- .../src/exports/vite/dev/generate_manifest.js | 3 +- packages/kit/src/runtime/client/client.js | 6 --- .../kit/src/runtime/server/page/render.js | 29 +++++++++----- .../basics/test/playwright/client.test.js | 38 +++++++++++++++++++ .../test/apps/basics/test/playwright/test.js | 2 +- .../kit/test/apps/options/test/paths.test.js | 13 ++----- 7 files changed, 65 insertions(+), 28 deletions(-) diff --git a/.changeset/keep-dev-styles-on-hydration.md b/.changeset/keep-dev-styles-on-hydration.md index 9f1834eba5c5..7b49de4b7f2f 100644 --- a/.changeset/keep-dev-styles-on-hydration.md +++ b/.changeset/keep-dev-styles-on-hydration.md @@ -2,4 +2,4 @@ '@sveltejs/kit': patch --- -fix: keep the top-level `@font-face` rules of the styles inlined during development through hydration while Vite's styles declare the same rules, so that their fonts aren't loaded again +fix: let Vite's client take over the styles inlined during development instead of replacing them on hydration, which re-created their font faces diff --git a/packages/kit/src/exports/vite/dev/generate_manifest.js b/packages/kit/src/exports/vite/dev/generate_manifest.js index 5d1c68d98bf9..bf7c9548dd4c 100644 --- a/packages/kit/src/exports/vite/dev/generate_manifest.js +++ b/packages/kit/src/exports/vite/dev/generate_manifest.js @@ -150,7 +150,8 @@ export function generate_manifest( : dep.url + '?inline'; try { const mod = await runner.import(inlineCssUrl); - styles[dep.url] = mod.default; + // keyed by the id that Vite's client knows the module by (see `render_response`) + styles[dep.id ?? dep.url] = mod.default; } catch { // this can happen with dynamically imported modules, I think // because the Vite module graph doesn't distinguish between diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 0003277ba19d..f433b199bd94 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -898,12 +898,6 @@ async function initialize(result, target, should_hydrate) { nav }; - // Removes the style node we used to avoid FOUC during development - if (__SVELTEKIT_DEV__) { - const style = document.querySelector('style[data-sveltekit]'); - if (style) style.remove(); - } - apply_navigation_result(result); // TODO treeshake `hydrate` in csr mode diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js index 0a8042eb03fa..7c0575ca1596 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -279,18 +279,29 @@ export async function render_response({ /** @param {string} path */ const prefixed = (path) => client_path(path, { base, assets }); - const style = client?.inline - ? client.inline?.style - : Array.from(inline_styles.values()).join('\n'); - - if (style) { - // We always inline all styles to avoid FOUC during development. - // Once that's accomplished, we find and remove the style node using the - // `data-sveltekit` attribute once CSR kicks in - const attributes = __SVELTEKIT_DEV__ ? ['data-sveltekit'] : []; + /** + * @param {string} style + * @param {string[]} attributes + */ + const add_style = async (style, attributes) => { if (csp.style_needs_nonce) attributes.push(`nonce="${csp.nonce}"`); await csp.add_style(style); head.add_style(style, attributes); + }; + + if (__SVELTEKIT_DEV__) { + // We always inline all styles to avoid FOUC during development. Each one is marked with + // its module's id, so that Vite's client adopts it instead of adding a copy, and then + // updates it on HMR and removes it when the module is no longer imported + for (const [id, style] of inline_styles) { + await add_style(style, [`data-vite-dev-id="${escape_html(id, true)}"`]); + } + } else { + const style = client?.inline + ? client.inline?.style + : Array.from(inline_styles.values()).join('\n'); + + if (style) await add_style(style, []); } /** diff --git a/packages/kit/test/apps/basics/test/playwright/client.test.js b/packages/kit/test/apps/basics/test/playwright/client.test.js index 8e59d2f1a8a4..1ba9f12bf615 100644 --- a/packages/kit/test/apps/basics/test/playwright/client.test.js +++ b/packages/kit/test/apps/basics/test/playwright/client.test.js @@ -2818,3 +2818,41 @@ test.describe('routing', () => { await expect(page).toHaveURL((url) => url.pathname === '/routing'); }); }); + +test.describe('CSS', () => { + test('lets Vite take over the styles inlined during development', async ({ page }) => { + test.skip(!process.env.DEV, 'styles are only inlined during development'); + + // hold back the stylesheet's module, so that hydration waits until the inlined style is found + /** @type {import('@playwright/test').Route[]} */ + const held = []; + await page.route( + (url) => url.pathname.endsWith('/css/_styles.css'), + (route) => { + held.push(route); + } + ); + + await page.goto('/css', { waitUntil: 'commit', wait_for_started: false }); + + // `color: green` is only in `_styles.css` + const inlined = await page.waitForFunction(() => + Array.from(document.querySelectorAll('style')).find((style) => + style.textContent?.includes('color: green') + ) + ); + await expect.poll(() => held.length).toBe(1); + await held[0].continue(); + await page.locator('body.started').waitFor(); + + expect(await inlined.evaluate((style) => style?.isConnected)).toBe(true); + expect( + await page.evaluate( + () => + Array.from(document.querySelectorAll('style')).filter((style) => + style.textContent?.includes('color: green') + ).length + ) + ).toBe(1); + }); +}); diff --git a/packages/kit/test/apps/basics/test/playwright/test.js b/packages/kit/test/apps/basics/test/playwright/test.js index d7149aa3695d..3cd75a5d5a48 100644 --- a/packages/kit/test/apps/basics/test/playwright/test.js +++ b/packages/kit/test/apps/basics/test/playwright/test.js @@ -741,7 +741,7 @@ test.describe('Page options', () => { expect(await page.textContent('h1')).toBe('content was rendered'); } else { expect(await page.$('h1')).toBeNull(); - expect(await page.$('style[data-sveltekit]')).toBeNull(); + expect(await page.$('style[data-vite-dev-id]')).toBeNull(); } }); diff --git a/packages/kit/test/apps/options/test/paths.test.js b/packages/kit/test/apps/options/test/paths.test.js index 295b2b30b953..93c2009bf53f 100644 --- a/packages/kit/test/apps/options/test/paths.test.js +++ b/packages/kit/test/apps/options/test/paths.test.js @@ -172,18 +172,11 @@ test.describe('assets path', () => { }); test.describe('inlineStyleThreshold', () => { - test('inlines CSS', async ({ page, javaScriptEnabled }) => { + test('inlines CSS', async ({ page }) => { await page.goto('/path-base/base/'); if (process.env.DEV) { - const ssr_style = await page.$('style[data-sveltekit]'); - - if (javaScriptEnabled) { - //