Skip to content
5 changes: 5 additions & 0 deletions .changeset/lucky-moons-resolve.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': patch
---

chore: only generate each route's resolution module once when prerendering
5 changes: 5 additions & 0 deletions .changeset/tidy-owls-preload.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': major
---

breaking: `preloadCode` now takes a route ID (e.g. `/blog/[slug]`) instead of a pathname. Route IDs are not prefixed with `paths.base`
3 changes: 2 additions & 1 deletion packages/kit/src/core/postbuild/fallback.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ async function generate_fallback({ manifest_path, env, out_dir, origin, assets }
prerendering: {
fallback: true,
dependencies: new Map(),
remote_responses: new Map()
remote_responses: new Map(),
resolved_route_ids: new Set()
},
read: (file) => readFileSync(join(assets, file))
});
Expand Down
6 changes: 5 additions & 1 deletion packages/kit/src/core/postbuild/prerender.js
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,9 @@ async function prerender({ hash, out, manifest_path, metadata, verbose, env, vit
/** @type {Map<string, Promise<any>>} */
const remote_responses = new Map();

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

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

Expand Down Expand Up @@ -308,7 +311,8 @@ async function prerender({ hash, out, manifest_path, metadata, verbose, env, vit
},
prerendering: {
dependencies,
remote_responses
remote_responses,
resolved_route_ids
},
read: (file) => {
// stuff we just wrote
Expand Down
165 changes: 136 additions & 29 deletions packages/kit/src/runtime/client/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
scroll_state,
load_css
} from './utils.js';
import { base, set_match_implementation } from '$app/paths/internal/client';
import { base, app_dir, set_match_implementation } from '$app/paths/internal/client';
import * as devalue from 'devalue';
import {
HISTORY_INFO_KEY,
Expand All @@ -41,7 +41,11 @@ import {
import { get_message, get_status } from '../../utils/error.js';
import { page, navigating, updated, notify_version } from './state.svelte.js';
import { payload } from './payload.js';
import { add_data_suffix, add_resolution_suffix } from '../pathname.js';
import {
add_data_suffix,
add_resolution_suffix,
route_id_resolution_pathname
} from '../pathname.js';
import { noop_span } from '../telemetry/noop.js';
import { read_ndjson } from './ndjson.js';
import Root from '../components/root.svelte';
Expand Down Expand Up @@ -294,6 +298,35 @@ function discard_load_cache() {
*/
const reroute_cache = new Map();

/**
* Sentinel for a route that exists but has no code to preload, i.e. a `+server.js` with no
* `+page`. Cached like a parsed route so that repeated `preloadCode(id)` calls for the same
* id don't re-request it.
*/
const ENDPOINT_ONLY = Symbol('endpoint only');

/**
* Cache of route ID -> parsed route for server-side route resolution.
* Populated whenever a server route resolution occurs (`match`, link preloading,
* hydration), so that `preloadCode(id)` doesn't need an extra server round trip.
* Lives until full page reload.
* @type {Map<string, import('types').CSRRoute | typeof ENDPOINT_ONLY>}
*/
const route_id_cache = new Map();

/**
* Parse a server-provided route and record it in `route_id_cache`, so that a subsequent
* `preloadCode(id)` for the same route doesn't need another server round trip. Always use
* this rather than calling `parse_server_route` directly.
* @param {import('types').CSRRouteServer} server_route
* @returns {import('types').CSRRoute}
*/
function parse_and_cache_server_route(server_route) {
const route = parse_server_route(server_route, app.nodes);
route_id_cache.set(route.id, route);
return route;
}

/**
* Note on before_navigate_callbacks, on_navigate_callbacks and after_navigate_callbacks:
* do not re-assign as some closures keep references to these Sets
Expand Down Expand Up @@ -787,6 +820,53 @@ async function _preload_data(intent) {
return load_cache.promise;
}

/**
* Fetch and parse the route with the given ID from the server-side route resolution endpoint.
* Returns `ENDPOINT_ONLY` if the route exists but has no code to preload, or `undefined` if
* there is no such route. Only used when `router.resolution === 'server'`.
* @param {string} id
* @returns {Promise<import('types').CSRRoute | typeof ENDPOINT_ONLY | undefined>}
*/
async function load_route_by_id(id) {
/** @type {{ route?: import('types').CSRRouteServer, endpoint_only?: boolean }} */
let module;

try {
module = await import(
/* @vite-ignore */
base + route_id_resolution_pathname(app_dir, id)
);
} catch {
// if there's no module at that path the response is a 404 (or, on a static
// host, fallback HTML with the wrong MIME type) and the import rejects —
// treat it the same as an unknown route rather than surfacing a cryptic error
return;
}

if (module.endpoint_only) {
// The route exists, it just has no code to preload.
route_id_cache.set(id, ENDPOINT_ONLY);
return ENDPOINT_ONLY;
}

if (!module.route) return;

return parse_and_cache_server_route(module.route);
}

/**
* Import the modules for a route's layout and leaf nodes, without running `load` functions.
* @param {import('types').CSRRoute} route
* @returns {Promise<void>}
*/
async function load_route_nodes(route) {
await Promise.all(
/** @type {[has_server_load: boolean, node_loader: import('types').CSRPageNodeLoader][]} */ (
[...route.layouts, route.leaf].filter(Boolean)
).map(([, node_loader]) => node_loader())
);
}

/**
* @param {URL} url
* @returns {Promise<void>}
Expand All @@ -795,11 +875,7 @@ async function _preload_code(url) {
const route = (await get_navigation_intent(url, false))?.route;

if (route) {
await Promise.all(
/** @type {[has_server_load: boolean, node_loader: import('types').CSRPageNodeLoader][]} */ (
[...route.layouts, route.leaf].filter(Boolean)
).map((load) => load[1]())
);
await load_route_nodes(route);
}
}

Expand Down Expand Up @@ -1730,7 +1806,7 @@ export async function get_navigation_intent(url, invalidating) {
return {
id: get_page_key(url),
invalidating,
route: parse_server_route(route, app.nodes),
route: parse_and_cache_server_route(route),
params,
url
};
Expand Down Expand Up @@ -2657,45 +2733,76 @@ export async function preloadData(href) {
* Programmatically imports the code for routes that haven't yet been fetched.
* Typically, you might call this to speed up subsequent navigation.
*
* You can specify routes by any matching pathname such as `/about` (to match `src/routes/about/+page.svelte`) or `/blog/*` (to match `src/routes/blog/[slug]/+page.svelte`).
* Takes a route ID such as `/about` or `/blog/[slug]`. Unlike pathnames, route IDs
* are never prefixed with the app's [base path](https://svelte.dev/docs/kit/configuration#paths).
* If you have a pathname rather than a route ID, you can convert it with
* [`match`](https://svelte.dev/docs/kit/$app-paths#match) from `$app/paths`:
*
* ```js
* import { match } from '$app/paths';
* import { preloadCode } from '$app/navigation';
*
* const matched = await match('/blog/hello-world');
* if (matched) await preloadCode(matched.id);
* ```
*
* Unlike `preloadData`, this won't call `load` functions.
* Returns a Promise that resolves when the modules have been imported.
*
* @param {string} pathname
* @param {RouteId} id
* @returns {Promise<void>}
*/
export async function preloadCode(pathname) {
export async function preloadCode(id) {
if (!BROWSER) {
throw new Error('Cannot call preloadCode(...) on the server');
}

// `current.url` is null until the first navigation/hydration completes, so fall back
// to `location` to support calling `preloadCode` during initial page load (#13297)
const url = new URL(pathname, current.url ?? location.href);
if (DEV && id[0] !== '/') {
throw new Error(
`argument passed to preloadCode must be a route ID (i.e. "/blog/[slug]" rather than "blog/[slug]")`
);
}

if (DEV) {
if (!pathname.startsWith('/')) {
throw new Error(
'argument passed to preloadCode must be a pathname (i.e. "/about" rather than "http://example.com/about"'
);
}
const route = __SVELTEKIT_CLIENT_ROUTING__
? routes.find((r) => r.id === id)
: (route_id_cache.get(id) ?? (await load_route_by_id(id)));

if (!pathname.startsWith(base)) {
throw new Error(
`pathname passed to preloadCode must start with \`paths.base\` (i.e. "${base}${pathname}" rather than "${pathname}")`
if (route === ENDPOINT_ONLY) {
if (DEV) {
console.warn(
`'${id}' has no \`+page\`, so there is no code to preload. If you meant to warm up an ` +
`endpoint, request it with \`fetch\` instead.`
);
}

if (__SVELTEKIT_CLIENT_ROUTING__) {
const rerouted = await get_rerouted_url(url);
if (!rerouted || !routes.find((route) => route.exec(get_url_path(rerouted)))) {
throw new Error(`'${pathname}' did not match any routes`);
return;
}

if (!route) {
if (DEV) {
// warn rather than throw, since under client routing an endpoint-only route id is
// indistinguishable from a typo — the client manifest only contains routes with a `+page`
let message = `'${id}' did not match any route`;

if (__SVELTEKIT_CLIENT_ROUTING__) {
message += ` (note that routes without a \`+page\` have no code to preload)`;

// the most common migration mistake is passing a pathname, which used to work
const candidates = [id];
if (base && id.startsWith(base)) candidates.push(id.slice(base.length) || '/');

if (candidates.some((path) => routes.some((r) => r.exec(path)))) {
message += `. It does match as a pathname — use \`match(...)\` from \`$app/paths\` to convert a pathname into a route ID`;
}
}

console.warn(message);
}

return;
}

return _preload_code(url);
await load_route_nodes(route);
}

/**
Expand Down Expand Up @@ -3346,7 +3453,7 @@ async function _hydrate(
} else {
// undefined in case of 404
if (server_route) {
parsed_route = route = parse_server_route(server_route, app.nodes);
parsed_route = route = parse_and_cache_server_route(server_route);
} else {
route = { id: null };
params = {};
Expand Down
37 changes: 37 additions & 0 deletions packages/kit/src/runtime/pathname.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,40 @@ export function add_resolution_suffix(pathname) {
export function strip_resolution_suffix(pathname) {
return pathname.slice(0, -ROUTE_SUFFIX.length);
}

const ROUTES_PREFIX = '/routes';

/**
* The pathname of the route-ID-keyed resolution module for a given route ID,
* e.g. `/_app/routes/blog/[slug]/__route.js` (before prefixing with `base`).
* @param {string} app_dir
* @param {string} route_id
* @returns {string}
*/
export function route_id_resolution_pathname(app_dir, route_id) {
return add_resolution_suffix(`/${app_dir}${ROUTES_PREFIX}${route_id === '/' ? '' : route_id}`);
}

/**
* Whether a pathname (with the `/__route.js` suffix already stripped, and `base` NOT yet stripped)
* is a route-ID resolution request rather than a pathname resolution request.
* @param {string} pathname
* @param {string} base
* @param {string} app_dir
* @returns {boolean}
*/
export function is_route_id_resolution_path(pathname, base, app_dir) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should import base and app_dir rather than passing them around. Slightly annoying to do on this PR though since this module can be imported from server or client, and we don't have an #app/paths/internal module that points to server/client implementations of a module exposing base and app_dir (we do have $app/paths/internal/(server|client) but that's something slightly different, which in any case should probably be replaced with a subpath import), so I'll add it to the long list of things I want to tidy up at some point

const prefix = `${base}/${app_dir}${ROUTES_PREFIX}`;
return pathname === prefix || pathname.startsWith(prefix + '/');
}

/**
* Extract the route ID from a decoded, base-stripped, suffix-stripped pathname,
* e.g. `/_app/routes/blog/[slug]` -> `/blog/[slug]`, `/_app/routes` -> `/`.
* @param {string} pathname
* @param {string} app_dir
* @returns {string}
*/
export function extract_route_id(pathname, app_dir) {
return pathname.slice(`/${app_dir}${ROUTES_PREFIX}`.length) || '/';
}
20 changes: 19 additions & 1 deletion packages/kit/src/runtime/server/page/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ import { uneval_action_response } from './actions.js';
import { SVELTE_KIT_ASSETS } from '../../../constants.js';
import { SCHEME } from '../../../utils/url.js';
import { create_server_routing_response, generate_route_object } from './server_routing.js';
import { add_data_suffix, add_resolution_suffix } from '../../pathname.js';
import {
add_data_suffix,
add_resolution_suffix,
route_id_resolution_pathname
} from '../../pathname.js';
import { try_get_request_store, with_request_store } from '@sveltejs/kit/internal/server';
import { text_encoder } from '../../utils.js';
import { count_non_ssi_comments, create_replacer, get_global_name } from '../utils.js';
Expand Down Expand Up @@ -390,6 +394,20 @@ export async function render_response({
pathname,
create_server_routing_response(route, event.params, new URL(pathname, event.url), client)
);

// Prerender a route-ID-keyed `/_app/routes/<id>/__route.js` module alongside the
// pathname-keyed one above, so that `preloadCode(id)` can resolve a route ID without
// hitting the server.
if (route && !state.prerendering.resolved_route_ids.has(route.id)) {
state.prerendering.resolved_route_ids.add(route.id);

const id_pathname = paths.base + route_id_resolution_pathname(paths.app_dir, route.id);

state.prerendering.dependencies.set(
Comment thread
elliott-with-the-longest-name-on-github marked this conversation as resolved.
id_pathname,
create_server_routing_response(route, null, new URL(id_pathname, event.url), client)
);
}
}

const blocks = [];
Expand Down
Loading
Loading