diff --git a/.changeset/odd-bears-greet.md b/.changeset/odd-bears-greet.md new file mode 100644 index 000000000000..2312fd0ce210 --- /dev/null +++ b/.changeset/odd-bears-greet.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +[feat] preload fonts and add preload customization diff --git a/documentation/docs/30-advanced/20-hooks.md b/documentation/docs/30-advanced/20-hooks.md index 727716f830d1..221d73d91174 100644 --- a/documentation/docs/30-advanced/20-hooks.md +++ b/documentation/docs/30-advanced/20-hooks.md @@ -72,6 +72,7 @@ You can add call multiple `handle` functions with [the `sequence` helper functio - `transformPageChunk(opts: { html: string, done: boolean }): MaybePromise` — applies custom transforms to HTML. If `done` is true, it's the final chunk. Chunks are not guaranteed to be well-formed HTML (they could include an element's opening tag but not its closing tag, for example) but they will always be split at sensible boundaries such as `%sveltekit.head%` or layout/page components. - `filterSerializedResponseHeaders(name: string, value: string): boolean` — determines which headers should be included in serialized responses when a `load` function loads a resource with `fetch`. By default, none will be included. +- `preload(input: { type: 'js' | 'css' | 'font' | 'asset', path: string }): boolean` — determines what should be added to the `` tag to preload it. Preloading can improve performance because things are downloaded sooner, but they can also hurt core web vitals because too many things may be downloaded unnecessarily. By default, `js`, `css` and `font` files will be preloaded. `asset` files are not preloaded at all currently, but we may add this later after evaluating feedback. ```js /// file: src/hooks.server.js @@ -79,7 +80,8 @@ You can add call multiple `handle` functions with [the `sequence` helper functio export async function handle({ event, resolve }) { const response = await resolve(event, { transformPageChunk: ({ html }) => html.replace('old', 'new'), - filterSerializedResponseHeaders: (name) => name.startsWith('x-') + filterSerializedResponseHeaders: (name) => name.startsWith('x-'), + preload: ({ type, path }) => type === 'js' || path.includes('/important/') }); return response; diff --git a/packages/kit/src/exports/vite/build/build_server.js b/packages/kit/src/exports/vite/build/build_server.js index 9ad9d3786293..0af93efd8ea8 100644 --- a/packages/kit/src/exports/vite/build/build_server.js +++ b/packages/kit/src/exports/vite/build/build_server.js @@ -283,11 +283,15 @@ export async function build_server(options, client) { /** @type {string[]} */ const stylesheets = []; + /** @type {string[]} */ + const fonts = []; + if (node.component) { const entry = find_deps(client.vite_manifest, node.component, true); imported.push(...entry.imports); stylesheets.push(...entry.stylesheets); + fonts.push(...entry.fonts); exports.push( `export const component = async () => (await import('../${ @@ -302,6 +306,7 @@ export async function build_server(options, client) { imported.push(...entry.imports); stylesheets.push(...entry.stylesheets); + fonts.push(...entry.fonts); imports.push(`import * as shared from '../${vite_manifest[node.shared].file}';`); exports.push(`export { shared };`); @@ -314,7 +319,8 @@ export async function build_server(options, client) { exports.push( `export const imports = ${s(imported)};`, - `export const stylesheets = ${s(stylesheets)};` + `export const stylesheets = ${s(stylesheets)};`, + `export const fonts = ${s(fonts)};` ); /** @type {string[]} */ diff --git a/packages/kit/src/exports/vite/build/utils.js b/packages/kit/src/exports/vite/build/utils.js index 9f51ee9a83b4..45de51ac73e8 100644 --- a/packages/kit/src/exports/vite/build/utils.js +++ b/packages/kit/src/exports/vite/build/utils.js @@ -45,6 +45,9 @@ export function find_deps(manifest, entry, add_dynamic_css) { /** @type {Set} */ const stylesheets = new Set(); + /** @type {Set} */ + const fonts = new Set(); + /** * @param {string} current * @param {boolean} add_js @@ -57,6 +60,14 @@ export function find_deps(manifest, entry, add_dynamic_css) { if (add_js) imports.add(chunk.file); + if (chunk.assets) { + for (const asset of chunk.assets) { + if (/\.(woff2?|ttf|otf)$/.test(asset)) { + fonts.add(asset); + } + } + } + if (chunk.css) { chunk.css.forEach((file) => stylesheets.add(file)); } @@ -77,7 +88,8 @@ export function find_deps(manifest, entry, add_dynamic_css) { return { file: chunk.file, imports: Array.from(imports), - stylesheets: Array.from(stylesheets) + stylesheets: Array.from(stylesheets), + fonts: Array.from(fonts) }; } diff --git a/packages/kit/src/exports/vite/dev/index.js b/packages/kit/src/exports/vite/dev/index.js index 541547f6d5ed..6944f30dd952 100644 --- a/packages/kit/src/exports/vite/dev/index.js +++ b/packages/kit/src/exports/vite/dev/index.js @@ -65,7 +65,8 @@ export async function dev(vite, vite_config, svelte_config) { entry: { file: `/@fs${runtime_prefix}/client/start.js`, imports: [], - stylesheets: [] + stylesheets: [], + fonts: [] }, nodes: manifest_data.nodes.map((node, index) => { return async () => { @@ -80,6 +81,7 @@ export async function dev(vite, vite_config, svelte_config) { // these are unused in dev, it's easier to include them result.imports = []; result.stylesheets = []; + result.fonts = []; if (node.component) { result.component = async () => { diff --git a/packages/kit/src/runtime/server/index.js b/packages/kit/src/runtime/server/index.js index d0e9ab2dced0..397f28086662 100644 --- a/packages/kit/src/runtime/server/index.js +++ b/packages/kit/src/runtime/server/index.js @@ -20,11 +20,15 @@ import { Redirect } from '../control.js'; /* global __SVELTEKIT_ADAPTER_NAME__ */ -/** @param {{ html: string }} opts */ +/** @type {import('types').RequiredResolveOptions['transformPageChunk']} */ const default_transform = ({ html }) => html; +/** @type {import('types').RequiredResolveOptions['filterSerializedResponseHeaders']} */ const default_filter = () => false; +/** @type {import('types').RequiredResolveOptions['preload']} */ +const default_preload = ({ type }) => type !== 'asset'; + /** @type {import('types').Respond} */ export async function respond(request, options, state) { let url = new URL(request.url); @@ -185,7 +189,8 @@ export async function respond(request, options, state) { /** @type {import('types').RequiredResolveOptions} */ let resolve_opts = { transformPageChunk: default_transform, - filterSerializedResponseHeaders: default_filter + filterSerializedResponseHeaders: default_filter, + preload: default_preload }; /** @@ -211,7 +216,8 @@ export async function respond(request, options, state) { resolve_opts = { transformPageChunk: opts.transformPageChunk || default_transform, - filterSerializedResponseHeaders: opts.filterSerializedResponseHeaders || default_filter + filterSerializedResponseHeaders: opts.filterSerializedResponseHeaders || default_filter, + preload: opts.preload || default_preload }; } diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js index 43e030216f8f..cd0e6a329eb1 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -55,6 +55,7 @@ export async function render_response({ const stylesheets = new Set(entry.stylesheets); const modulepreloads = new Set(entry.imports); + const fonts = new Set(options.manifest._.entry.fonts); /** @type {Set} */ const link_header_preloads = new Set(); @@ -129,6 +130,10 @@ export async function render_response({ node.stylesheets.forEach((url) => stylesheets.add(url)); } + if (node.fonts) { + node.fonts.forEach((url) => fonts.add(url)); + } + if (node.inline_styles) { Object.entries(await node.inline_styles()).forEach(([k, v]) => inline_styles.set(k, v)); } @@ -219,23 +224,43 @@ export async function render_response({ for (const dep of stylesheets) { const path = prefixed(dep); - const attributes = []; - if (csp.style_needs_nonce) { - attributes.push(`nonce="${csp.nonce}"`); - } + if (resolve_opts.preload({ type: 'css', path })) { + const attributes = []; + + if (csp.style_needs_nonce) { + attributes.push(`nonce="${csp.nonce}"`); + } - if (inline_styles.has(dep)) { - // don't load stylesheets that are already inlined - // include them in disabled state so that Vite can detect them and doesn't try to add them - attributes.push('disabled', 'media="(max-width: 0)"'); - } else { - const preload_atts = ['rel="preload"', 'as="style"'].concat(attributes); - link_header_preloads.add(`<${encodeURI(path)}>; ${preload_atts.join(';')}; nopush`); + if (inline_styles.has(dep)) { + // don't load stylesheets that are already inlined + // include them in disabled state so that Vite can detect them and doesn't try to add them + attributes.push('disabled', 'media="(max-width: 0)"'); + } else { + const preload_atts = ['rel="preload"', 'as="style"'].concat(attributes); + link_header_preloads.add(`<${encodeURI(path)}>; ${preload_atts.join(';')}; nopush`); + } + + attributes.unshift('rel="stylesheet"'); + head += `\n\t\t`; } + } + + for (const dep of fonts) { + const path = prefixed(dep); - attributes.unshift('rel="stylesheet"'); - head += `\n\t\t`; + if (resolve_opts.preload({ type: 'font', path })) { + const ext = dep.slice(dep.lastIndexOf('.') + 1); + const attributes = [ + 'rel="preload"', + 'as="font"', + `type="font/${ext}"`, + `href="${path}"`, + 'crossorigin' + ]; + + head += `\n\t\t`; + } } if (page_config.csr) { @@ -262,9 +287,12 @@ export async function render_response({ for (const dep of modulepreloads) { const path = prefixed(dep); - link_header_preloads.add(`<${encodeURI(path)}>; rel="modulepreload"; nopush`); - if (state.prerendering) { - head += `\n\t\t`; + + if (resolve_opts.preload({ type: 'js', path })) { + link_header_preloads.add(`<${encodeURI(path)}>; rel="modulepreload"; nopush`); + if (state.prerendering) { + head += `\n\t\t`; + } } } diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index c73ec17bdd90..e11258861278 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -238,6 +238,12 @@ export interface KitConfig { }; } +/** + * This function runs every time the SvelteKit server receives a [request](https://kit.svelte.dev/docs/web-standards#fetch-apis-request) and + * determines the [response](https://kit.svelte.dev/docs/web-standards#fetch-apis-response). + * It receives an `event` object representing the request and a function called `resolve`, which renders the route and generates a `Response`. + * This allows you to modify response headers or bodies, or bypass SvelteKit entirely (for implementing routes programmatically, for example). + */ export interface Handle { (input: { event: RequestEvent; @@ -590,8 +596,26 @@ export interface RequestHandler< } export interface ResolveOptions { + /** + * Applies custom transforms to HTML. If `done` is true, it's the final chunk. Chunks are not guaranteed to be well-formed HTML + * (they could include an element's opening tag but not its closing tag, for example) + * but they will always be split at sensible boundaries such as `%sveltekit.head%` or layout/page components. + * @param input the html chunk and the info if this is the last chunk + */ transformPageChunk?(input: { html: string; done: boolean }): MaybePromise; + /** + * Determines which headers should be included in serialized responses when a `load` function loads a resource with `fetch`. + * By default, none will be included. + * @param name header name + * @param value header value + */ filterSerializedResponseHeaders?(name: string, value: string): boolean; + /** + * Determines what should be added to the `` tag to preload it. + * By default, `js`, `css` and `font` files will be preloaded. + * @param input the type of the file and its path + */ + preload?(input: { type: 'font' | 'css' | 'js' | 'asset'; path: string }): boolean; } export class Server { @@ -616,6 +640,7 @@ export interface SSRManifest { file: string; imports: string[]; stylesheets: string[]; + fonts: string[]; }; nodes: SSRNodeLoader[]; routes: SSRRoute[]; diff --git a/packages/kit/types/internal.d.ts b/packages/kit/types/internal.d.ts index 1a42a20605ba..1edb814bd9ad 100644 --- a/packages/kit/types/internal.d.ts +++ b/packages/kit/types/internal.d.ts @@ -57,6 +57,7 @@ export interface BuildData { file: string; imports: string[]; stylesheets: string[]; + fonts: string[]; }; vite_manifest: import('vite').Manifest; }; @@ -255,6 +256,8 @@ export interface SSRNode { imports: string[]; /** external CSS files */ stylesheets: string[]; + /** external font files */ + fonts: string[]; /** inlined styles */ inline_styles?(): MaybePromise>;