diff --git a/.changeset/nervous-trees-listen.md b/.changeset/nervous-trees-listen.md new file mode 100644 index 000000000000..64748db738a9 --- /dev/null +++ b/.changeset/nervous-trees-listen.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +[feat] Avoid running load on the server unnecessarily diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 214d8843ccb5..5a86ff58d570 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -16,10 +16,13 @@ const INDEX_KEY = 'sveltekit:index'; const routes = parse(nodes, dictionary, matchers); +const default_layout_loader = nodes[0]; +const default_error_loader = nodes[1]; + // we import the root layout/error nodes eagerly, so that // connectivity errors after initialisation don't nuke the app -const default_layout = nodes[0](); -const default_error = nodes[1](); +default_layout_loader(); +default_error_loader(); // We track the scroll position associated with each history entry in sessionStorage, // rather than on history.state itself, because when navigation is driven by @@ -463,62 +466,57 @@ export function create_client({ target, base, trailing_slash }) { * If `server_data` is passed, this is treated as the initial run and the page endpoint is not requested. * * @param {{ - * node: import('types').CSRPageNode; + * loader: import('types').CSRPageNodeLoader; * parent: () => Promise>; * url: URL; * params: Record; * routeId: string | null; - * server_data: Record | null; + * server_data_node: import('./types').DataNode | null; * }} options * @returns {Promise} */ - async function load_node({ node, parent, url, params, routeId, server_data }) { + async function load_node({ loader, parent, url, params, routeId, server_data_node }) { + /** @type {Record | null} */ + let data = null; + + /** @type {import('types').Uses} */ const uses = { - params: new Set(), - url: false, dependencies: new Set(), - parent: false + params: new Set(), + parent: false, + url: false }; - /** @param {string[]} deps */ - function depends(...deps) { - for (const dep of deps) { - const { href } = new URL(dep, url); - uses.dependencies.add(href); - } - } - - /** @type {Record | null} */ - let data = null; + const node = await loader(); - if (node.server) { - // +page|layout.server.js data means we need to mark this URL as a dependency of itself, - // unless we want to get clever with usage detection on the server, which could - // be returned to the client either as payload or custom headers - uses.dependencies.add(url.href); - uses.url = true; - } + if (node.shared?.load) { + /** @param {string[]} deps */ + function depends(...deps) { + for (const dep of deps) { + const { href } = new URL(dep, url); + uses.dependencies.add(href); + } + } - /** @type {Record} */ - const uses_params = {}; - for (const key in params) { - Object.defineProperty(uses_params, key, { - get() { - uses.params.add(key); - return params[key]; - }, - enumerable: true - }); - } + /** @type {Record} */ + const uses_params = {}; + for (const key in params) { + Object.defineProperty(uses_params, key, { + get() { + uses.params.add(key); + return params[key]; + }, + enumerable: true + }); + } - const load_url = new LoadURL(url); + const load_url = new LoadURL(url); - if (node.shared?.load) { /** @type {import('types').LoadEvent} */ const load_input = { routeId, params: uses_params, - data: server_data, + data: server_data_node?.data ?? null, get url() { uses.url = true; return load_url; @@ -564,11 +562,9 @@ export function create_client({ target, base, trailing_slash }) { }, setHeaders: () => {}, // noop depends, - get parent() { - // uses.parent assignment here, not on method inokation, else we wouldn't notice when someone - // does await parent() inside an if branch which wasn't executed yet. + parent() { uses.parent = true; - return parent; + return parent(); } }; @@ -614,11 +610,55 @@ export function create_client({ target, base, trailing_slash }) { return { node, - data: data || server_data, - uses + loader, + server: server_data_node, + shared: node.shared?.load ? { type: 'data', data, uses } : null, + data: data ?? server_data_node?.data ?? null }; } + /** + * @param {import('types').Uses | undefined} uses + * @param {boolean} parent_changed + * @param {{ url: boolean, params: string[] }} changed + */ + function has_changed(changed, parent_changed, uses) { + if (!uses) return false; + + if (uses.parent && parent_changed) return true; + if (changed.url && uses.url) return true; + + for (const param of changed.params) { + if (uses.params.has(param)) return true; + } + + for (const dep of uses.dependencies) { + if (invalidated.some((fn) => fn(dep))) return true; + } + + return false; + } + + /** + * @param {import('types').ServerDataNode | import('types').ServerDataSkippedNode | null} node + * @returns {import('./types').DataNode | null} + */ + function create_data_node(node) { + if (node?.type === 'data') { + return { + type: 'data', + data: node.data, + uses: { + dependencies: new Set(node.uses.dependencies ?? []), + params: new Set(node.uses.params ?? []), + parent: !!node.uses.parent, + url: !!node.uses.url + } + }; + } + return null; + } + /** * @param {import('./types').NavigationIntent} intent * @returns {Promise} @@ -640,89 +680,95 @@ export function create_client({ target, base, trailing_slash }) { // to act on the failures at this point) [...errors, ...layouts, leaf].forEach((loader) => loader?.().catch(() => {})); - const nodes = [...layouts, leaf]; + const loaders = [...layouts, leaf]; // To avoid waterfalls when someone awaits a parent, compute as much as possible here already - /** @type {boolean[]} */ - const nodes_changed_since_last_render = []; - for (let i = 0; i < nodes.length; i++) { - if (!nodes[i]) { - nodes_changed_since_last_render.push(false); - } else { - const previous = current.branch[i]; - const changed_since_last_render = - !previous || - (changed.url && previous.uses.url) || - changed.params.some((param) => previous.uses.params.has(param)) || - Array.from(previous.uses.dependencies).some((dep) => invalidated.some((fn) => fn(dep))) || - (previous.uses.parent && nodes_changed_since_last_render.includes(true)); - nodes_changed_since_last_render.push(changed_since_last_render); - } - } - /** @type {import('./types').ServerDataPayload | null} */ - let server_data_payload = null; + /** @type {import('types').ServerData | null} */ + let server_data = null; + + const invalid_server_nodes = loaders.reduce((acc, loader, i) => { + const previous = current.branch[i]; + const invalid = + loader && + (previous?.loader !== loader || + has_changed(changed, acc.some(Boolean), previous.server?.uses)); - if (route.uses_server_data) { + acc.push(invalid); + return acc; + }, /** @type {boolean[]} */ ([])); + + if (route.uses_server_data && invalid_server_nodes.some(Boolean)) { try { const res = await native_fetch( - `${url.pathname}${url.pathname.endsWith('/') ? '' : '/'}__data.json${url.search}` + `${url.pathname}${url.pathname.endsWith('/') ? '' : '/'}__data.json${url.search}`, + { + headers: { + 'x-sveltekit-invalidated': invalid_server_nodes.map((x) => (x ? '1' : '')).join(',') + } + } ); - server_data_payload = /** @type {import('./types').ServerDataPayload} */ (await res.json()); + server_data = /** @type {import('types').ServerData} */ (await res.json()); if (!res.ok) { - throw server_data_payload; + throw server_data; } } catch (e) { - throw new Error('TODO render fallback error page'); + // something went catastrophically wrong — bail and defer to the server + native_navigation(url); + return; } - if (server_data_payload.type === 'redirect') { - return server_data_payload; + if (server_data.type === 'redirect') { + return server_data; } } - const server_data_nodes = server_data_payload?.nodes; + const server_data_nodes = server_data?.nodes; - const branch_promises = nodes.map(async (loader, i) => { - return Promise.resolve().then(async () => { - if (!loader) return; - const node = await loader(); + let parent_changed = false; - /** @type {import('./types').BranchNode | undefined} */ - const previous = current.branch[i]; - const changed_since_last_render = - nodes_changed_since_last_render[i] || !previous || node !== previous.node; + const branch_promises = loaders.map(async (loader, i) => { + if (!loader) return; - if (changed_since_last_render) { - const payload = server_data_nodes?.[i]; + /** @type {import('./types').BranchNode | undefined} */ + const previous = current.branch[i]; - if (payload?.status) { - throw error(payload.status, payload.message); - } + const server_data_node = server_data_nodes?.[i] ?? null; - if (payload?.error) { - throw payload.error; - } + const can_reuse_server_data = !server_data_node || server_data_node.type === 'skip'; + // re-use data from previous load if it's still valid + const valid = + can_reuse_server_data && + loader === previous?.loader && + !has_changed(changed, parent_changed, previous.shared?.uses); + if (valid) return previous; - return await load_node({ - node, - url, - params, - routeId: route.id, - parent: async () => { - const data = {}; - for (let j = 0; j < i; j += 1) { - Object.assign(data, (await branch_promises[j])?.data); - } - return data; - }, - server_data: payload?.data ?? null - }); + parent_changed = true; + + if (server_data_node?.type === 'error') { + if (server_data_node.httperror) { + // reconstruct as an HttpError + throw error(server_data_node.httperror.status, server_data_node.httperror.message); } else { - return previous; + throw server_data_node.error; } + } + + return load_node({ + loader, + url, + params, + routeId: route.id, + parent: async () => { + const data = {}; + for (let j = 0; j < i; j += 1) { + Object.assign(data, (await branch_promises[j])?.data); + } + return data; + }, + server_data_node: create_data_node(server_data_node) ?? previous?.server ?? null }); }); @@ -732,8 +778,8 @@ export function create_client({ target, base, trailing_slash }) { /** @type {Array} */ const branch = []; - for (let i = 0; i < nodes.length; i += 1) { - if (nodes[i]) { + for (let i = 0; i < loaders.length; i += 1) { + if (loaders[i]) { try { branch.push(await branch_promises[i]); } catch (e) { @@ -759,13 +805,10 @@ export function create_client({ target, base, trailing_slash }) { try { error_loaded = { node: await errors[i](), + loader: errors[i], data: {}, - uses: { - params: new Set(), - url: false, - dependencies: new Set(), - parent: false - } + server: null, + shared: null }; return await get_navigation_result_from_branch({ @@ -782,6 +825,9 @@ export function create_client({ target, base, trailing_slash }) { } } + // TODO post-https://github.com/sveltejs/kit/discussions/6124, this will + // no longer be necessary — if we get here, it's because the root layout + // load function failed, which means we have to fall back to the server return await load_root_error_page({ status, error, @@ -813,30 +859,57 @@ export function create_client({ target, base, trailing_slash }) { * url: URL; * routeId: string | null * }} opts + * @returns {Promise} */ async function load_root_error_page({ status, error, url, routeId }) { /** @type {Record} */ const params = {}; // error page does not have params + const node = await default_layout_loader(); + + /** @type {import('types').ServerDataNode | null} */ + let server_data_node = null; + + if (node.server) { + // TODO post-https://github.com/sveltejs/kit/discussions/6124 we can use + // existing root layout data + const res = await native_fetch( + `${url.pathname}${url.pathname.endsWith('/') ? '' : '/'}__data.json${url.search}`, + { + headers: { + 'x-sveltekit-invalidated': '1' + } + } + ); + + const server_data_nodes = await res.json(); + server_data_node = server_data_nodes?.[0] ?? null; + + if (!res.ok || server_data_nodes?.type !== 'data') { + // at this point we have no choice but to fall back to the server + native_navigation(url); + + // @ts-expect-error + return; + } + } + const root_layout = await load_node({ - node: await default_layout, + loader: default_layout_loader, url, params, routeId, parent: () => Promise.resolve({}), - server_data: null // TODO!!!!! + server_data_node: create_data_node(server_data_node) }); + /** @type {import('./types').BranchNode} */ const root_error = { - node: await default_error, - data: null, - // TODO make this unnecessary - uses: { - params: new Set(), - url: false, - dependencies: new Set(), - parent: false - } + node: await default_error_loader(), + loader: default_error_loader, + shared: null, + server: null, + data: null }; return await get_navigation_result_from_branch({ @@ -985,7 +1058,8 @@ export function create_client({ target, base, trailing_slash }) { if (resource === undefined) { // Force rerun of all load functions, regardless of their dependencies for (const node of current.branch) { - node?.uses.dependencies.add(''); + node?.server?.uses.dependencies.add(''); + node?.shared?.uses.dependencies.add(''); } invalidated.push(() => true); } else if (typeof resource === 'function') { @@ -1230,12 +1304,19 @@ export function create_client({ target, base, trailing_slash }) { const script = document.querySelector(`script[sveltekit\\:data-type="${type}"]`); return script?.textContent ? JSON.parse(script.textContent) : fallback; }; - const server_data = parse('server_data', []); + /** + * @type {Array} + * On initial navigation, this will only consist of data nodes or `null`. + * A possible error is passed through the `error` property, in which case + * the last entry of `node_ids` is an error page and the last entry of + * `server_data_nodes` is `null`. + */ + const server_data_nodes = parse('server_data', []); const validation_errors = parse('validation_errors', undefined); const branch_promises = node_ids.map(async (n, i) => { return load_node({ - node: await nodes[n](), + loader: nodes[n], url, params, routeId, @@ -1246,7 +1327,7 @@ export function create_client({ target, base, trailing_slash }) { } return data; }, - server_data: server_data[i] ?? null + server_data_node: create_data_node(server_data_nodes[i]) }); }); diff --git a/packages/kit/src/runtime/client/types.d.ts b/packages/kit/src/runtime/client/types.d.ts index bfcbc3dd59c8..1b32c231ca28 100644 --- a/packages/kit/src/runtime/client/types.d.ts +++ b/packages/kit/src/runtime/client/types.d.ts @@ -6,7 +6,7 @@ import { prefetch, prefetchRoutes } from '$app/navigation'; -import { CSRPageNode, CSRRoute } from 'types'; +import { CSRPageNode, CSRPageNodeLoader, CSRRoute, ServerErrorNode, Uses } from 'types'; import { HttpError } from '../../index/private.js'; import { SerializedHttpError } from '../server/page/types.js'; @@ -65,15 +65,18 @@ export type NavigationFinished = { export type BranchNode = { node: CSRPageNode; + loader: CSRPageNodeLoader; + server: DataNode | null; + shared: DataNode | null; data: Record | null; - uses: { - params: Set; - url: boolean; // TODO make more granular? - dependencies: Set; - parent: boolean; - }; }; +export interface DataNode { + type: 'data'; + data: Record | null; + uses: Uses; +} + export type NavigationState = { branch: Array; error: HttpError | Error | null; @@ -81,25 +84,3 @@ export type NavigationState = { session_id: number; url: URL; }; - -export type ServerDataPayload = ServerDataRedirected | ServerDataLoaded; - -export interface ServerDataRedirected { - type: 'redirect'; - location: string; -} - -export interface ServerDataLoaded { - type: 'data'; - nodes: Array<{ - data?: Record | null; // TODO or `-1` to indicate 'reuse cached data'? - status?: number; - message?: string; - error?: { - name: string; - message: string; - stack: string; - [key: string]: any; - }; - }>; -} diff --git a/packages/kit/src/runtime/server/index.js b/packages/kit/src/runtime/server/index.js index e4a8bd422dcf..af7e56a84f72 100644 --- a/packages/kit/src/runtime/server/index.js +++ b/packages/kit/src/runtime/server/index.js @@ -10,6 +10,7 @@ import { negotiate } from '../../utils/http.js'; import { HttpError, Redirect } from '../../index/private.js'; import { load_server_data } from './page/load_data.js'; import { json } from '../../index/index.js'; +import { once } from '../../utils/functions.js'; /* global __SVELTEKIT_ADAPTER_NAME__ */ @@ -254,19 +255,26 @@ export async function respond(request, options, state) { let response; if (is_data_request && route.type === 'page') { try { - /** @type {Redirect | HttpError | Error} */ - let error; - - // TODO only get the data we need for the navigation - const promises = [...route.layouts, route.leaf].map(async (n, i) => { - try { - if (error) return; - - // == because it could be undefined (in dev) or null (in build, because of JSON.stringify) - const node = n == undefined ? n : await options.manifest._.nodes[n](); - return { - // TODO return `uses`, so we can reuse server data effectively - data: await load_server_data({ + const node_ids = [...route.layouts, route.leaf]; + + const invalidated = + request.headers.get('x-sveltekit-invalidated')?.split(',').map(Boolean) ?? + node_ids.map(() => true); + + let aborted = false; + + const functions = node_ids.map((n, i) => { + return once(async () => { + try { + if (aborted) { + return /** @type {import('types').ServerDataSkippedNode} */ ({ + type: 'skip' + }); + } + + // == because it could be undefined (in dev) or null (in build, because of JSON.stringify) + const node = n == undefined ? n : await options.manifest._.nodes[n](); + return load_server_data({ dev: options.dev, event, node, @@ -274,47 +282,78 @@ export async function respond(request, options, state) { /** @type {Record} */ const data = {}; for (let j = 0; j < i; j += 1) { - const parent = await promises[j]; - if (!parent || parent instanceof HttpError || 'error' in parent) { - return data; - } + const parent = /** @type {import('types').ServerDataNode} */ ( + await functions[j]() + ); Object.assign(data, parent.data); } return data; } - }) - }; - } catch (e) { - error = normalize_error(e); - - if (error instanceof Redirect) { - throw error; - } - - if (error instanceof HttpError) { - return error; // { status, message } + }); + } catch (e) { + aborted = true; + throw e; } + }); + }); - options.handle_error(error, event); - - return { - error: error_to_pojo(error, options.get_stack) - }; + const promises = functions.map(async (fn, i) => { + if (!invalidated[i]) { + return /** @type {import('types').ServerDataSkippedNode} */ ({ + type: 'skip' + }); } + + return fn(); }); - response = json({ + let length = promises.length; + const nodes = await Promise.all( + promises.map((p, i) => + p.catch((e) => { + const error = normalize_error(e); + + if (error instanceof Redirect) { + throw error; + } + + length = i + 1; // don't include nodes after first error + + if (error instanceof HttpError) { + return /** @type {import('types').ServerErrorNode} */ ({ + type: 'error', + httperror: { ...error } + }); + } + + options.handle_error(error, event); + + return /** @type {import('types').ServerErrorNode} */ ({ + type: 'error', + error: error_to_pojo(error, options.get_stack) + }); + }) + ) + ); + + /** @type {import('types').ServerData} */ + const server_data = { type: 'data', - nodes: await Promise.all(promises) - }); + nodes: nodes.slice(0, length) + }; + + response = json(server_data); } catch (e) { const error = normalize_error(e); if (error instanceof Redirect) { - response = json({ + /** @type {import('types').ServerData} */ + const server_data = { type: 'redirect', location: error.location - }); + }; + + response = json(server_data); } else { response = json(error_to_pojo(error, options.get_stack), { status: 500 }); } diff --git a/packages/kit/src/runtime/server/page/index.js b/packages/kit/src/runtime/server/page/index.js index 07ccefbe7ace..744af2f2d15c 100644 --- a/packages/kit/src/runtime/server/page/index.js +++ b/packages/kit/src/runtime/server/page/index.js @@ -145,7 +145,7 @@ export async function render_page(event, route, options, state, resolve_opts) { /** @type {Error | null} */ let load_error = null; - /** @type {Array | null>>} */ + /** @type {Array>} */ const server_promises = nodes.map((node, i) => { if (load_error) { // if an error happens immediately, don't bother with the rest of the nodes @@ -168,7 +168,8 @@ export async function render_page(event, route, options, state, resolve_opts) { /** @type {Record} */ const data = {}; for (let j = 0; j < i; j += 1) { - Object.assign(data, await server_promises[j]); + const parent = await server_promises[j]; + if (parent) Object.assign(data, await parent.data); } return data; } @@ -291,7 +292,7 @@ export async function render_page(event, route, options, state, resolve_opts) { response: new Response(undefined), body: JSON.stringify({ type: 'data', - nodes: branch.map((branch_node) => ({ data: branch_node?.server_data })) + nodes: branch.map((branch_node) => branch_node?.server_data) }) }); } diff --git a/packages/kit/src/runtime/server/page/load_data.js b/packages/kit/src/runtime/server/page/load_data.js index 03422217c864..2150b5f202d8 100644 --- a/packages/kit/src/runtime/server/page/load_data.js +++ b/packages/kit/src/runtime/server/page/load_data.js @@ -8,19 +8,46 @@ import { LoadURL, PrerenderingURL } from '../../../utils/url.js'; * node: import('types').SSRNode | undefined; * parent: () => Promise>; * }} opts + * @returns {Promise} */ export async function load_server_data({ dev, event, node, parent }) { if (!node?.server) return null; - const server_data = await node.server.load?.call(null, { + const uses = { + dependencies: new Set(), + params: new Set(), + parent: false, + url: false + }; + + /** @param {string[]} deps */ + function depends(...deps) { + for (const dep of deps) { + const { href } = new URL(dep, event.url); + uses.dependencies.add(href); + } + } + + const params = new Proxy(event.params, { + get: (target, key) => { + uses.params.add(key); + return target[/** @type {string} */ (key)]; + } + }); + + const result = await node.server.load?.call(null, { // can't use destructuring here because it will always // invoke event.clientAddress, which breaks prerendering get clientAddress() { return event.clientAddress; }, + depends, locals: event.locals, - params: event.params, - parent, + params, + parent: async () => { + uses.parent = true; + return parent(); + }, platform: event.platform, request: event.request, routeId: event.routeId, @@ -28,13 +55,22 @@ export async function load_server_data({ dev, event, node, parent }) { url: event.url }); - const result = server_data ? await unwrap_promises(server_data) : null; + const data = result ? await unwrap_promises(result) : null; if (dev) { - check_serializability(result, /** @type {string} */ (node.server_id), 'data'); + check_serializability(data, /** @type {string} */ (node.server_id), 'data'); } - return result; + return { + type: 'data', + data, + uses: { + dependencies: uses.dependencies.size > 0 ? Array.from(uses.dependencies) : undefined, + params: uses.params.size > 0 ? Array.from(uses.params) : undefined, + parent: uses.parent ? 1 : undefined, + url: uses.url ? 1 : undefined + } + }; } /** @@ -44,21 +80,22 @@ export async function load_server_data({ dev, event, node, parent }) { * fetcher: typeof fetch; * node: import('types').SSRNode | undefined; * parent: () => Promise>; - * server_data_promise: Promise | null>; + * server_data_promise: Promise; * state: import('types').SSRState; * }} opts + * @returns {Promise | null>} */ export async function load_data({ event, fetcher, node, parent, server_data_promise, state }) { - const server_data = await server_data_promise; + const server_data_node = await server_data_promise; if (!node?.shared?.load) { - return server_data; + return server_data_node?.data ?? null; } const load_input = { url: state.prerendering ? new PrerenderingURL(event.url) : new LoadURL(event.url), params: event.params, - data: server_data, + data: server_data_node?.data ?? null, routeId: event.routeId, fetch: fetcher, setHeaders: event.setHeaders, diff --git a/packages/kit/src/utils/functions.js b/packages/kit/src/utils/functions.js new file mode 100644 index 000000000000..062910784a41 --- /dev/null +++ b/packages/kit/src/utils/functions.js @@ -0,0 +1,16 @@ +/** + * @template T + * @param {() => T} fn + */ +export function once(fn) { + let done = false; + + /** @type T */ + let result; + + return () => { + if (done) return result; + done = true; + return (result = fn()); + }; +} diff --git a/packages/kit/test/apps/basics/src/routes/load/unchanged/+layout.server.js b/packages/kit/test/apps/basics/src/routes/load/unchanged/+layout.server.js new file mode 100644 index 000000000000..3e71bd890ab4 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/load/unchanged/+layout.server.js @@ -0,0 +1,7 @@ +import { increment } from './state.js'; + +export function load() { + return { + count: increment() + }; +} diff --git a/packages/kit/test/apps/basics/src/routes/load/unchanged/+layout.svelte b/packages/kit/test/apps/basics/src/routes/load/unchanged/+layout.svelte new file mode 100644 index 000000000000..59827db10199 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/load/unchanged/+layout.svelte @@ -0,0 +1,10 @@ + + + + diff --git a/packages/kit/test/apps/basics/src/routes/load/unchanged/+page.svelte b/packages/kit/test/apps/basics/src/routes/load/unchanged/+page.svelte new file mode 100644 index 000000000000..92cd56efc69b --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/load/unchanged/+page.svelte @@ -0,0 +1,2 @@ +uses parent +isolated diff --git a/packages/kit/test/apps/basics/src/routes/load/unchanged/isolated/[slug]/+page.server.js b/packages/kit/test/apps/basics/src/routes/load/unchanged/isolated/[slug]/+page.server.js new file mode 100644 index 000000000000..ca49098363d1 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/load/unchanged/isolated/[slug]/+page.server.js @@ -0,0 +1,6 @@ +/** @type {import('./$types').PageServerLoad} */ +export function load({ params }) { + return { + slug: params.slug + }; +} diff --git a/packages/kit/test/apps/basics/src/routes/load/unchanged/isolated/[slug]/+page.svelte b/packages/kit/test/apps/basics/src/routes/load/unchanged/isolated/[slug]/+page.svelte new file mode 100644 index 000000000000..b04d74c9430f --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/load/unchanged/isolated/[slug]/+page.svelte @@ -0,0 +1,7 @@ + + +

