Skip to content

Commit

Permalink
inject font preload links (#4963)
Browse files Browse the repository at this point in the history
* inject font preload links

* fix merge

* move + code style

* preload option, some docs

Co-authored-by: Simon Holthausen <simon.holthausen@vercel.com>
  • Loading branch information
Rich-Harris and dummdidumm committed Nov 16, 2022
1 parent 929e777 commit c15fecd
Show file tree
Hide file tree
Showing 9 changed files with 112 additions and 23 deletions.
5 changes: 5 additions & 0 deletions .changeset/odd-bears-greet.md
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': patch
---

[feat] preload fonts and add preload customization
4 changes: 3 additions & 1 deletion documentation/docs/30-advanced/20-hooks.md
Expand Up @@ -72,14 +72,16 @@ You can add call multiple `handle` functions with [the `sequence` helper functio

- `transformPageChunk(opts: { html: string, done: boolean }): MaybePromise<string | undefined>` — 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 `<head>` 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
/** @type {import('@sveltejs/kit').Handle} */
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;
Expand Down
8 changes: 7 additions & 1 deletion packages/kit/src/exports/vite/build/build_server.js
Expand Up @@ -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('../${
Expand All @@ -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 };`);
Expand All @@ -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[]} */
Expand Down
14 changes: 13 additions & 1 deletion packages/kit/src/exports/vite/build/utils.js
Expand Up @@ -45,6 +45,9 @@ export function find_deps(manifest, entry, add_dynamic_css) {
/** @type {Set<string>} */
const stylesheets = new Set();

/** @type {Set<string>} */
const fonts = new Set();

/**
* @param {string} current
* @param {boolean} add_js
Expand All @@ -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));
}
Expand All @@ -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)
};
}

Expand Down
4 changes: 3 additions & 1 deletion packages/kit/src/exports/vite/dev/index.js
Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand Down
12 changes: 9 additions & 3 deletions packages/kit/src/runtime/server/index.js
Expand Up @@ -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);
Expand Down Expand Up @@ -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
};

/**
Expand All @@ -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
};
}

Expand Down
60 changes: 44 additions & 16 deletions packages/kit/src/runtime/server/page/render.js
Expand Up @@ -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<string>} */
const link_header_preloads = new Set();
Expand Down Expand Up @@ -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));
}
Expand Down Expand Up @@ -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<link href="${path}" ${attributes.join(' ')}>`;
}
}

for (const dep of fonts) {
const path = prefixed(dep);

attributes.unshift('rel="stylesheet"');
head += `\n\t\t<link href="${path}" ${attributes.join(' ')}>`;
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<link ${attributes.join(' ')}>`;
}
}

if (page_config.csr) {
Expand All @@ -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<link rel="modulepreload" href="${path}">`;

if (resolve_opts.preload({ type: 'js', path })) {
link_header_preloads.add(`<${encodeURI(path)}>; rel="modulepreload"; nopush`);
if (state.prerendering) {
head += `\n\t\t<link rel="modulepreload" href="${path}">`;
}
}
}

Expand Down
25 changes: 25 additions & 0 deletions packages/kit/types/index.d.ts
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string | undefined>;
/**
* 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 `<head>` 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 {
Expand All @@ -616,6 +640,7 @@ export interface SSRManifest {
file: string;
imports: string[];
stylesheets: string[];
fonts: string[];
};
nodes: SSRNodeLoader[];
routes: SSRRoute[];
Expand Down
3 changes: 3 additions & 0 deletions packages/kit/types/internal.d.ts
Expand Up @@ -57,6 +57,7 @@ export interface BuildData {
file: string;
imports: string[];
stylesheets: string[];
fonts: string[];
};
vite_manifest: import('vite').Manifest;
};
Expand Down Expand Up @@ -255,6 +256,8 @@ export interface SSRNode {
imports: string[];
/** external CSS files */
stylesheets: string[];
/** external font files */
fonts: string[];
/** inlined styles */
inline_styles?(): MaybePromise<Record<string, string>>;

Expand Down

0 comments on commit c15fecd

Please sign in to comment.