slug: {data.slug}

+

count: {data.count}

diff --git a/packages/kit/test/apps/basics/src/routes/load/unchanged/reset/+server.js b/packages/kit/test/apps/basics/src/routes/load/unchanged/reset/+server.js new file mode 100644 index 000000000000..448545fd5f90 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/load/unchanged/reset/+server.js @@ -0,0 +1,7 @@ +import { reset } from '../state.js'; + +/** @type {import('./$types').RequestHandler} */ +export function GET() { + reset(); + return new Response('ok'); +} diff --git a/packages/kit/test/apps/basics/src/routes/load/unchanged/state.js b/packages/kit/test/apps/basics/src/routes/load/unchanged/state.js new file mode 100644 index 000000000000..10436c57304a --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/load/unchanged/state.js @@ -0,0 +1,9 @@ +let count = 0; + +export function increment() { + return count++; +} + +export function reset() { + count = 0; +} diff --git a/packages/kit/test/apps/basics/src/routes/load/unchanged/uses-parent/[slug]/+page.server.js b/packages/kit/test/apps/basics/src/routes/load/unchanged/uses-parent/[slug]/+page.server.js new file mode 100644 index 000000000000..fba9a5a3c012 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/load/unchanged/uses-parent/[slug]/+page.server.js @@ -0,0 +1,9 @@ +/** @type {import('./$types').PageServerLoad} */ +export async function load({ params, parent }) { + const { count } = await parent(); + + return { + doubled: count * 2, + slug: params.slug + }; +} diff --git a/packages/kit/test/apps/basics/src/routes/load/unchanged/uses-parent/[slug]/+page.svelte b/packages/kit/test/apps/basics/src/routes/load/unchanged/uses-parent/[slug]/+page.svelte new file mode 100644 index 000000000000..f23520ab163b --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/load/unchanged/uses-parent/[slug]/+page.svelte @@ -0,0 +1,8 @@ + + +

slug: {data.slug}

+

count: {data.count}

+

doubled: {data.doubled}

diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js index f95a9568de7c..8e43d8711ef7 100644 --- a/packages/kit/test/apps/basics/test/client.test.js +++ b/packages/kit/test/apps/basics/test/client.test.js @@ -592,3 +592,41 @@ test('can use $app/stores from anywhere on client', async ({ page }) => { await page.click('button'); await expect(page.locator('h1')).toHaveText('/store/client-access'); }); + +test.describe.serial('Invalidation', () => { + test('+layout.server.js does not re-run when downstream load functions are invalidated', async ({ + page, + request, + clicknav + }) => { + await request.get('/load/unchanged/reset'); + + await page.goto('/load/unchanged/isolated/a'); + expect(await page.textContent('h1')).toBe('slug: a'); + expect(await page.textContent('h2')).toBe('count: 0'); + + await clicknav('[href="/load/unchanged/isolated/b"]'); + expect(await page.textContent('h1')).toBe('slug: b'); + expect(await page.textContent('h2')).toBe('count: 0'); + }); + + test('+layout.server.js re-runs when await parent() is called from downstream load function', async ({ + page, + request, + clicknav + }) => { + await request.get('/load/unchanged/reset'); + + await page.goto('/load/unchanged/uses-parent/a'); + expect(await page.textContent('h1')).toBe('slug: a'); + expect(await page.textContent('h2')).toBe('count: 0'); + expect(await page.textContent('h3')).toBe('doubled: 0'); + + await clicknav('[href="/load/unchanged/uses-parent/b"]'); + expect(await page.textContent('h1')).toBe('slug: b'); + expect(await page.textContent('h2')).toBe('count: 0'); + + // this looks wrong, but is actually the intended behaviour (the increment side-effect in a GET would be a bug in a real app) + expect(await page.textContent('h3')).toBe('doubled: 2'); + }); +}); diff --git a/packages/kit/test/apps/options/test/test.js b/packages/kit/test/apps/options/test/test.js index dff361ae6b54..629e80d6557f 100644 --- a/packages/kit/test/apps/options/test/test.js +++ b/packages/kit/test/apps/options/test/test.js @@ -161,7 +161,7 @@ test.describe('trailingSlash', () => { expect(r.url()).toBe(`${baseURL}/path-base/page-endpoint/__data.json`); expect(await r.json()).toEqual({ type: 'data', - nodes: [{ data: null }, { data: { message: 'hi' } }] + nodes: [null, { type: 'data', data: { message: 'hi' }, uses: {} }] }); }); diff --git a/packages/kit/test/prerendering/basics/test/test.js b/packages/kit/test/prerendering/basics/test/test.js index e79573f0f333..280a862864a1 100644 --- a/packages/kit/test/prerendering/basics/test/test.js +++ b/packages/kit/test/prerendering/basics/test/test.js @@ -82,14 +82,14 @@ test('generates __data.json file for shadow endpoints', () => { read('__data.json'), JSON.stringify({ type: 'data', - nodes: [{ data: null }, { data: { message: 'hello' } }] + nodes: [null, { type: 'data', data: { message: 'hello' }, uses: {} }] }) ); assert.equal( read('shadowed-get/__data.json'), JSON.stringify({ type: 'data', - nodes: [{ data: null }, { data: { answer: 42 } }] + nodes: [null, { type: 'data', data: { answer: 42 }, uses: {} }] }) ); }); @@ -179,7 +179,7 @@ test('fetches data from local endpoint', () => { read('origin/__data.json'), JSON.stringify({ type: 'data', - nodes: [{ data: null }, { data: { message: 'hello' } }] + nodes: [null, { type: 'data', data: { message: 'hello' }, uses: {} }] }) ); assert.equal(read('origin/message.json'), JSON.stringify({ message: 'hello' })); diff --git a/packages/kit/types/internal.d.ts b/packages/kit/types/internal.d.ts index b3677941b849..2e43e299baff 100644 --- a/packages/kit/types/internal.d.ts +++ b/packages/kit/types/internal.d.ts @@ -62,9 +62,9 @@ export interface BuildData { export interface CSRPageNode { component: typeof SvelteComponent; shared: { - load: Load; - hydrate: boolean; - router: boolean; + load?: Load; + hydrate?: boolean; + router?: boolean; }; server: boolean; } @@ -169,6 +169,50 @@ export interface Respond { export type RouteData = PageData | EndpointData; +export type ServerData = + | { + type: 'redirect'; + location: string; + } + | { + type: 'data'; + nodes: Array; + }; + +/** + * Signals a successful response of the server `load` function. + * The `uses` property tells the client when it's possible to reuse this data + * in a subsequent request. + */ +export interface ServerDataNode { + type: 'data'; + data: Record | null; + uses: { + dependencies?: string[]; + params?: string[]; + parent?: number | void; // 1 or undefined + url?: number | void; // 1 or undefined + }; +} + +/** + * Signals that the server `load` function was not run, and the + * client should use what it has in memory + */ +export interface ServerDataSkippedNode { + type: 'skip'; +} + +/** + * Signals that the server `load` function failed + */ +export interface ServerErrorNode { + type: 'error'; + // Either-or situation, but we don't want to have to do a type assertion + error?: Record; + httperror?: { status: number; message: string }; +} + export interface SSRComponent { default: { render(props: Record): { @@ -292,6 +336,13 @@ export interface SSRState { export type StrictBody = string | Uint8Array; +export interface Uses { + dependencies: Set; + params: Set; + parent: boolean; + url: boolean; +} + export type ValidatedConfig = RecursiveRequired; export type ValidatedKitConfig = RecursiveRequired;