From 6dd0fd15392655d02776731dc0d24cc7bab0f1c0 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Mon, 6 Feb 2023 15:03:03 +0100 Subject: [PATCH 01/62] defer for client-side navigations --- packages/kit/src/exports/index.js | 10 +- packages/kit/src/exports/vite/dev/index.js | 3 +- packages/kit/src/runtime/client/client.js | 152 ++++++++++++++---- packages/kit/src/runtime/control.js | 14 ++ packages/kit/src/runtime/server/data/index.js | 137 ++++++++++++++-- packages/kit/src/runtime/server/page/index.js | 5 +- .../kit/src/runtime/server/page/load_data.js | 26 ++- .../kit/src/runtime/server/page/types.d.ts | 4 +- packages/kit/src/runtime/server/utils.js | 33 ++-- packages/kit/src/utils/promises.js | 8 +- .../apps/basics/src/routes/defer/+page.svelte | 2 + .../src/routes/defer/server/+page.server.js | 17 ++ .../src/routes/defer/server/+page.svelte | 18 +++ .../src/routes/defer/universal/+page.js | 17 ++ .../src/routes/defer/universal/+page.svelte | 18 +++ packages/kit/test/apps/basics/test/test.js | 38 +++++ packages/kit/test/types/load.test.ts | 20 +++ packages/kit/types/index.d.ts | 10 ++ packages/kit/types/internal.d.ts | 64 ++++++-- 19 files changed, 510 insertions(+), 86 deletions(-) create mode 100644 packages/kit/test/apps/basics/src/routes/defer/+page.svelte create mode 100644 packages/kit/test/apps/basics/src/routes/defer/server/+page.server.js create mode 100644 packages/kit/test/apps/basics/src/routes/defer/server/+page.svelte create mode 100644 packages/kit/test/apps/basics/src/routes/defer/universal/+page.js create mode 100644 packages/kit/test/apps/basics/src/routes/defer/universal/+page.svelte create mode 100644 packages/kit/test/types/load.test.ts diff --git a/packages/kit/src/exports/index.js b/packages/kit/src/exports/index.js index b14a6e9613b9..45f550201c14 100644 --- a/packages/kit/src/exports/index.js +++ b/packages/kit/src/exports/index.js @@ -1,4 +1,4 @@ -import { HttpError, Redirect, ActionFailure } from '../runtime/control.js'; +import { HttpError, Redirect, ActionFailure, Deferred } from '../runtime/control.js'; import { BROWSER, DEV } from 'esm-env'; // For some reason we need to type the params as well here, @@ -72,3 +72,11 @@ export function text(body, init) { export function fail(status, data) { return new ActionFailure(status, data); } + +/** + * @template {Record} T + * @param {T} data + */ +export function defer(data) { + return new Deferred(data); +} diff --git a/packages/kit/src/exports/vite/dev/index.js b/packages/kit/src/exports/vite/dev/index.js index 5d7ca57ff606..01c7345bcc41 100644 --- a/packages/kit/src/exports/vite/dev/index.js +++ b/packages/kit/src/exports/vite/dev/index.js @@ -339,7 +339,8 @@ export async function dev(vite, vite_config, svelte_config) { control_module_node.replace_implementations({ ActionFailure: control_module_vite.ActionFailure, HttpError: control_module_vite.HttpError, - Redirect: control_module_vite.Redirect + Redirect: control_module_vite.Redirect, + Deferred: control_module_vite.Deferred }); } align_exports(); diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index ef4cf180da25..4fb8894e082f 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -27,7 +27,7 @@ import { parse } from './parse.js'; import Root from '__GENERATED__/root.svelte'; import { nodes, server_loads, dictionary, matchers, hooks } from '__CLIENT__/manifest.js'; import { base } from '$internal/paths'; -import { HttpError, Redirect } from '../control.js'; +import { Deferred, HttpError, Redirect } from '../control.js'; import { stores } from './singletons.js'; import { unwrap_promises } from '../../utils/promises.js'; import * as devalue from 'devalue'; @@ -619,14 +619,15 @@ export function create_client({ target }) { try { lock_fetch(); data = (await node.universal.load.call(null, load_input)) ?? null; - if (data != null && Object.getPrototypeOf(data) !== Object.prototype) { + const to_check = data instanceof Deferred ? data.data : data; + if (to_check != null && Object.getPrototypeOf(to_check) !== Object.prototype) { throw new Error( `a load function related to route '${route.id}' returned ${ - typeof data !== 'object' - ? `a ${typeof data}` - : data instanceof Response + typeof to_check !== 'object' + ? `a ${typeof to_check}` + : to_check instanceof Response ? 'a Response object' - : Array.isArray(data) + : Array.isArray(to_check) ? 'an array' : 'a non-plain object' }, but must return a plain object at the top level (i.e. \`return {...}\`)` @@ -685,18 +686,16 @@ export function create_client({ target }) { */ function create_data_node(node, previous) { if (node?.type === 'data') { - return { + /** @type {import('./types').DataNode} */ + const data_node = { type: 'data', data: node.data, - uses: { - dependencies: new Set(node.uses.dependencies ?? []), - params: new Set(node.uses.params ?? []), - parent: !!node.uses.parent, - route: !!node.uses.route, - url: !!node.uses.url - }, + // It's important that we use the existing `uses` object here, so that + // potentially deferred data can manipulate the object later + uses: node.uses, slash: node.slash }; + return data_node; } else if (node?.type === 'skip') { return previous ?? null; } @@ -722,7 +721,7 @@ export function create_client({ target }) { errors.forEach((loader) => loader?.().catch(() => {})); loaders.forEach((loader) => loader?.[1]().catch(() => {})); - /** @type {import('types').ServerData | null} */ + /** @type {import('types').ServerNodesResponse | import('types').ServerRedirectNode | null} */ let server_data = null; const url_changed = current.url ? id !== current.url.pathname + current.url.search : false; @@ -1630,6 +1629,10 @@ export function create_client({ target }) { try { const branch_promises = node_ids.map(async (n, i) => { const server_data_node = server_data_nodes[i]; + // Type isn't completely accurate, we still need to deserialize uses + if (server_data_node?.uses) { + server_data_node.uses = deserialize_uses(server_data_node.uses); + } return load_node({ loader: nodes[n], @@ -1680,7 +1683,7 @@ export function create_client({ target }) { /** * @param {URL} url * @param {boolean[]} invalid - * @returns {Promise} + * @returns {Promise} */ async function load_data(url, invalid) { const data_url = new URL(url); @@ -1694,29 +1697,118 @@ async function load_data(url, invalid) { ); const res = await native_fetch(data_url.href); - const data = await res.json(); if (!res.ok) { // error message is a JSON-stringified string which devalue can't handle at the top level // turn it into a HttpError to not call handleError on the client again (was already handled on the server) - throw new HttpError(res.status, data); + throw new HttpError(res.status, await res.json()); } - // revive devalue-flattened data - data.nodes?.forEach((/** @type {any} */ node) => { - if (node?.type === 'data') { - node.data = devalue.unflatten(node.data); - node.uses = { - dependencies: new Set(node.uses.dependencies ?? []), - params: new Set(node.uses.params ?? []), - parent: !!node.uses.parent, - route: !!node.uses.route, - url: !!node.uses.url - }; + return new Promise(async (resolve) => { + /** + * @type {Map void; reject: (v: any) => void; uses: import('types').Uses }>} + * Map of deferred promises that will be resolved by a subsequent chunk of data + */ + const pending = new Map(); + const reader = /** @type {ReadableStream} */ (res.body).getReader(); + const decoder = new TextDecoder(); + + let text = ''; + + while (true) { + // Format follows ndjson (each line is a JSON object) or regular JSON spec + const { done, value } = await reader.read(); + if (done && !text && !value) break; + + text += !value && text ? '\n' : decoder.decode(value); // no value -> final chunk -> add a new line to trigger the last parse + + while (true) { + const split = text.indexOf('\n'); + if (split === -1) { + break; + } + + const node = JSON.parse(text.slice(0, split)); + text = text.slice(split + 1); + + if (node.type === 'redirect') { + return resolve(node); + } + + if (node.type === 'data') { + // This is the first (and possibly only, if no `defer` used) chunk + node.nodes?.forEach((/** @type {any} */ node) => { + if (node?.type === 'data') { + node.uses = deserialize_uses(node.uses); + node.data = devalue.unflatten(node.data); + + for (const key in node.data) { + const entry = node.data[key]; + // Revive promise, to be resolved in a later chunk + if (typeof entry === 'string' && entry.startsWith('_deferred_')) { + /** @type {(v: any) => void} */ + let resolve; + /** @type {(v: any) => void} */ + let reject; + node.data[key] = new Promise((f, r) => { + resolve = f; + reject = r; + }); + pending.set(entry, { + // @ts-expect-error TS doesnt know this is set + resolve, + // @ts-expect-error TS doesnt know this is set + reject, + uses: node.uses + }); + } + } + } + }); + + resolve(node); + } else if (node.type === 'chunk') { + // This is a subsequent chunk containing deferred data + let { id, data, error, uses } = node; + const entry = pending.get(id); + // Shouldn't ever be undefined, but just in case + if (entry) { + if (error) { + entry.reject(error); + } else { + entry.resolve(devalue.unflatten(data)); + } + if (uses) { + uses = deserialize_uses(uses); + // Merge into existing uses + entry.uses.dependencies = new Set([...entry.uses.dependencies, ...uses.dependencies]); + entry.uses.params = new Set([...entry.uses.params, ...uses.params]); + entry.uses.parent = entry.uses.parent || uses.parent; + entry.uses.route = entry.uses.route || uses.route; + entry.uses.url = entry.uses.url || uses.url; + } + } + pending.delete(id); + } + } } }); - return data; + // TODO edge case handling necessary? stream() read fails? +} + +/** + * @param {any} uses + * @return {import('types').Uses} + */ +function deserialize_uses(uses) { + return { + dependencies: new Set(uses?.dependencies ?? []), + params: new Set(uses?.params ?? []), + parent: !!uses?.parent, + route: !!uses?.route, + url: !!uses?.url + }; } /** diff --git a/packages/kit/src/runtime/control.js b/packages/kit/src/runtime/control.js index 87e0dcc2b2c5..ddd6271317fb 100644 --- a/packages/kit/src/runtime/control.js +++ b/packages/kit/src/runtime/control.js @@ -44,6 +44,18 @@ export let ActionFailure = class ActionFailure { } }; +/** + * @template {Record} T + */ +export let Deferred = class Deferred { + /** + * @param {T} data + */ + constructor(data) { + this.data = data; + } +}; + /** * This is a grotesque hack that, in dev, allows us to replace the implementations * of these classes that you'd get by importing them from `@sveltejs/kit` with the @@ -54,10 +66,12 @@ export let ActionFailure = class ActionFailure { * ActionFailure: typeof ActionFailure; * HttpError: typeof HttpError; * Redirect: typeof Redirect; + * Deferred: typeof Deferred; * }} implementations */ export function replace_implementations(implementations) { ActionFailure = implementations.ActionFailure; HttpError = implementations.HttpError; Redirect = implementations.Redirect; + Deferred = implementations.Deferred; } diff --git a/packages/kit/src/runtime/server/data/index.js b/packages/kit/src/runtime/server/data/index.js index 8c0faa4ff247..3e3544d9f175 100644 --- a/packages/kit/src/runtime/server/data/index.js +++ b/packages/kit/src/runtime/server/data/index.js @@ -58,6 +58,7 @@ export async function render_data( // == because it could be undefined (in dev) or null (in build, because of JSON.stringify) const node = n == undefined ? n : await manifest._.nodes[n](); + // load this. for the child, return as is. for the final result, stream things return load_server_data({ event: new_event, state, @@ -115,13 +116,124 @@ export async function render_data( ); try { - const stubs = nodes.slice(0, length).map(serialize_data_node); + /** + * @type {Array} + * The first (and possibly only, if no `defer` used) chunk sent out + */ + const result = []; + /** + * @type {Array<{id: string; node_idx: number; promise: Promise<{id: string; node_idx: number; uses: import('types').Uses; result?: any; error?: any}>}>} + * List of deferred promises, to be resolved after the first chunk is sent + */ + let deferred = []; - const json = `{"type":"data","nodes":[${stubs.join(',')}]}`; - return json_response(json); + // First process all results in order, and possibly collect and setup deferred promises + for (let node_idx = 0; node_idx < nodes.length; node_idx++) { + const node = nodes[node_idx]; + if (!node || node.type === 'skip' || node.type === 'error' || !node.data) { + result.push(node); + continue; + } + + /** @type {Record} */ + let start = {}; + let node_uses_defer = false; + + for (const key in node.data ?? []) { + const entry = node.data[key]; + if (typeof entry === 'object' && typeof entry?.then === 'function') { + node_uses_defer = true; + start[key] = `_deferred_${key}`; + deferred.push({ + id: key, + node_idx, + promise: entry + .then( + /** @param {any} result */ (result) => ({ + id: key, + result, + node_idx, + uses: node.uses + }) + ) + .catch(/** @param {any} error */ (error) => ({ id: key, error, uses: node.uses })) + }); + } else { + start[key] = entry; + } + } + + result.push({ + type: 'data', + data: start, + uses: node_uses_defer ? undefined : node.uses, + slash: node.slash + }); + } + + // Now send the first chunk. If there are no deferred promises, we're done + let json = ''; + + try { + json = `{"type":"data","nodes":[${result.map(serialize_data_node).join(',')}]}`; + if (!deferred.length) { + return json_response(json); + } + } catch (e) { + const error = /** @type {any} */ (e); + return json_response( + await handle_error_and_jsonify( + event, + options, + new Error(clarify_devalue_error(event, error)) + ), + 500 + ); + } + + return new Response( + new ReadableStream({ + async start(controller) { + controller.enqueue(`${json}\n`); + + // Await all deferred promises and send out the rest of the chunks + while (deferred.length) { + const next = await Promise.race(deferred.map((d) => d.promise)); + deferred = deferred.filter((d) => d.id !== next.id); + controller.enqueue( + serialize_data_node( + /** @type {import('types').ServerDataChunkNode} */ ({ + type: 'chunk', + id: `_deferred_${next.id}`, + data: next.result, + error: next.error, + // only send uses when it's the last chunk of the data node + // so we can be sure all uses are accounted for + uses: deferred.some((d) => d.node_idx === next.node_idx) ? undefined : next.uses + }) + ) + '\n' + ); + } + + controller.close(); + } + }), + { + headers: { + 'content-type': 'application/octet-stream' + } + } + ); } catch (e) { const error = /** @type {any} */ (e); - return json_response(JSON.stringify(clarify_devalue_error(event, error)), 500); + return json_response( + await handle_error_and_jsonify( + event, + options, + new Error(clarify_devalue_error(event, error)) + ), + 500 + ); } } catch (e) { const error = normalize_error(e); @@ -129,18 +241,17 @@ export async function render_data( if (error instanceof Redirect) { return redirect_json_response(error); } else { - // TODO make it clearer that this was an unexpected error - return json_response(JSON.stringify(await handle_error_and_jsonify(event, options, error))); + return json_response(await handle_error_and_jsonify(event, options, error), 500); } } } /** - * @param {string} json + * @param {Record | string} json * @param {number} [status] */ function json_response(json, status = 200) { - return text(json, { + return text(typeof json === 'string' ? json : JSON.stringify(json), { status, headers: { 'content-type': 'application/json', @@ -153,10 +264,8 @@ function json_response(json, status = 200) { * @param {Redirect} redirect */ export function redirect_json_response(redirect) { - return json_response( - JSON.stringify({ - type: 'redirect', - location: redirect.location - }) - ); + return json_response({ + type: 'redirect', + location: redirect.location + }); } diff --git a/packages/kit/src/runtime/server/page/index.js b/packages/kit/src/runtime/server/page/index.js index 3bcebe191151..8e35e66dfce1 100644 --- a/packages/kit/src/runtime/server/page/index.js +++ b/packages/kit/src/runtime/server/page/index.js @@ -290,9 +290,8 @@ export async function render_page(event, route, page, options, manifest, state, } if (state.prerendering && should_prerender_data) { - const body = `{"type":"data","nodes":[${branch - .map((node) => serialize_data_node(node?.server_data)) - .join(',')}]}`; + // ndjson format + const body = branch.map((node) => serialize_data_node(node?.server_data)).join('\n') + '\n'; state.prerendering.dependencies.set(data_pathname, { response: text(body), diff --git a/packages/kit/src/runtime/server/page/load_data.js b/packages/kit/src/runtime/server/page/load_data.js index 0c212fa9e604..36ef10bb952d 100644 --- a/packages/kit/src/runtime/server/page/load_data.js +++ b/packages/kit/src/runtime/server/page/load_data.js @@ -1,5 +1,6 @@ import { disable_search, make_trackable } from '../../../utils/url.js'; import { unwrap_promises } from '../../../utils/promises.js'; +import { Deferred } from '../../control.js'; /** * Calls the user's server `load` function. @@ -64,14 +65,19 @@ export async function load_server_data({ event, state, node, parent }) { url }); - const data = result ? await unwrap_promises(result) : null; + const data = result + ? await unwrap_promises( + // Don't defer if we're prerendering + state.prerendering && result instanceof Deferred ? result.data : result + ) + : null; if (__SVELTEKIT_DEV__) { validate_load_response(data, /** @type {string} */ (event.route.id)); } return { type: 'data', - data, + data: data instanceof Deferred ? data.data : data, uses, slash: node.server.trailingSlash }; @@ -89,7 +95,7 @@ export async function load_server_data({ event, state, node, parent }) { * state: import('types').SSRState; * csr: boolean; * }} opts - * @returns {Promise | null>} + * @returns {Promise> | null>} */ export async function load_data({ event, @@ -118,9 +124,17 @@ export async function load_data({ parent }); - const data = result ? await unwrap_promises(result) : null; - validate_load_response(data, /** @type {string} */ (event.route.id)); - return data; + const data = result + ? await unwrap_promises( + // Don't defer if we're prerendering + state.prerendering && result instanceof Deferred ? result.data : result + ) + : null; + if (__SVELTEKIT_DEV__) { + validate_load_response(data, /** @type {string} */ (event.route.id)); + } + + return data instanceof Deferred ? data.data : data; } /** diff --git a/packages/kit/src/runtime/server/page/types.d.ts b/packages/kit/src/runtime/server/page/types.d.ts index 2bb6c89808a7..f5b0c2d77e7a 100644 --- a/packages/kit/src/runtime/server/page/types.d.ts +++ b/packages/kit/src/runtime/server/page/types.d.ts @@ -1,5 +1,5 @@ import { CookieSerializeOptions } from 'cookie'; -import { SSRNode, CspDirectives } from 'types'; +import { SSRNode, CspDirectives, ServerDataNode } from 'types'; export interface Fetched { url: string; @@ -13,7 +13,7 @@ export interface Fetched { export type Loaded = { node: SSRNode; data: Record | null; - server_data: any; + server_data: ServerDataNode | null; }; type CspMode = 'hash' | 'nonce' | 'auto'; diff --git a/packages/kit/src/runtime/server/utils.js b/packages/kit/src/runtime/server/utils.js index ed8c4803d4c0..0354fec83cb4 100644 --- a/packages/kit/src/runtime/server/utils.js +++ b/packages/kit/src/runtime/server/utils.js @@ -148,31 +148,40 @@ export function clarify_devalue_error(event, error) { return error.message; } -/** @param {import('types').ServerDataNode | import('types').ServerDataSkippedNode | import('types').ServerErrorNode | null} node */ +/** @param {import('types').ServerDataNodePreSerialization | import('types').ServerDataSkippedNode | import('types').ServerErrorNode | import('types').ServerDataChunkNode | null | undefined} node */ export function serialize_data_node(node) { if (!node) return 'null'; if (node.type === 'error' || node.type === 'skip') { return JSON.stringify(node); } - - const stringified = devalue.stringify(node.data); - const uses = []; - if (node.uses.dependencies.size > 0) { + if (node.uses && node.uses.dependencies.size > 0) { uses.push(`"dependencies":${JSON.stringify(Array.from(node.uses.dependencies))}`); } - if (node.uses.params.size > 0) { + if (node.uses && node.uses.params.size > 0) { uses.push(`"params":${JSON.stringify(Array.from(node.uses.params))}`); } - if (node.uses.parent) uses.push(`"parent":1`); - if (node.uses.route) uses.push(`"route":1`); - if (node.uses.url) uses.push(`"url":1`); + if (node.uses?.parent) uses.push(`"parent":1`); + if (node.uses?.route) uses.push(`"route":1`); + if (node.uses?.url) uses.push(`"url":1`); + + const uses_str = node.uses ? `,"uses":{${uses.join(',')}}` : ''; - return `{"type":"data","data":${stringified},"uses":{${uses.join(',')}}${ - node.slash ? `,"slash":${JSON.stringify(node.slash)}` : '' - }}`; + if (node.type === 'data') { + return `{"type":"data","data":${devalue.stringify(node.data)}${uses_str}${ + node.slash ? `,"slash":${JSON.stringify(node.slash)}` : '' + }}`; + } else { + if (node.error) { + return `{"type":"chunk","id":"${node.id}","error":${devalue.stringify( + node.error + )}${uses_str}}`; + } else { + return `{"type":"chunk","id":"${node.id}","data":${devalue.stringify(node.data)}${uses_str}}`; + } + } } diff --git a/packages/kit/src/utils/promises.js b/packages/kit/src/utils/promises.js index ac2b53a60dd4..0e2fa548c3fa 100644 --- a/packages/kit/src/utils/promises.js +++ b/packages/kit/src/utils/promises.js @@ -1,10 +1,16 @@ +import { Deferred } from '../runtime/control.js'; + /** * Given an object, return a new object where all top level values are awaited * - * @param {Record} object + * @param {Record | Deferred>} object * @returns {Promise>} */ export async function unwrap_promises(object) { + if (object instanceof Deferred) { + return object.data; + } + for (const key in object) { if (typeof object[key]?.then === 'function') { return Object.fromEntries( diff --git a/packages/kit/test/apps/basics/src/routes/defer/+page.svelte b/packages/kit/test/apps/basics/src/routes/defer/+page.svelte new file mode 100644 index 000000000000..5a4a614ee349 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/defer/+page.svelte @@ -0,0 +1,2 @@ +Universal +Server diff --git a/packages/kit/test/apps/basics/src/routes/defer/server/+page.server.js b/packages/kit/test/apps/basics/src/routes/defer/server/+page.server.js new file mode 100644 index 000000000000..ec0d967c140a --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/defer/server/+page.server.js @@ -0,0 +1,17 @@ +import { defer } from '@sveltejs/kit'; + +export function load() { + return defer({ + eager: 'eager', + success: new Promise((resolve) => { + setTimeout(() => { + resolve('success'); + }, 1000); + }), + fail: new Promise((_, reject) => { + setTimeout(() => { + reject('fail'); + }, 1000); + }) + }); +} diff --git a/packages/kit/test/apps/basics/src/routes/defer/server/+page.svelte b/packages/kit/test/apps/basics/src/routes/defer/server/+page.svelte new file mode 100644 index 000000000000..f85387c3c1f9 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/defer/server/+page.svelte @@ -0,0 +1,18 @@ + + +

{data.eager}

+ +{#await data.success} +

Loading success

+{:then result} +

{result}

+{/await} + +{#await data.fail} +

Loading fail

+{:catch result} +

{result}

+{/await} diff --git a/packages/kit/test/apps/basics/src/routes/defer/universal/+page.js b/packages/kit/test/apps/basics/src/routes/defer/universal/+page.js new file mode 100644 index 000000000000..ec0d967c140a --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/defer/universal/+page.js @@ -0,0 +1,17 @@ +import { defer } from '@sveltejs/kit'; + +export function load() { + return defer({ + eager: 'eager', + success: new Promise((resolve) => { + setTimeout(() => { + resolve('success'); + }, 1000); + }), + fail: new Promise((_, reject) => { + setTimeout(() => { + reject('fail'); + }, 1000); + }) + }); +} diff --git a/packages/kit/test/apps/basics/src/routes/defer/universal/+page.svelte b/packages/kit/test/apps/basics/src/routes/defer/universal/+page.svelte new file mode 100644 index 000000000000..f85387c3c1f9 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/defer/universal/+page.svelte @@ -0,0 +1,18 @@ + + +

{data.eager}

+ +{#await data.success} +

Loading success

+{:then result} +

{result}

+{/await} + +{#await data.fail} +

Loading fail

+{:catch result} +

{result}

+{/await} diff --git a/packages/kit/test/apps/basics/test/test.js b/packages/kit/test/apps/basics/test/test.js index cd95a60439dc..3ab65e857725 100644 --- a/packages/kit/test/apps/basics/test/test.js +++ b/packages/kit/test/apps/basics/test/test.js @@ -1091,3 +1091,41 @@ test.describe.serial('Cookies API', () => { expect(await span.innerText()).toContain('undefined'); }); }); + +test.describe('defer', () => { + test('Works for universal load functions', async ({ page, javaScriptEnabled }) => { + if (javaScriptEnabled) { + await page.goto('/defer'); + page.click('[href="/defer/universal"]', { noWaitAfter: true }); + } else { + await page.goto('/defer/universal'); + } + + await expect(page.locator('p.eager')).toHaveText('eager'); + expect(page.locator('p.loadingsuccess')).toBeVisible(); + expect(page.locator('p.loadingfail')).toBeVisible(); + + await expect(page.locator('p.success')).toHaveText('success'); + await expect(page.locator('p.fail')).toHaveText('fail'); + expect(page.locator('p.loadingsuccess')).toBeHidden(); + expect(page.locator('p.loadingfail')).toBeHidden(); + }); + + test('Works for server load functions', async ({ page, javaScriptEnabled }) => { + if (javaScriptEnabled) { + await page.goto('/defer'); + page.click('[href="/defer/server"]', { noWaitAfter: true }); + } else { + await page.goto('/defer/server'); + } + + await expect(page.locator('p.eager')).toHaveText('eager'); + expect(page.locator('p.loadingsuccess')).toBeVisible(); + expect(page.locator('p.loadingfail')).toBeVisible(); + + await expect(page.locator('p.success')).toHaveText('success'); + await expect(page.locator('p.fail')).toHaveText('fail'); + expect(page.locator('p.loadingsuccess')).toBeHidden(); + expect(page.locator('p.loadingfail')).toBeHidden(); + }); +}); diff --git a/packages/kit/test/types/load.test.ts b/packages/kit/test/types/load.test.ts new file mode 100644 index 000000000000..321d70931bdd --- /dev/null +++ b/packages/kit/test/types/load.test.ts @@ -0,0 +1,20 @@ +import Kit, { Deferred } from '@sveltejs/kit'; + +// Test: Return types inferred correctly and transformed into a union +type LoadReturn1 = { success: true } | { message: Promise }; + +let result1: Kit.AwaitedProperties = null as any; +result1.message = ''; +result1.success = true; +// @ts-expect-error - cannot both be present at the same time +result1 = { message: '', success: true }; + +// Test: Return types keep promise for Deferred +type LoadReturn2 = { success: true } | Deferred<{ message: Promise; eager: true }>; + +let result2: Kit.AwaitedProperties = null as any; +result2.message = Promise.resolve(''); +result2.eager = true; +result2.success = true; +// @ts-expect-error - cannot both be present at the same time +result2 = { message: '', success: true }; diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index e7440202a024..8b2c08b7f885 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -37,6 +37,10 @@ export interface Adapter { type AwaitedPropertiesUnion | void> = input extends void ? undefined // needs to be undefined, because void will break intellisense + : input extends Deferred + ? { + [key in keyof Data]: Data[key]; + } : input extends Record ? { [key in keyof input]: Awaited; @@ -1190,3 +1194,9 @@ export interface SubmitFunction< }) => void) >; } + +export function defer>(data: T): Deferred; + +export interface Deferred> extends UniqueInterface { + data: T; +} diff --git a/packages/kit/types/internal.d.ts b/packages/kit/types/internal.d.ts index 0f053a16706e..4460c55e6e4e 100644 --- a/packages/kit/types/internal.d.ts +++ b/packages/kit/types/internal.d.ts @@ -187,29 +187,61 @@ export interface RouteData { } | null; } -export type ServerData = - | { - type: 'redirect'; - location: string; - } - | { - type: 'data'; - /** - * If `null`, then there was no load function - */ - nodes: Array; - }; +export type ServerRedirectNode = { + type: 'redirect'; + location: string; +}; + +export type ServerNodesResponse = { + type: 'data'; + /** + * If `null`, then there was no load function <- TODO is this outdated now with the recent changes? + */ + nodes: Array; +}; + +export type ServerDataResponse = ServerRedirectNode | ServerNodesResponse; + +/** + * Pre-serialized server version of successful response of the server `load` function. + */ +export interface ServerDataNodePreSerialization { + type: 'data'; + /** + * The serialized version of this contains a `_deferred_${someId}` for any deferred promises, + * which will be resolved later through chunk nodes. + */ + data: Record | null; + /** + * Defined if the `load` function didn't return a `defer`red result. + */ + uses?: Uses; + slash?: TrailingSlash; +} /** * 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; +export interface ServerDataNode extends ServerDataNodePreSerialization { + /** + * Defined in the serialized version if the `load` function didn't return a `defer`red result. + * Make sure to pass this property by reference and not copy it somehow, as it might be updated + * by a deferred promise. + */ uses: Uses; - slash?: TrailingSlash; +} + +export interface ServerDataChunkNode { + type: 'chunk'; + id: string; + data?: Record; + error?: any; + /** + * Defined for the final chunk of the corresponding `load` function + */ + uses?: Uses; } /** From fa7837ca72b0b9468c0b03c9b957bb0ec18659f9 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Mon, 6 Feb 2023 15:20:29 +0100 Subject: [PATCH 02/62] docs --- .../docs/20-core-concepts/20-load.md | 38 +++++++++++++++++++ packages/kit/types/index.d.ts | 20 ++++++++++ 2 files changed, 58 insertions(+) diff --git a/documentation/docs/20-core-concepts/20-load.md b/documentation/docs/20-core-concepts/20-load.md index 4a563afc9002..674693a868dd 100644 --- a/documentation/docs/20-core-concepts/20-load.md +++ b/documentation/docs/20-core-concepts/20-load.md @@ -555,6 +555,44 @@ To summarize, a `load` function will re-run in the following situations: Note that re-running a `load` function will update the `data` prop inside the corresponding `+layout.svelte` or `+page.svelte`; it does _not_ cause the component to be recreated. As a result, internal state is preserved. If this isn't what you want, you can reset whatever you need to reset inside an [`afterNavigate`](modules#$app-navigation-afternavigate) callback, and/or wrap your component in a [`{#key ...}`](https://svelte.dev/docs#template-syntax-key) block. +## Defer loading slow data + +Some data in your app might be slow to load. In such situations, it's useful to only wait until the fast data is available and start rendering while the slow data is still loading. You can do so by wrapping your returned data with the `defer` function: + +```js +/// file: +page.js +import { defer } from '@sveltejs/kit'; + +/** @type {import('./$types').PageLoad} */ +export function load({ fetch }) { + const fast = fetch('/api/responds/quickly'); + const slow = fetch('/api/takes/a/while'); + return defer({ + fast: await fast, + slow + }); +} +``` + +`defer` will not wait for promises passed to it. In the above example, the UI will be rendered as soon as `fast` has resolved. Use Svelte's `{#await}` to show meaningful fallback UI while the slow data is still loading: + +```svelte +/// file: +page.svelte + + +

{data.fast}

+{#await data.slow} +

Loading ...

+{:then result} +

{result}

+{:catch error} +

An error occurred: {error}

+{/catch} +``` + ## Shared state In many server environments, a single instance of your app will serve multiple users. For that reason, per-request or per-user state must not be stored in shared variables outside your `load` functions, but should instead be stored in `event.locals`. diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 8b2c08b7f885..6b1cf26f8de1 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -1195,6 +1195,26 @@ export interface SubmitFunction< >; } +/** + * Enables defering parts of the data loading and showing the next page quicker. Promises will not be awaited, allowing you to show + * meaningful fallback UI using Svelte's `{#await}` syntax while the rest of the data is loading. + * + * ```js + * /// file: +page.js + * import { defer } from '@sveltejs/kit'; + * + * /// type: import('./$types').PageLoad + * export function load({ fetch }) { + * const fast = fetch('/api/responds/quickly'); + * const slow = fetch('/api/takes/a/while'); + * return defer({ + * fast: await fast, + * slow + * }); + * } + * ``` + * @param data A regular JavaScript object. Top-level promises will be not be awaited. + */ export function defer>(data: T): Deferred; export interface Deferred> extends UniqueInterface { From e920b1924186e3d1c6500d3500fa7c7c74beb237 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Mon, 6 Feb 2023 15:33:07 +0100 Subject: [PATCH 03/62] changeset --- .changeset/ten-mice-brush.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/ten-mice-brush.md diff --git a/.changeset/ten-mice-brush.md b/.changeset/ten-mice-brush.md new file mode 100644 index 000000000000..469242c218cf --- /dev/null +++ b/.changeset/ten-mice-brush.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +feat: add `defer` utility for loading UI From c8a71737cb0352f08a47b8b34c029a5fce1df3e5 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Sun, 12 Feb 2023 09:12:12 +0100 Subject: [PATCH 04/62] needs lots of cleanup, but works for client-side navigations --- packages/kit/package.json | 2 +- packages/kit/src/runtime/client/client.js | 76 ++++-- packages/kit/src/runtime/server/data/index.js | 237 ++++++++++-------- packages/kit/types/internal.d.ts | 6 +- pnpm-lock.yaml | 8 +- 5 files changed, 188 insertions(+), 141 deletions(-) diff --git a/packages/kit/package.json b/packages/kit/package.json index bc368b462d83..3817f4f46dec 100644 --- a/packages/kit/package.json +++ b/packages/kit/package.json @@ -13,7 +13,7 @@ "@sveltejs/vite-plugin-svelte": "^2.0.0", "@types/cookie": "^0.5.1", "cookie": "^0.5.0", - "devalue": "^4.2.3", + "devalue": "^4.3.0", "esm-env": "^1.0.0", "kleur": "^4.1.5", "magic-string": "^0.27.0", diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 11d52a6baa26..854e29d8cc84 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -1792,33 +1792,26 @@ async function load_data(url, invalid) { } if (node.type === 'data') { - // This is the first (and possibly only, if no `defer` used) chunk + // This is the first (and possibly only, if no pending promises) chunk node.nodes?.forEach((/** @type {any} */ node) => { if (node?.type === 'data') { node.uses = deserialize_uses(node.uses); - node.data = devalue.unflatten(node.data); - - for (const key in node.data) { - const entry = node.data[key]; - // Revive promise, to be resolved in a later chunk - if (typeof entry === 'string' && entry.startsWith('_deferred_')) { - /** @type {(v: any) => void} */ - let resolve; - /** @type {(v: any) => void} */ - let reject; - node.data[key] = new Promise((f, r) => { - resolve = f; - reject = r; - }); - pending.set(entry, { - // @ts-expect-error TS doesnt know this is set - resolve, - // @ts-expect-error TS doesnt know this is set - reject, + node.data = devalue.unflatten(node.data, { + Promise: (id) => { + /** @type {any} */ + const obj = { + id, + resolve: undefined, + reject: undefined, uses: node.uses + }; + pending.set(id, obj); + return new Promise((f, r) => { + obj.resolve = f; + obj.reject = r; }); } - } + }); } }); @@ -1830,9 +1823,46 @@ async function load_data(url, invalid) { // Shouldn't ever be undefined, but just in case if (entry) { if (error) { - entry.reject(error); + entry.reject( + devalue.unflatten(error, { + Promise: (id) => { + /** @type {any} */ + const obj = { + id, + resolve: undefined, + reject: undefined, + uses: entry.uses + }; + pending.set(id, obj); + return new Promise((f, r) => { + obj.resolve = f; + obj.reject = r; + }); + } + }) + ); } else { - entry.resolve(devalue.unflatten(data)); + entry.resolve( + devalue.unflatten(data, { + Promise: (id) => { + /** @type {(v: any) => void} */ + let resolve; + /** @type {(v: any) => void} */ + let reject; + pending.set(id, { + // @ts-expect-error TS doesnt know this is set + resolve, + // @ts-expect-error TS doesnt know this is set + reject, + uses: entry.uses + }); + return new Promise((f, r) => { + resolve = f; + reject = r; + }); + } + }) + ); } if (uses) { uses = deserialize_uses(uses); diff --git a/packages/kit/src/runtime/server/data/index.js b/packages/kit/src/runtime/server/data/index.js index 3e3544d9f175..c93230f2b8f5 100644 --- a/packages/kit/src/runtime/server/data/index.js +++ b/packages/kit/src/runtime/server/data/index.js @@ -5,6 +5,7 @@ import { load_server_data } from '../page/load_data.js'; import { clarify_devalue_error, handle_error_and_jsonify, serialize_data_node } from '../utils.js'; import { normalize_path } from '../../../utils/url.js'; import { text } from '../../../exports/index.js'; +import * as devalue from 'devalue'; export const INVALIDATED_PARAM = 'x-sveltekit-invalidated'; @@ -115,126 +116,121 @@ export async function render_data( ) ); - try { - /** - * @type {Array} - * The first (and possibly only, if no `defer` used) chunk sent out - */ - const result = []; - /** - * @type {Array<{id: string; node_idx: number; promise: Promise<{id: string; node_idx: number; uses: import('types').Uses; result?: any; error?: any}>}>} - * List of deferred promises, to be resolved after the first chunk is sent - */ - let deferred = []; - - // First process all results in order, and possibly collect and setup deferred promises - for (let node_idx = 0; node_idx < nodes.length; node_idx++) { - const node = nodes[node_idx]; - if (!node || node.type === 'skip' || node.type === 'error' || !node.data) { - result.push(node); - continue; - } + return new Response( + new ReadableStream({ + async start(controller) { + let promise_id = 1; + let count = 0; + let strings = []; - /** @type {Record} */ - let start = {}; - let node_uses_defer = false; - - for (const key in node.data ?? []) { - const entry = node.data[key]; - if (typeof entry === 'object' && typeof entry?.then === 'function') { - node_uses_defer = true; - start[key] = `_deferred_${key}`; - deferred.push({ - id: key, - node_idx, - promise: entry - .then( - /** @param {any} result */ (result) => ({ - id: key, - result, - node_idx, - uses: node.uses - }) - ) - .catch(/** @param {any} error */ (error) => ({ id: key, error, uses: node.uses })) - }); - } else { - start[key] = entry; - } - } + try { + for (const node of nodes) { + console.log('node', count); + let node_count = 0; + let uses_str = ''; - result.push({ - type: 'data', - data: start, - uses: node_uses_defer ? undefined : node.uses, - slash: node.slash - }); - } + const revivers = { + /** @param {any} thing */ + Promise: (thing) => { + if (typeof thing?.then === 'function') { + const id = promise_id++; + count += 1; + node_count += 1; - // Now send the first chunk. If there are no deferred promises, we're done - let json = ''; + thing + .then((d) => ({ d })) + .catch((e) => ({ e })) + .then( + /** + * @param {{d: any; e: any}} result + */ + async ({ d: d, e }) => { + let data; + let error; + try { + data = !e ? devalue.stringify(d, revivers) : undefined; + error = e ? devalue.stringify(e, revivers) : undefined; + } catch (e) { + error = await handle_error_and_jsonify( + event, + options, + new Error(clarify_devalue_error(event, /** @type {any} */ (e))) + ); + } - try { - json = `{"type":"data","nodes":[${result.map(serialize_data_node).join(',')}]}`; - if (!deferred.length) { - return json_response(json); - } - } catch (e) { - const error = /** @type {any} */ (e); - return json_response( - await handle_error_and_jsonify( - event, - options, - new Error(clarify_devalue_error(event, error)) - ), - 500 - ); - } + node_count -= 1; + // only send uses when it's the last chunk of the data node + // so we can be sure all uses are accounted for + let uses = + node_count === 0 + ? undefined + : stringify_uses( + /** @type {import('types').ServerDataNodePreSerialization} */ ( + node + ) + ); + if (uses === uses_str) { + // No change - no need to send it + uses = undefined; + } + + controller.enqueue( + `{"type":"chunk","id":${id}${data ? `,"data":${data}` : ''}${ + error ? `,"error":${error}` : '' + }${uses ? `,"uses":${uses}` : ''}}\n` + ); - return new Response( - new ReadableStream({ - async start(controller) { - controller.enqueue(`${json}\n`); - - // Await all deferred promises and send out the rest of the chunks - while (deferred.length) { - const next = await Promise.race(deferred.map((d) => d.promise)); - deferred = deferred.filter((d) => d.id !== next.id); - controller.enqueue( - serialize_data_node( - /** @type {import('types').ServerDataChunkNode} */ ({ - type: 'chunk', - id: `_deferred_${next.id}`, - data: next.result, - error: next.error, - // only send uses when it's the last chunk of the data node - // so we can be sure all uses are accounted for - uses: deferred.some((d) => d.node_idx === next.node_idx) ? undefined : next.uses - }) - ) + '\n' - ); + count -= 1; + if (count === 0) { + controller.close(); + } + } + ); + + return id; + } + } + }; + + let str = ''; + + if (!node) { + str = 'null'; + } else if (node.type === 'error' || node.type === 'skip') { + str = JSON.stringify(node); + } else { + uses_str = stringify_uses(node); + + str = `{"type":"data","data":${devalue.stringify(node.data, revivers)}${uses_str}${ + node.slash ? `,"slash":${JSON.stringify(node.slash)}` : '' + }}`; + } + + strings.push(str); } + controller.enqueue(`{"type":"data","nodes":[${strings.join(',')}]}\n`); + + if (count === 0) { + controller.close(); + } + } catch (e) { + const error = await handle_error_and_jsonify( + event, + options, + new Error(clarify_devalue_error(event, /** @type {any} */ (e))) + ); + controller.enqueue(error); // TODO should this be .error(..) ? how does frontend know this failed right away? status is 200 controller.close(); } - }), - { - headers: { - 'content-type': 'application/octet-stream' - } } - ); - } catch (e) { - const error = /** @type {any} */ (e); - return json_response( - await handle_error_and_jsonify( - event, - options, - new Error(clarify_devalue_error(event, error)) - ), - 500 - ); - } + }), + { + headers: { + 'content-type': 'application/x-ndjson' + } + } + ); } catch (e) { const error = normalize_error(e); @@ -246,6 +242,27 @@ export async function render_data( } } +/** + * @param {import('types').ServerDataNodePreSerialization} node + */ +function stringify_uses(node) { + const uses = []; + + if (node.uses && node.uses.dependencies.size > 0) { + uses.push(`"dependencies":${JSON.stringify(Array.from(node.uses.dependencies))}`); + } + + if (node.uses && node.uses.params.size > 0) { + uses.push(`"params":${JSON.stringify(Array.from(node.uses.params))}`); + } + + if (node.uses?.parent) uses.push(`"parent":1`); + if (node.uses?.route) uses.push(`"route":1`); + if (node.uses?.url) uses.push(`"url":1`); + + return node.uses ? `,"uses":{${uses.join(',')}}` : ''; +} + /** * @param {Record | string} json * @param {number} [status] diff --git a/packages/kit/types/internal.d.ts b/packages/kit/types/internal.d.ts index 32e3a9532dfd..8b425234e4ab 100644 --- a/packages/kit/types/internal.d.ts +++ b/packages/kit/types/internal.d.ts @@ -208,12 +208,12 @@ export type ServerDataResponse = ServerRedirectNode | ServerNodesResponse; export interface ServerDataNodePreSerialization { type: 'data'; /** - * The serialized version of this contains a `_deferred_${someId}` for any deferred promises, + * The serialized version of this contains a serialized representation of any deferred promises, * which will be resolved later through chunk nodes. */ data: Record | null; /** - * Defined if the `load` function didn't return a `defer`red result. + * Defined if the `load` function didn't return a result containing promises. */ uses?: Uses; slash?: TrailingSlash; @@ -235,7 +235,7 @@ export interface ServerDataNode extends ServerDataNodePreSerialization { export interface ServerDataChunkNode { type: 'chunk'; - id: string; + id: number; data?: Record; error?: any; /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6af153d9384d..95ed4c04574b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -271,7 +271,7 @@ importers: '@types/sade': ^1.7.4 '@types/set-cookie-parser': ^2.4.2 cookie: ^0.5.0 - devalue: ^4.2.3 + devalue: ^4.3.0 esm-env: ^1.0.0 kleur: ^4.1.5 magic-string: ^0.27.0 @@ -292,7 +292,7 @@ importers: '@sveltejs/vite-plugin-svelte': 2.0.0_svelte@3.55.1+vite@4.0.4 '@types/cookie': 0.5.1 cookie: 0.5.0 - devalue: 4.2.3 + devalue: 4.3.0 esm-env: 1.0.0 kleur: 4.1.5 magic-string: 0.27.0 @@ -2196,8 +2196,8 @@ packages: resolution: {integrity: sha512-463v3ZeIrcWtdgIg6vI6XUncguvr2TnGl4SzDXinkt9mSLpBJKXT3mW6xT3VQdDN11+WVs29pgvivTc4Lp8v+w==} engines: {node: '>=8'} - /devalue/4.2.3: - resolution: {integrity: sha512-JG6Q248aN0pgFL57e3zqTVeFraBe+5W2ugvv1mLXsJP6YYIYJhRZhAl7QP8haJrqob6X10F9NEkuCvNILZTPeQ==} + /devalue/4.3.0: + resolution: {integrity: sha512-n94yQo4LI3w7erwf84mhRUkUJfhLoCZiLyoOZ/QFsDbcWNZePrLwbQpvZBUG2TNxwV3VjCKPxkiiQA6pe3TrTA==} dev: false /diff/5.1.0: From 8e07a4a4ec4b0f5608180c9ce7ab8fadcda3b4ef Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Sun, 12 Feb 2023 11:47:07 +0100 Subject: [PATCH 05/62] remove defer --- packages/kit/src/exports/index.js | 10 +------ packages/kit/src/exports/vite/dev/index.js | 3 +- packages/kit/src/runtime/client/client.js | 13 ++++----- packages/kit/src/runtime/control.js | 14 ---------- .../kit/src/runtime/server/page/load_data.js | 19 +++---------- packages/kit/src/utils/promises.js | 8 +----- .../src/routes/defer/server/+page.server.js | 28 +++++++++---------- .../src/routes/defer/server/+page.svelte | 4 +-- .../src/routes/defer/universal/+page.js | 28 +++++++++---------- .../src/routes/defer/universal/+page.svelte | 4 +-- packages/kit/types/index.d.ts | 26 ----------------- 11 files changed, 45 insertions(+), 112 deletions(-) diff --git a/packages/kit/src/exports/index.js b/packages/kit/src/exports/index.js index 45f550201c14..b14a6e9613b9 100644 --- a/packages/kit/src/exports/index.js +++ b/packages/kit/src/exports/index.js @@ -1,4 +1,4 @@ -import { HttpError, Redirect, ActionFailure, Deferred } from '../runtime/control.js'; +import { HttpError, Redirect, ActionFailure } from '../runtime/control.js'; import { BROWSER, DEV } from 'esm-env'; // For some reason we need to type the params as well here, @@ -72,11 +72,3 @@ export function text(body, init) { export function fail(status, data) { return new ActionFailure(status, data); } - -/** - * @template {Record} T - * @param {T} data - */ -export function defer(data) { - return new Deferred(data); -} diff --git a/packages/kit/src/exports/vite/dev/index.js b/packages/kit/src/exports/vite/dev/index.js index 01c7345bcc41..5d7ca57ff606 100644 --- a/packages/kit/src/exports/vite/dev/index.js +++ b/packages/kit/src/exports/vite/dev/index.js @@ -339,8 +339,7 @@ export async function dev(vite, vite_config, svelte_config) { control_module_node.replace_implementations({ ActionFailure: control_module_vite.ActionFailure, HttpError: control_module_vite.HttpError, - Redirect: control_module_vite.Redirect, - Deferred: control_module_vite.Deferred + Redirect: control_module_vite.Redirect }); } align_exports(); diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 854e29d8cc84..c41043b50ff8 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -28,7 +28,7 @@ import { parse } from './parse.js'; import Root from '__GENERATED__/root.svelte'; import { nodes, server_loads, dictionary, matchers, hooks } from '__CLIENT__/manifest.js'; import { base } from '$internal/paths'; -import { Deferred, HttpError, Redirect } from '../control.js'; +import { HttpError, Redirect } from '../control.js'; import { stores } from './singletons.js'; import { unwrap_promises } from '../../utils/promises.js'; import * as devalue from 'devalue'; @@ -662,15 +662,14 @@ export function create_client({ target }) { try { lock_fetch(); data = (await node.universal.load.call(null, load_input)) ?? null; - const to_check = data instanceof Deferred ? data.data : data; - if (to_check != null && Object.getPrototypeOf(to_check) !== Object.prototype) { + if (data != null && Object.getPrototypeOf(data) !== Object.prototype) { throw new Error( `a load function related to route '${route.id}' returned ${ - typeof to_check !== 'object' - ? `a ${typeof to_check}` - : to_check instanceof Response + typeof data !== 'object' + ? `a ${typeof data}` + : data instanceof Response ? 'a Response object' - : Array.isArray(to_check) + : Array.isArray(data) ? 'an array' : 'a non-plain object' }, but must return a plain object at the top level (i.e. \`return {...}\`)` diff --git a/packages/kit/src/runtime/control.js b/packages/kit/src/runtime/control.js index ddd6271317fb..87e0dcc2b2c5 100644 --- a/packages/kit/src/runtime/control.js +++ b/packages/kit/src/runtime/control.js @@ -44,18 +44,6 @@ export let ActionFailure = class ActionFailure { } }; -/** - * @template {Record} T - */ -export let Deferred = class Deferred { - /** - * @param {T} data - */ - constructor(data) { - this.data = data; - } -}; - /** * This is a grotesque hack that, in dev, allows us to replace the implementations * of these classes that you'd get by importing them from `@sveltejs/kit` with the @@ -66,12 +54,10 @@ export let Deferred = class Deferred { * ActionFailure: typeof ActionFailure; * HttpError: typeof HttpError; * Redirect: typeof Redirect; - * Deferred: typeof Deferred; * }} implementations */ export function replace_implementations(implementations) { ActionFailure = implementations.ActionFailure; HttpError = implementations.HttpError; Redirect = implementations.Redirect; - Deferred = implementations.Deferred; } diff --git a/packages/kit/src/runtime/server/page/load_data.js b/packages/kit/src/runtime/server/page/load_data.js index 36ef10bb952d..26a652967d7d 100644 --- a/packages/kit/src/runtime/server/page/load_data.js +++ b/packages/kit/src/runtime/server/page/load_data.js @@ -1,6 +1,5 @@ import { disable_search, make_trackable } from '../../../utils/url.js'; import { unwrap_promises } from '../../../utils/promises.js'; -import { Deferred } from '../../control.js'; /** * Calls the user's server `load` function. @@ -65,19 +64,14 @@ export async function load_server_data({ event, state, node, parent }) { url }); - const data = result - ? await unwrap_promises( - // Don't defer if we're prerendering - state.prerendering && result instanceof Deferred ? result.data : result - ) - : null; + const data = result ? await unwrap_promises(result) : null; if (__SVELTEKIT_DEV__) { validate_load_response(data, /** @type {string} */ (event.route.id)); } return { type: 'data', - data: data instanceof Deferred ? data.data : data, + data, uses, slash: node.server.trailingSlash }; @@ -124,17 +118,12 @@ export async function load_data({ parent }); - const data = result - ? await unwrap_promises( - // Don't defer if we're prerendering - state.prerendering && result instanceof Deferred ? result.data : result - ) - : null; + const data = result ? await unwrap_promises(result) : null; if (__SVELTEKIT_DEV__) { validate_load_response(data, /** @type {string} */ (event.route.id)); } - return data instanceof Deferred ? data.data : data; + return data; } /** diff --git a/packages/kit/src/utils/promises.js b/packages/kit/src/utils/promises.js index 0e2fa548c3fa..ac2b53a60dd4 100644 --- a/packages/kit/src/utils/promises.js +++ b/packages/kit/src/utils/promises.js @@ -1,16 +1,10 @@ -import { Deferred } from '../runtime/control.js'; - /** * Given an object, return a new object where all top level values are awaited * - * @param {Record | Deferred>} object + * @param {Record} object * @returns {Promise>} */ export async function unwrap_promises(object) { - if (object instanceof Deferred) { - return object.data; - } - for (const key in object) { if (typeof object[key]?.then === 'function') { return Object.fromEntries( diff --git a/packages/kit/test/apps/basics/src/routes/defer/server/+page.server.js b/packages/kit/test/apps/basics/src/routes/defer/server/+page.server.js index ec0d967c140a..79e96acc2baa 100644 --- a/packages/kit/test/apps/basics/src/routes/defer/server/+page.server.js +++ b/packages/kit/test/apps/basics/src/routes/defer/server/+page.server.js @@ -1,17 +1,17 @@ -import { defer } from '@sveltejs/kit'; - export function load() { - return defer({ + return { eager: 'eager', - success: new Promise((resolve) => { - setTimeout(() => { - resolve('success'); - }, 1000); - }), - fail: new Promise((_, reject) => { - setTimeout(() => { - reject('fail'); - }, 1000); - }) - }); + lazy: { + success: new Promise((resolve) => { + setTimeout(() => { + resolve('success'); + }, 1000); + }), + fail: new Promise((_, reject) => { + setTimeout(() => { + reject('fail'); + }, 1000); + }) + } + }; } diff --git a/packages/kit/test/apps/basics/src/routes/defer/server/+page.svelte b/packages/kit/test/apps/basics/src/routes/defer/server/+page.svelte index f85387c3c1f9..f88e161a4bc9 100644 --- a/packages/kit/test/apps/basics/src/routes/defer/server/+page.svelte +++ b/packages/kit/test/apps/basics/src/routes/defer/server/+page.svelte @@ -5,13 +5,13 @@

{data.eager}

-{#await data.success} +{#await data.lazy.success}

Loading success

{:then result}

{result}

{/await} -{#await data.fail} +{#await data.lazy.fail}

Loading fail

{:catch result}

{result}

diff --git a/packages/kit/test/apps/basics/src/routes/defer/universal/+page.js b/packages/kit/test/apps/basics/src/routes/defer/universal/+page.js index ec0d967c140a..79e96acc2baa 100644 --- a/packages/kit/test/apps/basics/src/routes/defer/universal/+page.js +++ b/packages/kit/test/apps/basics/src/routes/defer/universal/+page.js @@ -1,17 +1,17 @@ -import { defer } from '@sveltejs/kit'; - export function load() { - return defer({ + return { eager: 'eager', - success: new Promise((resolve) => { - setTimeout(() => { - resolve('success'); - }, 1000); - }), - fail: new Promise((_, reject) => { - setTimeout(() => { - reject('fail'); - }, 1000); - }) - }); + lazy: { + success: new Promise((resolve) => { + setTimeout(() => { + resolve('success'); + }, 1000); + }), + fail: new Promise((_, reject) => { + setTimeout(() => { + reject('fail'); + }, 1000); + }) + } + }; } diff --git a/packages/kit/test/apps/basics/src/routes/defer/universal/+page.svelte b/packages/kit/test/apps/basics/src/routes/defer/universal/+page.svelte index f85387c3c1f9..f88e161a4bc9 100644 --- a/packages/kit/test/apps/basics/src/routes/defer/universal/+page.svelte +++ b/packages/kit/test/apps/basics/src/routes/defer/universal/+page.svelte @@ -5,13 +5,13 @@

{data.eager}

-{#await data.success} +{#await data.lazy.success}

Loading success

{:then result}

{result}

{/await} -{#await data.fail} +{#await data.lazy.fail}

Loading fail

{:catch result}

{result}

diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 5b29cada0a45..2fe1fd45d539 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -1233,32 +1233,6 @@ export interface SubmitFunction< >; } -/** - * Enables defering parts of the data loading and showing the next page quicker. Promises will not be awaited, allowing you to show - * meaningful fallback UI using Svelte's `{#await}` syntax while the rest of the data is loading. - * - * ```js - * /// file: +page.js - * import { defer } from '@sveltejs/kit'; - * - * /// type: import('./$types').PageLoad - * export function load({ fetch }) { - * const fast = fetch('/api/responds/quickly'); - * const slow = fetch('/api/takes/a/while'); - * return defer({ - * fast: await fast, - * slow - * }); - * } - * ``` - * @param data A regular JavaScript object. Top-level promises will be not be awaited. - */ -export function defer>(data: T): Deferred; - -export interface Deferred> extends UniqueInterface { - data: T; -} - /** * The type of `export const snapshot` exported from a page or layout component. */ From 62a28bbb8c92583a46e892d9132ba357cfcd3bf0 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Sun, 12 Feb 2023 12:15:32 +0100 Subject: [PATCH 06/62] deduplicate --- packages/kit/src/runtime/client/client.js | 81 ++++++++--------------- 1 file changed, 26 insertions(+), 55 deletions(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index c41043b50ff8..916e89edad85 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -1768,6 +1768,29 @@ async function load_data(url, invalid) { const reader = /** @type {ReadableStream} */ (res.body).getReader(); const decoder = new TextDecoder(); + /** + * @param {any} data + * @param {import('types').Uses} uses + */ + function deserialize(data, uses) { + return devalue.unflatten(data, { + Promise: (id) => { + /** @type {any} */ + const obj = { + id, + resolve: undefined, + reject: undefined, + uses + }; + pending.set(id, obj); + return new Promise((f, r) => { + obj.resolve = f; + obj.reject = r; + }); + } + }); + } + let text = ''; while (true) { @@ -1795,22 +1818,7 @@ async function load_data(url, invalid) { node.nodes?.forEach((/** @type {any} */ node) => { if (node?.type === 'data') { node.uses = deserialize_uses(node.uses); - node.data = devalue.unflatten(node.data, { - Promise: (id) => { - /** @type {any} */ - const obj = { - id, - resolve: undefined, - reject: undefined, - uses: node.uses - }; - pending.set(id, obj); - return new Promise((f, r) => { - obj.resolve = f; - obj.reject = r; - }); - } - }); + node.data = deserialize(node.data, node.uses); } }); @@ -1822,46 +1830,9 @@ async function load_data(url, invalid) { // Shouldn't ever be undefined, but just in case if (entry) { if (error) { - entry.reject( - devalue.unflatten(error, { - Promise: (id) => { - /** @type {any} */ - const obj = { - id, - resolve: undefined, - reject: undefined, - uses: entry.uses - }; - pending.set(id, obj); - return new Promise((f, r) => { - obj.resolve = f; - obj.reject = r; - }); - } - }) - ); + entry.reject(deserialize(error, entry.uses)); } else { - entry.resolve( - devalue.unflatten(data, { - Promise: (id) => { - /** @type {(v: any) => void} */ - let resolve; - /** @type {(v: any) => void} */ - let reject; - pending.set(id, { - // @ts-expect-error TS doesnt know this is set - resolve, - // @ts-expect-error TS doesnt know this is set - reject, - uses: entry.uses - }); - return new Promise((f, r) => { - resolve = f; - reject = r; - }); - } - }) - ); + entry.resolve(deserialize(data, entry.uses)); } if (uses) { uses = deserialize_uses(uses); From 3ef481401c5945bd416cc85ff8b56324daffdee6 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Mon, 13 Feb 2023 09:50:13 +0100 Subject: [PATCH 07/62] extract common logic into generator and reuse --- packages/kit/src/runtime/server/data/index.js | 262 +++++++++++------- packages/kit/src/runtime/server/page/index.js | 17 +- packages/kit/src/runtime/server/utils.js | 38 --- 3 files changed, 167 insertions(+), 150 deletions(-) diff --git a/packages/kit/src/runtime/server/data/index.js b/packages/kit/src/runtime/server/data/index.js index c93230f2b8f5..21809756f76f 100644 --- a/packages/kit/src/runtime/server/data/index.js +++ b/packages/kit/src/runtime/server/data/index.js @@ -2,7 +2,7 @@ import { HttpError, Redirect } from '../../control.js'; import { normalize_error } from '../../../utils/error.js'; import { once } from '../../../utils/functions.js'; import { load_server_data } from '../page/load_data.js'; -import { clarify_devalue_error, handle_error_and_jsonify, serialize_data_node } from '../utils.js'; +import { clarify_devalue_error, handle_error_and_jsonify } from '../utils.js'; import { normalize_path } from '../../../utils/url.js'; import { text } from '../../../exports/index.js'; import * as devalue from 'devalue'; @@ -116,118 +116,26 @@ export async function render_data( ) ); + const chunks = get_data_json(event, options, nodes); + const { value: first } = await chunks.next(); + if (first?.type === 'error') { + return json_response(first.data, 500); + } + return new Response( new ReadableStream({ async start(controller) { - let promise_id = 1; - let count = 0; - let strings = []; - - try { - for (const node of nodes) { - console.log('node', count); - let node_count = 0; - let uses_str = ''; - - const revivers = { - /** @param {any} thing */ - Promise: (thing) => { - if (typeof thing?.then === 'function') { - const id = promise_id++; - count += 1; - node_count += 1; - - thing - .then((d) => ({ d })) - .catch((e) => ({ e })) - .then( - /** - * @param {{d: any; e: any}} result - */ - async ({ d: d, e }) => { - let data; - let error; - try { - data = !e ? devalue.stringify(d, revivers) : undefined; - error = e ? devalue.stringify(e, revivers) : undefined; - } catch (e) { - error = await handle_error_and_jsonify( - event, - options, - new Error(clarify_devalue_error(event, /** @type {any} */ (e))) - ); - } - - node_count -= 1; - // only send uses when it's the last chunk of the data node - // so we can be sure all uses are accounted for - let uses = - node_count === 0 - ? undefined - : stringify_uses( - /** @type {import('types').ServerDataNodePreSerialization} */ ( - node - ) - ); - if (uses === uses_str) { - // No change - no need to send it - uses = undefined; - } - - controller.enqueue( - `{"type":"chunk","id":${id}${data ? `,"data":${data}` : ''}${ - error ? `,"error":${error}` : '' - }${uses ? `,"uses":${uses}` : ''}}\n` - ); - - count -= 1; - if (count === 0) { - controller.close(); - } - } - ); - - return id; - } - } - }; - - let str = ''; - - if (!node) { - str = 'null'; - } else if (node.type === 'error' || node.type === 'skip') { - str = JSON.stringify(node); - } else { - uses_str = stringify_uses(node); - - str = `{"type":"data","data":${devalue.stringify(node.data, revivers)}${uses_str}${ - node.slash ? `,"slash":${JSON.stringify(node.slash)}` : '' - }}`; - } - - strings.push(str); - } - - controller.enqueue(`{"type":"data","nodes":[${strings.join(',')}]}\n`); - - if (count === 0) { - controller.close(); - } - } catch (e) { - const error = await handle_error_and_jsonify( - event, - options, - new Error(clarify_devalue_error(event, /** @type {any} */ (e))) - ); - controller.enqueue(error); // TODO should this be .error(..) ? how does frontend know this failed right away? status is 200 - controller.close(); + controller.enqueue(/** @type {NonNullable} */ (first).data); + for await (const next of chunks) { + controller.enqueue(next.data); } + controller.close(); } }), { headers: { - 'content-type': 'application/x-ndjson' + 'content-type': 'application/x-ndjson', + 'cache-control': 'private, no-store' } } ); @@ -286,3 +194,147 @@ export function redirect_json_response(redirect) { location: redirect.location }); } + +/** + * @param {import('types').RequestEvent} event + * @param {import('types').SSROptions} options + * @param {Array} nodes + */ +export async function* get_data_json(event, options, nodes) { + // This method is necessary because we can't yield from inside a callback, + // so we smooth other the internal less ergonomic callback API + /** @type {(v: any) => void} */ + let resolve; + let promise = + /** @type {Promise<{ result: {type: 'chunk' | 'error'; data: any}; done: boolean }>} */ ( + new Promise((r) => { + resolve = r; + }) + ); + // Ensure it runs after we enter the loop to not swallow the first eager result + Promise.resolve().then(() => + _get_data_json(event, options, nodes, (result, done) => { + resolve({ result, done }); + if (!done) { + promise = new Promise((r) => { + resolve = r; + }); + } + }) + ); + + while (true) { + const { result, done } = await promise; + yield result; + if (done) return undefined; + } +} + +/** + * @param {import('types').RequestEvent} event + * @param {import('types').SSROptions} options + * @param {Array} nodes + * @param {(result: {type: 'chunk' | 'error'; data: any}, done: boolean) => void} next + */ +async function _get_data_json(event, options, nodes, next) { + let promise_id = 1; + let count = 0; + let strings = []; + + try { + for (const node of nodes) { + let node_count = 0; + let uses_str = ''; + + const revivers = { + /** @param {any} thing */ + Promise: (thing) => { + if (typeof thing?.then === 'function') { + const id = promise_id++; + count += 1; + node_count += 1; + + thing + .then(/** @param {any} d */ (d) => ({ d })) + .catch(/** @param {any} e */ (e) => ({ e })) + .then( + /** + * @param {{d: any; e: any}} result + */ + async ({ d: d, e }) => { + let data; + let error; + try { + if (e) { + error = devalue.stringify(e, revivers); + } else { + data = devalue.stringify(d, revivers); + } + } catch (e) { + error = await handle_error_and_jsonify( + event, + options, + new Error(clarify_devalue_error(event, /** @type {any} */ (e))) + ); + } + + node_count -= 1; + // only send uses when it's the last chunk of the data node + // so we can be sure all uses are accounted for + let uses = + node_count === 0 + ? undefined + : stringify_uses( + /** @type {import('types').ServerDataNodePreSerialization} */ (node) + ); + if (uses === uses_str) { + // No change - no need to send it + uses = undefined; + } + + count -= 1; + + next( + { + type: 'chunk', + data: `{"type":"chunk","id":${id}${data ? `,"data":${data}` : ''}${ + error ? `,"error":${error}` : '' + }${uses ? `,"uses":${uses}` : ''}}\n` + }, + count === 0 + ); + } + ); + + return id; + } + } + }; + + let str = ''; + + if (!node) { + str = 'null'; + } else if (node.type === 'error' || node.type === 'skip') { + str = JSON.stringify(node); + } else { + uses_str = stringify_uses(node); + + str = `{"type":"data","data":${devalue.stringify(node.data, revivers)}${uses_str}${ + node.slash ? `,"slash":${JSON.stringify(node.slash)}` : '' + }}`; + } + + strings.push(str); + } + + next({ type: 'chunk', data: `{"type":"data","nodes":[${strings.join(',')}]}\n` }, count === 0); + } catch (e) { + const error = await handle_error_and_jsonify( + event, + options, + new Error(clarify_devalue_error(event, /** @type {any} */ (e))) + ); + next({ type: 'error', data: error }, true); // TODO should this be .error(..) ? how does frontend know this failed right away? status is 200 + } +} diff --git a/packages/kit/src/runtime/server/page/index.js b/packages/kit/src/runtime/server/page/index.js index 8e35e66dfce1..2bd54bf0e88c 100644 --- a/packages/kit/src/runtime/server/page/index.js +++ b/packages/kit/src/runtime/server/page/index.js @@ -3,12 +3,7 @@ import { compact } from '../../../utils/array.js'; import { normalize_error } from '../../../utils/error.js'; import { add_data_suffix } from '../../../utils/url.js'; import { HttpError, Redirect } from '../../control.js'; -import { - redirect_response, - static_error_page, - handle_error_and_jsonify, - serialize_data_node -} from '../utils.js'; +import { redirect_response, static_error_page, handle_error_and_jsonify } from '../utils.js'; import { handle_action_json_request, handle_action_request, @@ -19,6 +14,7 @@ import { load_data, load_server_data } from './load_data.js'; import { render_response } from './render.js'; import { respond_with_error } from './respond_with_error.js'; import { get_option } from '../../../utils/options.js'; +import { get_data_json } from '../data/index.js'; /** * @param {import('types').RequestEvent} event @@ -291,7 +287,14 @@ export async function render_page(event, route, page, options, manifest, state, if (state.prerendering && should_prerender_data) { // ndjson format - const body = branch.map((node) => serialize_data_node(node?.server_data)).join('\n') + '\n'; + let body = ''; + for await (const node of get_data_json( + event, + options, + branch.map((node) => node?.server_data) + )) { + body += node.data; + } state.prerendering.dependencies.set(data_pathname, { response: text(body), diff --git a/packages/kit/src/runtime/server/utils.js b/packages/kit/src/runtime/server/utils.js index 943697008fbc..8186be6fee0d 100644 --- a/packages/kit/src/runtime/server/utils.js +++ b/packages/kit/src/runtime/server/utils.js @@ -147,41 +147,3 @@ export function clarify_devalue_error(event, error) { // belt and braces — this should never happen return error.message; } - -/** @param {import('types').ServerDataNodePreSerialization | import('types').ServerDataSkippedNode | import('types').ServerErrorNode | import('types').ServerDataChunkNode | null | undefined} node */ -export function serialize_data_node(node) { - if (!node) return 'null'; - - if (node.type === 'error' || node.type === 'skip') { - return JSON.stringify(node); - } - const uses = []; - - if (node.uses && node.uses.dependencies.size > 0) { - uses.push(`"dependencies":${JSON.stringify(Array.from(node.uses.dependencies))}`); - } - - if (node.uses && node.uses.params.size > 0) { - uses.push(`"params":${JSON.stringify(Array.from(node.uses.params))}`); - } - - if (node.uses?.parent) uses.push(`"parent":1`); - if (node.uses?.route) uses.push(`"route":1`); - if (node.uses?.url) uses.push(`"url":1`); - - const uses_str = node.uses ? `,"uses":{${uses.join(',')}}` : ''; - - if (node.type === 'data') { - return `{"type":"data","data":${devalue.stringify(node.data)}${uses_str}${ - node.slash ? `,"slash":${JSON.stringify(node.slash)}` : '' - }}`; - } else { - if (node.error) { - return `{"type":"chunk","id":"${node.id}","error":${devalue.stringify( - node.error - )}${uses_str}}`; - } else { - return `{"type":"chunk","id":"${node.id}","data":${devalue.stringify(node.data)}${uses_str}}`; - } - } -} From dae3ce7135dc3b9813fc0c962e6bfc0b3cf5bff2 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Mon, 13 Feb 2023 17:44:39 +0100 Subject: [PATCH 08/62] ssr --- packages/kit/src/runtime/server/data/index.js | 86 ++------ .../kit/src/runtime/server/page/render.js | 188 ++++++++++++++---- packages/kit/src/runtime/server/utils.js | 21 ++ packages/kit/src/utils/generators.js | 47 +++++ .../kit/test/apps/basics/test/client.test.js | 56 ++++++ packages/kit/test/apps/basics/test/test.js | 38 ---- packages/kit/types/index.d.ts | 4 - 7 files changed, 289 insertions(+), 151 deletions(-) create mode 100644 packages/kit/src/utils/generators.js diff --git a/packages/kit/src/runtime/server/data/index.js b/packages/kit/src/runtime/server/data/index.js index 21809756f76f..2a7399327d10 100644 --- a/packages/kit/src/runtime/server/data/index.js +++ b/packages/kit/src/runtime/server/data/index.js @@ -2,10 +2,11 @@ import { HttpError, Redirect } from '../../control.js'; import { normalize_error } from '../../../utils/error.js'; import { once } from '../../../utils/functions.js'; import { load_server_data } from '../page/load_data.js'; -import { clarify_devalue_error, handle_error_and_jsonify } from '../utils.js'; +import { clarify_devalue_error, handle_error_and_jsonify, stringify_uses } from '../utils.js'; import { normalize_path } from '../../../utils/url.js'; import { text } from '../../../exports/index.js'; import * as devalue from 'devalue'; +import { to_generator } from '../../../utils/generators.js'; export const INVALIDATED_PARAM = 'x-sveltekit-invalidated'; @@ -118,8 +119,9 @@ export async function render_data( const chunks = get_data_json(event, options, nodes); const { value: first } = await chunks.next(); - if (first?.type === 'error') { - return json_response(first.data, 500); + + if (!first?.has_more) { + return json_response(/** @type {NonNullable} */ (first).data); } return new Response( @@ -150,27 +152,6 @@ export async function render_data( } } -/** - * @param {import('types').ServerDataNodePreSerialization} node - */ -function stringify_uses(node) { - const uses = []; - - if (node.uses && node.uses.dependencies.size > 0) { - uses.push(`"dependencies":${JSON.stringify(Array.from(node.uses.dependencies))}`); - } - - if (node.uses && node.uses.params.size > 0) { - uses.push(`"params":${JSON.stringify(Array.from(node.uses.params))}`); - } - - if (node.uses?.parent) uses.push(`"parent":1`); - if (node.uses?.route) uses.push(`"route":1`); - if (node.uses?.url) uses.push(`"url":1`); - - return node.uses ? `,"uses":{${uses.join(',')}}` : ''; -} - /** * @param {Record | string} json * @param {number} [status] @@ -195,46 +176,15 @@ export function redirect_json_response(redirect) { }); } -/** - * @param {import('types').RequestEvent} event - * @param {import('types').SSROptions} options - * @param {Array} nodes - */ -export async function* get_data_json(event, options, nodes) { - // This method is necessary because we can't yield from inside a callback, - // so we smooth other the internal less ergonomic callback API - /** @type {(v: any) => void} */ - let resolve; - let promise = - /** @type {Promise<{ result: {type: 'chunk' | 'error'; data: any}; done: boolean }>} */ ( - new Promise((r) => { - resolve = r; - }) - ); - // Ensure it runs after we enter the loop to not swallow the first eager result - Promise.resolve().then(() => - _get_data_json(event, options, nodes, (result, done) => { - resolve({ result, done }); - if (!done) { - promise = new Promise((r) => { - resolve = r; - }); - } - }) - ); - - while (true) { - const { result, done } = await promise; - yield result; - if (done) return undefined; - } -} +export const get_data_json = to_generator(_get_data_json); /** + * The first chunk returns the devalue'd nodes with potentially pending promises. + * Subsequent chunks (if any) return the resolved promises. * @param {import('types').RequestEvent} event * @param {import('types').SSROptions} options * @param {Array} nodes - * @param {(result: {type: 'chunk' | 'error'; data: any}, done: boolean) => void} next + * @param {(result: {has_more: boolean; data: any}, done: boolean) => void} next */ async function _get_data_json(event, options, nodes, next) { let promise_id = 1; @@ -296,10 +246,10 @@ async function _get_data_json(event, options, nodes, next) { next( { - type: 'chunk', + has_more: count !== 0, data: `{"type":"chunk","id":${id}${data ? `,"data":${data}` : ''}${ error ? `,"error":${error}` : '' - }${uses ? `,"uses":${uses}` : ''}}\n` + }${uses ? `,${uses}` : ''}}\n` }, count === 0 ); @@ -320,7 +270,7 @@ async function _get_data_json(event, options, nodes, next) { } else { uses_str = stringify_uses(node); - str = `{"type":"data","data":${devalue.stringify(node.data, revivers)}${uses_str}${ + str = `{"type":"data","data":${devalue.stringify(node.data, revivers)},${uses_str}${ node.slash ? `,"slash":${JSON.stringify(node.slash)}` : '' }}`; } @@ -328,13 +278,11 @@ async function _get_data_json(event, options, nodes, next) { strings.push(str); } - next({ type: 'chunk', data: `{"type":"data","nodes":[${strings.join(',')}]}\n` }, count === 0); - } catch (e) { - const error = await handle_error_and_jsonify( - event, - options, - new Error(clarify_devalue_error(event, /** @type {any} */ (e))) + next( + { has_more: count !== 0, data: `{"type":"data","nodes":[${strings.join(',')}]}\n` }, + count === 0 ); - next({ type: 'error', data: error }, true); // TODO should this be .error(..) ? how does frontend know this failed right away? status is 200 + } catch (e) { + throw new Error(clarify_devalue_error(event, /** @type {any} */ (e))); } } diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js index 12a49c4b4ecb..fb61251d7103 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -7,9 +7,10 @@ import { serialize_data } from './serialize_data.js'; import { s } from '../../../utils/misc.js'; import { Csp } from './csp.js'; import { uneval_action_response } from './actions.js'; -import { clarify_devalue_error } from '../utils.js'; +import { clarify_devalue_error, stringify_uses } from '../utils.js'; import { version, public_env } from '../../shared.js'; import { text } from '../../../exports/index.js'; +import { to_generator } from '../../../utils/generators.js'; // TODO rename this function/module @@ -200,39 +201,13 @@ export async function render_response({ return `${resolved_assets}/${path}`; }; - const serialized = { data: '', form: 'null', error: 'null' }; - - try { - serialized.data = `[${branch - .map(({ server_data }) => { - if (server_data?.type === 'data') { - const data = devalue.uneval(server_data.data); - - const uses = []; - if (server_data.uses.dependencies.size > 0) { - uses.push(`dependencies:${s(Array.from(server_data.uses.dependencies))}`); - } - - if (server_data.uses.params.size > 0) { - uses.push(`params:${s(Array.from(server_data.uses.params))}`); - } - - if (server_data.uses.parent) uses.push(`parent:1`); - if (server_data.uses.route) uses.push(`route:1`); - if (server_data.uses.url) uses.push(`url:1`); - - return `{type:"data",data:${data},uses:{${uses.join(',')}}${ - server_data.slash ? `,slash:${s(server_data.slash)}` : '' - }}`; - } - - return s(server_data); - }) - .join(',')}]`; - } catch (e) { - const error = /** @type {any} */ (e); - throw new Error(clarify_devalue_error(event, error)); - } + const data_chunks = get_data( + event, + branch.map((b) => b.server_data) + ); + const { value } = await data_chunks.next(); + const { data, has_more: needs_streaming } = /** @type {NonNullable} */ (value); + const serialized = { data, form: 'null', error: 'null' }; if (form_value) { serialized.form = uneval_action_response(form_value, /** @type {string} */ (event.route.id)); @@ -316,10 +291,31 @@ export async function render_response({ opts.push(`hydrate: {\n\t\t\t\t\t${hydrate.join(',\n\t\t\t\t\t')}\n\t\t\t\t}`); } - // prettier-ignore + const streaming = needs_streaming + ? `window.$__sveltekit__ = window.$__sveltekit__ || {}; + + const deferred = new Map(); + + $__sveltekit__.defer = (id) => new Promise((fulfil, reject) => { + deferred.set(id, { fulfil, reject }); + }); + + $__sveltekit__.resolve = ({ id, data, error, uses }) => { + const { fulfil, reject } = deferred.get(id); + deferred.delete(id); + + if (error) reject(error); + else fulfil(data); + + if (uses) { + // TODO + } + };` + : ''; + const init_app = ` import { start } from ${s(prefixed(entry.file))}; - + ${streaming} start({ ${opts.join(',\n\t\t\t\t')} }); @@ -442,8 +438,120 @@ export async function render_response({ } } - return text(transformed, { - status, - headers - }); + return !needs_streaming + ? text(transformed, { + status, + headers + }) + : new Response( + new ReadableStream({ + async start(controller) { + controller.enqueue(transformed); + for await (const next of data_chunks) { + controller.enqueue(next.data); + } + controller.close(); + } + }), + { + headers: { + 'content-type': 'text/html' + } + } + ); +} + +const get_data = to_generator(_get_data); + +/** + * The first chunk returns the unevaluated data nodes, potentially with promise placeholders. + * Subsequent chunks (if any) return script tags that resolve those promises. + * @param {import('types').RequestEvent} event + * @param {Array} nodes + * @param {(result: {has_more: boolean; data: string}, done: boolean) => void} next + */ +async function _get_data(event, nodes, next) { + let promise_id = 1; + let count = 0; + let strings = []; + + try { + for (const node of nodes) { + let node_count = 0; + let uses_str = ''; + + const replacer = + /** @param {any} thing */ + (thing) => { + if (typeof thing?.then === 'function') { + const id = promise_id++; + count += 1; + node_count += 1; + + thing + .then(/** @param {any} data */ (data) => ({ data })) + .catch(/** @param {any} error */ (error) => ({ error })) + .then( + /** + * @param {{data: any; error: any}} result + */ + async ({ data, error }) => { + node_count -= 1; + // only send uses when it's the last chunk of the data node + // so we can be sure all uses are accounted for + const uses = + node_count === 0 + ? undefined + : stringify_uses( + /** @type {import('types').ServerDataNodePreSerialization} */ (node) + ) === uses_str + ? // No change - no need to send it + undefined + : node?.uses; + + count -= 1; + + let str; + try { + str = devalue.uneval({ id, data, error, uses }, replacer); + } catch (e) { + error = `new Error(${clarify_devalue_error(event, /** @type {any} */ (e))});`; + data = undefined; + str = devalue.uneval({ id, data, error, uses }, replacer); + } + + next( + { + has_more: count !== 0, + // Needs to be a module script tag or else it's executed before the start script + data: `` + }, + count === 0 + ); + } + ); + + return `$__sveltekit__.defer(${id})`; + } + }; + + let str = ''; + + if (!node) { + str = 'null'; + } else { + uses_str = stringify_uses(node); + + str = `{"type":"data","data":${devalue.uneval(node.data, replacer)},${uses_str}${ + node.slash ? `,"slash":${JSON.stringify(node.slash)}` : '' + }}`; + } + + strings.push(str); + } + + next({ has_more: count !== 0, data: `[${strings.join(',')}]` }, count === 0); + } catch (e) { + throw new Error(clarify_devalue_error(event, /** @type {any} */ (e))); + } } diff --git a/packages/kit/src/runtime/server/utils.js b/packages/kit/src/runtime/server/utils.js index 8186be6fee0d..91a967936d46 100644 --- a/packages/kit/src/runtime/server/utils.js +++ b/packages/kit/src/runtime/server/utils.js @@ -147,3 +147,24 @@ export function clarify_devalue_error(event, error) { // belt and braces — this should never happen return error.message; } + +/** + * @param {import('types').ServerDataNodePreSerialization} node + */ +export function stringify_uses(node) { + const uses = []; + + if (node.uses && node.uses.dependencies.size > 0) { + uses.push(`"dependencies":${JSON.stringify(Array.from(node.uses.dependencies))}`); + } + + if (node.uses && node.uses.params.size > 0) { + uses.push(`"params":${JSON.stringify(Array.from(node.uses.params))}`); + } + + if (node.uses?.parent) uses.push(`"parent":1`); + if (node.uses?.route) uses.push(`"route":1`); + if (node.uses?.url) uses.push(`"url":1`); + + return `"uses":{${uses.join(',')}}`; +} diff --git a/packages/kit/src/utils/generators.js b/packages/kit/src/utils/generators.js new file mode 100644 index 000000000000..fcce7dc9a38c --- /dev/null +++ b/packages/kit/src/utils/generators.js @@ -0,0 +1,47 @@ +/** + * This method is necessary because we can't yield from inside a callback, + * so we smooth other an internal less ergonomic callback API + * @template {(...args: any) => void} Fn + * @param {Fn} fn + * @returns {Parameters extends [...infer InitPs, (value: infer Value, done: boolean) => void] ? (...args: InitPs) => AsyncGenerator : never} + */ +export function to_generator(fn) { + // @ts-ignore yeah TS this return type is fucked up, I know + return async function* (...args) { + /** @type {(v: any) => void} */ + let fulfill; + /** @type {(v: any) => void} */ + let reject; + let promise = new Promise((f, r) => { + fulfill = f; + reject = r; + }); + // Ensure it runs after we enter the loop to not swallow the first eager result + Promise.resolve() + .then(() => + fn( + ...args, + /** + *@param {any} result + * @param {boolean} done + */ + (result, done) => { + fulfill({ result, done }); + if (!done) { + promise = new Promise((r) => { + fulfill = r; + }); + } + } + ) + ) + // catch and rethrow to avoid unhandled promise rejection + .catch((e) => reject(e)); + + while (true) { + const { result, done } = await promise; + yield result; + if (done) return undefined; + } + }; +} diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js index 086452215eef..5f4f23b5f16a 100644 --- a/packages/kit/test/apps/basics/test/client.test.js +++ b/packages/kit/test/apps/basics/test/client.test.js @@ -671,3 +671,59 @@ test.describe('Snapshots', () => { expect(await page.locator('input').inputValue()).toBe('works for reloads'); }); }); + +test.describe('defer', () => { + test('Works for universal load functions (direct hit)', async ({ page }) => { + page.goto('/defer/universal'); + + await expect(page.locator('p.eager')).toHaveText('eager'); + expect(page.locator('p.loadingsuccess')).toBeVisible(); + expect(page.locator('p.loadingfail')).toBeVisible(); + + await expect(page.locator('p.success')).toHaveText('success'); + await expect(page.locator('p.fail')).toHaveText('fail'); + expect(page.locator('p.loadingsuccess')).toBeHidden(); + expect(page.locator('p.loadingfail')).toBeHidden(); + }); + + test('Works for universal load functions (client nav)', async ({ page }) => { + await page.goto('/defer'); + page.click('[href="/defer/universal"]'); + + await expect(page.locator('p.eager')).toHaveText('eager'); + expect(page.locator('p.loadingsuccess')).toBeVisible(); + expect(page.locator('p.loadingfail')).toBeVisible(); + + await expect(page.locator('p.success')).toHaveText('success'); + await expect(page.locator('p.fail')).toHaveText('fail'); + expect(page.locator('p.loadingsuccess')).toBeHidden(); + expect(page.locator('p.loadingfail')).toBeHidden(); + }); + + test('Works for server load functions (direkt hit)', async ({ page }) => { + page.goto('/defer/server'); + + await expect(page.locator('p.eager')).toHaveText('eager'); + expect(page.locator('p.loadingsuccess')).toBeVisible(); + expect(page.locator('p.loadingfail')).toBeVisible(); + + await expect(page.locator('p.success')).toHaveText('success'); + await expect(page.locator('p.fail')).toHaveText('fail'); + expect(page.locator('p.loadingsuccess')).toBeHidden(); + expect(page.locator('p.loadingfail')).toBeHidden(); + }); + + test('Works for server load functions (client nav)', async ({ page }) => { + await page.goto('/defer'); + page.click('[href="/defer/server"]'); + + await expect(page.locator('p.eager')).toHaveText('eager'); + expect(page.locator('p.loadingsuccess')).toBeVisible(); + expect(page.locator('p.loadingfail')).toBeVisible(); + + await expect(page.locator('p.success')).toHaveText('success'); + await expect(page.locator('p.fail')).toHaveText('fail'); + expect(page.locator('p.loadingsuccess')).toBeHidden(); + expect(page.locator('p.loadingfail')).toBeHidden(); + }); +}); diff --git a/packages/kit/test/apps/basics/test/test.js b/packages/kit/test/apps/basics/test/test.js index 3ab65e857725..cd95a60439dc 100644 --- a/packages/kit/test/apps/basics/test/test.js +++ b/packages/kit/test/apps/basics/test/test.js @@ -1091,41 +1091,3 @@ test.describe.serial('Cookies API', () => { expect(await span.innerText()).toContain('undefined'); }); }); - -test.describe('defer', () => { - test('Works for universal load functions', async ({ page, javaScriptEnabled }) => { - if (javaScriptEnabled) { - await page.goto('/defer'); - page.click('[href="/defer/universal"]', { noWaitAfter: true }); - } else { - await page.goto('/defer/universal'); - } - - await expect(page.locator('p.eager')).toHaveText('eager'); - expect(page.locator('p.loadingsuccess')).toBeVisible(); - expect(page.locator('p.loadingfail')).toBeVisible(); - - await expect(page.locator('p.success')).toHaveText('success'); - await expect(page.locator('p.fail')).toHaveText('fail'); - expect(page.locator('p.loadingsuccess')).toBeHidden(); - expect(page.locator('p.loadingfail')).toBeHidden(); - }); - - test('Works for server load functions', async ({ page, javaScriptEnabled }) => { - if (javaScriptEnabled) { - await page.goto('/defer'); - page.click('[href="/defer/server"]', { noWaitAfter: true }); - } else { - await page.goto('/defer/server'); - } - - await expect(page.locator('p.eager')).toHaveText('eager'); - expect(page.locator('p.loadingsuccess')).toBeVisible(); - expect(page.locator('p.loadingfail')).toBeVisible(); - - await expect(page.locator('p.success')).toHaveText('success'); - await expect(page.locator('p.fail')).toHaveText('fail'); - expect(page.locator('p.loadingsuccess')).toBeHidden(); - expect(page.locator('p.loadingfail')).toBeHidden(); - }); -}); diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 2fe1fd45d539..721aa4cbde12 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -39,10 +39,6 @@ export interface Adapter { type AwaitedPropertiesUnion | void> = input extends void ? undefined // needs to be undefined, because void will break intellisense - : input extends Deferred - ? { - [key in keyof Data]: Data[key]; - } : input extends Record ? { [key in keyof input]: Awaited; From 40b291765b8194e05df9391c5676b7e9a0d59cbb Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Wed, 15 Feb 2023 10:55:52 +0100 Subject: [PATCH 09/62] update changelog --- .changeset/ten-mice-brush.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/ten-mice-brush.md b/.changeset/ten-mice-brush.md index 469242c218cf..05f57d76c85b 100644 --- a/.changeset/ten-mice-brush.md +++ b/.changeset/ten-mice-brush.md @@ -2,4 +2,4 @@ '@sveltejs/kit': minor --- -feat: add `defer` utility for loading UI +feat: implement streaming promises for server load functions From 74af48d81fc03e974e53655ddde32cf3ec2fe948 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 15 Feb 2023 13:55:38 +0100 Subject: [PATCH 10/62] fixes --- packages/kit/src/runtime/server/utils.js | 1 - .../kit/test/apps/basics/test/client.test.js | 67 +++++++++++-------- 2 files changed, 39 insertions(+), 29 deletions(-) diff --git a/packages/kit/src/runtime/server/utils.js b/packages/kit/src/runtime/server/utils.js index 91a967936d46..9ea8a53a3176 100644 --- a/packages/kit/src/runtime/server/utils.js +++ b/packages/kit/src/runtime/server/utils.js @@ -1,4 +1,3 @@ -import * as devalue from 'devalue'; import { json, text } from '../../exports/index.js'; import { coalesce_to_error } from '../../utils/error.js'; import { negotiate } from '../../utils/http.js'; diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js index 5f4f23b5f16a..94ebd559cbf9 100644 --- a/packages/kit/test/apps/basics/test/client.test.js +++ b/packages/kit/test/apps/basics/test/client.test.js @@ -673,57 +673,68 @@ test.describe('Snapshots', () => { }); test.describe('defer', () => { + /** + * @param {import('@playwright/test').Page} page + * @param {string} str + */ + function locator(page, str) { + return page.locator(str, { + // @ts-expect-error don't wait for the started event as streaming happens before that + wait_for_started: false + }); + } + test('Works for universal load functions (direct hit)', async ({ page }) => { page.goto('/defer/universal'); - await expect(page.locator('p.eager')).toHaveText('eager'); - expect(page.locator('p.loadingsuccess')).toBeVisible(); - expect(page.locator('p.loadingfail')).toBeVisible(); + await expect(locator(page, 'p.eager')).toHaveText('eager', {}); + expect(locator(page, 'p.loadingsuccess')).toBeVisible(); + expect(locator(page, 'p.loadingfail')).toBeVisible(); - await expect(page.locator('p.success')).toHaveText('success'); - await expect(page.locator('p.fail')).toHaveText('fail'); - expect(page.locator('p.loadingsuccess')).toBeHidden(); - expect(page.locator('p.loadingfail')).toBeHidden(); + await expect(locator(page, 'p.success')).toHaveText('success'); + await expect(locator(page, 'p.fail')).toHaveText('fail'); + expect(locator(page, 'p.loadingsuccess')).toBeHidden(); + expect(locator(page, 'p.loadingfail')).toBeHidden(); }); test('Works for universal load functions (client nav)', async ({ page }) => { await page.goto('/defer'); page.click('[href="/defer/universal"]'); - await expect(page.locator('p.eager')).toHaveText('eager'); - expect(page.locator('p.loadingsuccess')).toBeVisible(); - expect(page.locator('p.loadingfail')).toBeVisible(); + await expect(locator(page, 'p.eager')).toHaveText('eager'); + expect(locator(page, 'p.loadingsuccess')).toBeVisible(); + expect(locator(page, 'p.loadingfail')).toBeVisible(); - await expect(page.locator('p.success')).toHaveText('success'); - await expect(page.locator('p.fail')).toHaveText('fail'); - expect(page.locator('p.loadingsuccess')).toBeHidden(); - expect(page.locator('p.loadingfail')).toBeHidden(); + await expect(locator(page, 'p.success')).toHaveText('success'); + await expect(locator(page, 'p.fail')).toHaveText('fail'); + expect(locator(page, 'p.loadingsuccess')).toBeHidden(); + expect(locator(page, 'p.loadingfail')).toBeHidden(); }); test('Works for server load functions (direkt hit)', async ({ page }) => { page.goto('/defer/server'); - await expect(page.locator('p.eager')).toHaveText('eager'); - expect(page.locator('p.loadingsuccess')).toBeVisible(); - expect(page.locator('p.loadingfail')).toBeVisible(); + await expect(locator(page, 'p.eager')).toHaveText('eager'); + expect(locator(page, 'p.loadingsuccess')).toBeVisible(); + expect(locator(page, 'p.loadingfail')).toBeVisible(); - await expect(page.locator('p.success')).toHaveText('success'); - await expect(page.locator('p.fail')).toHaveText('fail'); - expect(page.locator('p.loadingsuccess')).toBeHidden(); - expect(page.locator('p.loadingfail')).toBeHidden(); + await expect(locator(page, 'p.success')).toHaveText('success'); + await expect(locator(page, 'p.fail')).toHaveText('fail'); + expect(locator(page, 'p.loadingsuccess')).toBeHidden(); + expect(locator(page, 'p.loadingfail')).toBeHidden(); }); test('Works for server load functions (client nav)', async ({ page }) => { await page.goto('/defer'); page.click('[href="/defer/server"]'); - await expect(page.locator('p.eager')).toHaveText('eager'); - expect(page.locator('p.loadingsuccess')).toBeVisible(); - expect(page.locator('p.loadingfail')).toBeVisible(); + await expect(locator(page, 'p.eager')).toHaveText('eager'); + expect(locator(page, 'p.loadingsuccess')).toBeVisible(); + expect(locator(page, 'p.loadingfail')).toBeVisible(); - await expect(page.locator('p.success')).toHaveText('success'); - await expect(page.locator('p.fail')).toHaveText('fail'); - expect(page.locator('p.loadingsuccess')).toBeHidden(); - expect(page.locator('p.loadingfail')).toBeHidden(); + await expect(locator(page, 'p.success')).toHaveText('success'); + await expect(locator(page, 'p.fail')).toHaveText('fail'); + expect(locator(page, 'p.loadingsuccess')).toBeHidden(); + expect(locator(page, 'p.loadingfail')).toBeHidden(); }); }); From 42a805116ce3ee8c5d0a424c3608d6e735248777 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 15 Feb 2023 13:59:28 +0100 Subject: [PATCH 11/62] update docs --- .../docs/20-core-concepts/20-load.md | 78 ++++++++++--------- 1 file changed, 40 insertions(+), 38 deletions(-) diff --git a/documentation/docs/20-core-concepts/20-load.md b/documentation/docs/20-core-concepts/20-load.md index 674693a868dd..88ef51e708d4 100644 --- a/documentation/docs/20-core-concepts/20-load.md +++ b/documentation/docs/20-core-concepts/20-load.md @@ -450,6 +450,46 @@ export function load() { ``` +## Defer loading slow data + +Top level promises will be awaited, but nested promises won't, allowing you to defer loading slow data. + +```js +/// file: +page.server.js +import { defer } from '@sveltejs/kit'; + +/** @type {import('./$types').PageLoad} */ +export function load({ fetch }) { + const fast = fetch('/api/responds/quickly'); + const slow = fetch('/api/takes/a/while'); + return { + fast, + deferred: { + slow + } + }; +} +``` + +SvelteKit will not wait for these nested promises. In the above example, the UI will be rendered as soon as `fast` has resolved. Use Svelte's `{#await}` to show meaningful fallback UI while the slow data is still loading: + +```svelte +/// file: +page.svelte + + +

{data.fast}

+{#await data.deferred} +

Loading ...

+{:then result} +

{result}

+{:catch error} +

An error occurred: {error}

+{/catch} +``` + ## Parallel loading When rendering (or navigating to) a page, SvelteKit runs all `load` functions concurrently, avoiding a waterfall of requests. During client-side navigation, the result of calling multiple server `load` functions are grouped into a single response. Once all `load` functions have returned, the page is rendered. @@ -555,44 +595,6 @@ To summarize, a `load` function will re-run in the following situations: Note that re-running a `load` function will update the `data` prop inside the corresponding `+layout.svelte` or `+page.svelte`; it does _not_ cause the component to be recreated. As a result, internal state is preserved. If this isn't what you want, you can reset whatever you need to reset inside an [`afterNavigate`](modules#$app-navigation-afternavigate) callback, and/or wrap your component in a [`{#key ...}`](https://svelte.dev/docs#template-syntax-key) block. -## Defer loading slow data - -Some data in your app might be slow to load. In such situations, it's useful to only wait until the fast data is available and start rendering while the slow data is still loading. You can do so by wrapping your returned data with the `defer` function: - -```js -/// file: +page.js -import { defer } from '@sveltejs/kit'; - -/** @type {import('./$types').PageLoad} */ -export function load({ fetch }) { - const fast = fetch('/api/responds/quickly'); - const slow = fetch('/api/takes/a/while'); - return defer({ - fast: await fast, - slow - }); -} -``` - -`defer` will not wait for promises passed to it. In the above example, the UI will be rendered as soon as `fast` has resolved. Use Svelte's `{#await}` to show meaningful fallback UI while the slow data is still loading: - -```svelte -/// file: +page.svelte - - -

{data.fast}

-{#await data.slow} -

Loading ...

-{:then result} -

{result}

-{:catch error} -

An error occurred: {error}

-{/catch} -``` - ## Shared state In many server environments, a single instance of your app will serve multiple users. For that reason, per-request or per-user state must not be stored in shared variables outside your `load` functions, but should instead be stored in `event.locals`. From 188c50f1cfba9aafa88c9205a365ff6d459a3a46 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 15 Feb 2023 14:56:43 +0100 Subject: [PATCH 12/62] try this --- .../kit/test/apps/basics/test/client.test.js | 71 ++++++++----------- 1 file changed, 30 insertions(+), 41 deletions(-) diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js index 94ebd559cbf9..8e1466ab60de 100644 --- a/packages/kit/test/apps/basics/test/client.test.js +++ b/packages/kit/test/apps/basics/test/client.test.js @@ -673,68 +673,57 @@ test.describe('Snapshots', () => { }); test.describe('defer', () => { - /** - * @param {import('@playwright/test').Page} page - * @param {string} str - */ - function locator(page, str) { - return page.locator(str, { - // @ts-expect-error don't wait for the started event as streaming happens before that - wait_for_started: false - }); - } - test('Works for universal load functions (direct hit)', async ({ page }) => { - page.goto('/defer/universal'); + await page.goto('/defer/universal'); - await expect(locator(page, 'p.eager')).toHaveText('eager', {}); - expect(locator(page, 'p.loadingsuccess')).toBeVisible(); - expect(locator(page, 'p.loadingfail')).toBeVisible(); + await expect(page.locator('p.eager')).toHaveText('eager'); + expect(page.locator('p.loadingsuccess')).toBeVisible(); + expect(page.locator('p.loadingfail')).toBeVisible(); - await expect(locator(page, 'p.success')).toHaveText('success'); - await expect(locator(page, 'p.fail')).toHaveText('fail'); - expect(locator(page, 'p.loadingsuccess')).toBeHidden(); - expect(locator(page, 'p.loadingfail')).toBeHidden(); + await expect(page.locator('p.success')).toHaveText('success'); + await expect(page.locator('p.fail')).toHaveText('fail'); + expect(page.locator('p.loadingsuccess')).toBeHidden(); + expect(page.locator('p.loadingfail')).toBeHidden(); }); test('Works for universal load functions (client nav)', async ({ page }) => { await page.goto('/defer'); page.click('[href="/defer/universal"]'); - await expect(locator(page, 'p.eager')).toHaveText('eager'); - expect(locator(page, 'p.loadingsuccess')).toBeVisible(); - expect(locator(page, 'p.loadingfail')).toBeVisible(); + await expect(page.locator('p.eager')).toHaveText('eager'); + expect(page.locator('p.loadingsuccess')).toBeVisible(); + expect(page.locator('p.loadingfail')).toBeVisible(); - await expect(locator(page, 'p.success')).toHaveText('success'); - await expect(locator(page, 'p.fail')).toHaveText('fail'); - expect(locator(page, 'p.loadingsuccess')).toBeHidden(); - expect(locator(page, 'p.loadingfail')).toBeHidden(); + await expect(page.locator('p.success')).toHaveText('success'); + await expect(page.locator('p.fail')).toHaveText('fail'); + expect(page.locator('p.loadingsuccess')).toBeHidden(); + expect(page.locator('p.loadingfail')).toBeHidden(); }); test('Works for server load functions (direkt hit)', async ({ page }) => { - page.goto('/defer/server'); + await page.goto('/defer/server'); - await expect(locator(page, 'p.eager')).toHaveText('eager'); - expect(locator(page, 'p.loadingsuccess')).toBeVisible(); - expect(locator(page, 'p.loadingfail')).toBeVisible(); + await expect(page.locator('p.eager')).toHaveText('eager'); + expect(page.locator('p.loadingsuccess')).toBeVisible(); + expect(page.locator('p.loadingfail')).toBeVisible(); - await expect(locator(page, 'p.success')).toHaveText('success'); - await expect(locator(page, 'p.fail')).toHaveText('fail'); - expect(locator(page, 'p.loadingsuccess')).toBeHidden(); - expect(locator(page, 'p.loadingfail')).toBeHidden(); + await expect(page.locator('p.success')).toHaveText('success'); + await expect(page.locator('p.fail')).toHaveText('fail'); + expect(page.locator('p.loadingsuccess')).toBeHidden(); + expect(page.locator('p.loadingfail')).toBeHidden(); }); test('Works for server load functions (client nav)', async ({ page }) => { await page.goto('/defer'); page.click('[href="/defer/server"]'); - await expect(locator(page, 'p.eager')).toHaveText('eager'); - expect(locator(page, 'p.loadingsuccess')).toBeVisible(); - expect(locator(page, 'p.loadingfail')).toBeVisible(); + await expect(page.locator('p.eager')).toHaveText('eager'); + expect(page.locator('p.loadingsuccess')).toBeVisible(); + expect(page.locator('p.loadingfail')).toBeVisible(); - await expect(locator(page, 'p.success')).toHaveText('success'); - await expect(locator(page, 'p.fail')).toHaveText('fail'); - expect(locator(page, 'p.loadingsuccess')).toBeHidden(); - expect(locator(page, 'p.loadingfail')).toBeHidden(); + await expect(page.locator('p.success')).toHaveText('success'); + await expect(page.locator('p.fail')).toHaveText('fail'); + expect(page.locator('p.loadingsuccess')).toBeHidden(); + expect(page.locator('p.loadingfail')).toBeHidden(); }); }); From 831677d12b84e24828ac20277c1c081d7c97127d Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 15 Feb 2023 17:14:05 +0100 Subject: [PATCH 13/62] this fucking timing stuff --- packages/kit/test/apps/basics/test/client.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js index 8e1466ab60de..bbdc970bfd05 100644 --- a/packages/kit/test/apps/basics/test/client.test.js +++ b/packages/kit/test/apps/basics/test/client.test.js @@ -674,7 +674,7 @@ test.describe('Snapshots', () => { test.describe('defer', () => { test('Works for universal load functions (direct hit)', async ({ page }) => { - await page.goto('/defer/universal'); + await page.goto('/defer/universal', { wait_for_started: false }); await expect(page.locator('p.eager')).toHaveText('eager'); expect(page.locator('p.loadingsuccess')).toBeVisible(); @@ -701,7 +701,7 @@ test.describe('defer', () => { }); test('Works for server load functions (direkt hit)', async ({ page }) => { - await page.goto('/defer/server'); + await page.goto('/defer/server', { wait_for_started: false }); await expect(page.locator('p.eager')).toHaveText('eager'); expect(page.locator('p.loadingsuccess')).toBeVisible(); From 718ef66d7787ea1f96c32f1e9cb03fa38d8abce5 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 15 Feb 2023 18:21:43 +0100 Subject: [PATCH 14/62] fingers crossed --- .../kit/test/apps/basics/test/client.test.js | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js index bbdc970bfd05..c984e55cdd9b 100644 --- a/packages/kit/test/apps/basics/test/client.test.js +++ b/packages/kit/test/apps/basics/test/client.test.js @@ -674,9 +674,16 @@ test.describe('Snapshots', () => { test.describe('defer', () => { test('Works for universal load functions (direct hit)', async ({ page }) => { - await page.goto('/defer/universal', { wait_for_started: false }); + page.goto('/defer/universal'); + + // Write first assertion like this to control the retry interval. Else it might happen that + // the test fails because the next retry is too late (probably uses a back-off strategy) + await expect(async () => { + expect(await page.locator('p.eager').textContent()).toBe('eager'); + }).toPass({ + intervals: [100] + }); - await expect(page.locator('p.eager')).toHaveText('eager'); expect(page.locator('p.loadingsuccess')).toBeVisible(); expect(page.locator('p.loadingfail')).toBeVisible(); @@ -703,7 +710,14 @@ test.describe('defer', () => { test('Works for server load functions (direkt hit)', async ({ page }) => { await page.goto('/defer/server', { wait_for_started: false }); - await expect(page.locator('p.eager')).toHaveText('eager'); + // Write first assertion like this to control the retry interval. Else it might happen that + // the test fails because the next retry is too late (probably uses a back-off strategy) + await expect(async () => { + expect(await page.locator('p.eager').textContent()).toBe('eager'); + }).toPass({ + intervals: [100] + }); + expect(page.locator('p.loadingsuccess')).toBeVisible(); expect(page.locator('p.loadingfail')).toBeVisible(); From 73892d91c1a2450b632b1f7ccadfd3d47b5f1844 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Fri, 17 Feb 2023 17:36:11 +0100 Subject: [PATCH 15/62] incapable of getting ten lines of code right without intellisense --- documentation/docs/20-core-concepts/20-load.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/documentation/docs/20-core-concepts/20-load.md b/documentation/docs/20-core-concepts/20-load.md index 88ef51e708d4..6e7731f6332a 100644 --- a/documentation/docs/20-core-concepts/20-load.md +++ b/documentation/docs/20-core-concepts/20-load.md @@ -471,7 +471,7 @@ export function load({ fetch }) { } ``` -SvelteKit will not wait for these nested promises. In the above example, the UI will be rendered as soon as `fast` has resolved. Use Svelte's `{#await}` to show meaningful fallback UI while the slow data is still loading: +In the above example, the UI will be rendered as soon as `fast` has resolved. Use Svelte's `{#await}` to show meaningful fallback UI while the `slow` data is still loading: ```svelte /// file: +page.svelte @@ -481,13 +481,13 @@ SvelteKit will not wait for these nested promises. In the above example, the UI

{data.fast}

-{#await data.deferred} +{#await data.deferred.slow}

Loading ...

{:then result}

{result}

{:catch error}

An error occurred: {error}

-{/catch} +{/await} ``` ## Parallel loading From 1da068e9f2d48f403089f684c0e658834f7e0185 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Fri, 17 Feb 2023 17:53:54 +0100 Subject: [PATCH 16/62] remove unpredictable uses tracking in favor of docs and warning --- .../docs/20-core-concepts/20-load.md | 2 ++ packages/kit/src/runtime/client/client.js | 11 +------ packages/kit/src/runtime/server/data/index.js | 26 ++++++--------- .../kit/src/runtime/server/page/render.js | 33 +++++++------------ packages/kit/src/runtime/server/utils.js | 2 +- packages/kit/types/internal.d.ts | 25 ++++---------- 6 files changed, 32 insertions(+), 67 deletions(-) diff --git a/documentation/docs/20-core-concepts/20-load.md b/documentation/docs/20-core-concepts/20-load.md index 6e7731f6332a..556c667db956 100644 --- a/documentation/docs/20-core-concepts/20-load.md +++ b/documentation/docs/20-core-concepts/20-load.md @@ -542,6 +542,8 @@ export async function load() { A `load` function that calls `await parent()` will also re-run if a parent `load` function is re-run. +Tracking happens until the load function returns its value. If you have nested promises that resolve later and which then use dependencies, then these usages will not be tracked. + ### Manual invalidation You can also re-run `load` functions that apply to the current page using [`invalidate(url)`](modules#$app-navigation-invalidate), which re-runs all `load` functions that depend on `url`, and [`invalidateAll()`](modules#$app-navigation-invalidateall), which re-runs every `load` function. diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 916e89edad85..a48630423675 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -1825,7 +1825,7 @@ async function load_data(url, invalid) { resolve(node); } else if (node.type === 'chunk') { // This is a subsequent chunk containing deferred data - let { id, data, error, uses } = node; + let { id, data, error } = node; const entry = pending.get(id); // Shouldn't ever be undefined, but just in case if (entry) { @@ -1834,15 +1834,6 @@ async function load_data(url, invalid) { } else { entry.resolve(deserialize(data, entry.uses)); } - if (uses) { - uses = deserialize_uses(uses); - // Merge into existing uses - entry.uses.dependencies = new Set([...entry.uses.dependencies, ...uses.dependencies]); - entry.uses.params = new Set([...entry.uses.params, ...uses.params]); - entry.uses.parent = entry.uses.parent || uses.parent; - entry.uses.route = entry.uses.route || uses.route; - entry.uses.url = entry.uses.url || uses.url; - } } pending.delete(id); } diff --git a/packages/kit/src/runtime/server/data/index.js b/packages/kit/src/runtime/server/data/index.js index 2a7399327d10..22407335ba61 100644 --- a/packages/kit/src/runtime/server/data/index.js +++ b/packages/kit/src/runtime/server/data/index.js @@ -193,7 +193,6 @@ async function _get_data_json(event, options, nodes, next) { try { for (const node of nodes) { - let node_count = 0; let uses_str = ''; const revivers = { @@ -202,7 +201,6 @@ async function _get_data_json(event, options, nodes, next) { if (typeof thing?.then === 'function') { const id = promise_id++; count += 1; - node_count += 1; thing .then(/** @param {any} d */ (d) => ({ d })) @@ -211,7 +209,7 @@ async function _get_data_json(event, options, nodes, next) { /** * @param {{d: any; e: any}} result */ - async ({ d: d, e }) => { + async ({ d, e }) => { let data; let error; try { @@ -228,18 +226,14 @@ async function _get_data_json(event, options, nodes, next) { ); } - node_count -= 1; - // only send uses when it's the last chunk of the data node - // so we can be sure all uses are accounted for - let uses = - node_count === 0 - ? undefined - : stringify_uses( - /** @type {import('types').ServerDataNodePreSerialization} */ (node) - ); - if (uses === uses_str) { - // No change - no need to send it - uses = undefined; + if ( + __SVELTEKIT_DEV__ && + stringify_uses(/** @type {import('types').ServerDataNode} */ (node)) !== + uses_str + ) { + console.warn( + 'Accessed dependencies after load function returned. These usages will not be tracked.' + ); } count -= 1; @@ -249,7 +243,7 @@ async function _get_data_json(event, options, nodes, next) { has_more: count !== 0, data: `{"type":"chunk","id":${id}${data ? `,"data":${data}` : ''}${ error ? `,"error":${error}` : '' - }${uses ? `,${uses}` : ''}}\n` + }\n` }, count === 0 ); diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js index fb61251d7103..47f1ea54e3b9 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -300,16 +300,12 @@ export async function render_response({ deferred.set(id, { fulfil, reject }); }); - $__sveltekit__.resolve = ({ id, data, error, uses }) => { + $__sveltekit__.resolve = ({ id, data, error }) => { const { fulfil, reject } = deferred.get(id); deferred.delete(id); if (error) reject(error); else fulfil(data); - - if (uses) { - // TODO - } };` : ''; @@ -477,7 +473,6 @@ async function _get_data(event, nodes, next) { try { for (const node of nodes) { - let node_count = 0; let uses_str = ''; const replacer = @@ -486,7 +481,6 @@ async function _get_data(event, nodes, next) { if (typeof thing?.then === 'function') { const id = promise_id++; count += 1; - node_count += 1; thing .then(/** @param {any} data */ (data) => ({ data })) @@ -496,28 +490,25 @@ async function _get_data(event, nodes, next) { * @param {{data: any; error: any}} result */ async ({ data, error }) => { - node_count -= 1; - // only send uses when it's the last chunk of the data node - // so we can be sure all uses are accounted for - const uses = - node_count === 0 - ? undefined - : stringify_uses( - /** @type {import('types').ServerDataNodePreSerialization} */ (node) - ) === uses_str - ? // No change - no need to send it - undefined - : node?.uses; + if ( + __SVELTEKIT_DEV__ && + stringify_uses(/** @type {import('types').ServerDataNode} */ (node)) !== + uses_str + ) { + console.warn( + 'Accessed dependencies after load function returned. These usages will not be tracked.' + ); + } count -= 1; let str; try { - str = devalue.uneval({ id, data, error, uses }, replacer); + str = devalue.uneval({ id, data, error }, replacer); } catch (e) { error = `new Error(${clarify_devalue_error(event, /** @type {any} */ (e))});`; data = undefined; - str = devalue.uneval({ id, data, error, uses }, replacer); + str = devalue.uneval({ id, data, error }, replacer); } next( diff --git a/packages/kit/src/runtime/server/utils.js b/packages/kit/src/runtime/server/utils.js index 9ea8a53a3176..9a3d73e36d80 100644 --- a/packages/kit/src/runtime/server/utils.js +++ b/packages/kit/src/runtime/server/utils.js @@ -148,7 +148,7 @@ export function clarify_devalue_error(event, error) { } /** - * @param {import('types').ServerDataNodePreSerialization} node + * @param {import('types').ServerDataNode} node */ export function stringify_uses(node) { const uses = []; diff --git a/packages/kit/types/internal.d.ts b/packages/kit/types/internal.d.ts index 8b425234e4ab..3570b68335dd 100644 --- a/packages/kit/types/internal.d.ts +++ b/packages/kit/types/internal.d.ts @@ -203,9 +203,11 @@ export type ServerNodesResponse = { export type ServerDataResponse = ServerRedirectNode | ServerNodesResponse; /** - * Pre-serialized server version of successful response of the server `load` function. + * 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 ServerDataNodePreSerialization { +export interface ServerDataNode { type: 'data'; /** * The serialized version of this contains a serialized representation of any deferred promises, @@ -215,33 +217,18 @@ export interface ServerDataNodePreSerialization { /** * Defined if the `load` function didn't return a result containing promises. */ - uses?: Uses; + uses: Uses; slash?: TrailingSlash; } /** - * 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. + * Resolved data/error of a deferred promise. */ -export interface ServerDataNode extends ServerDataNodePreSerialization { - /** - * Defined in the serialized version if the `load` function didn't return a `defer`red result. - * Make sure to pass this property by reference and not copy it somehow, as it might be updated - * by a deferred promise. - */ - uses: Uses; -} - export interface ServerDataChunkNode { type: 'chunk'; id: number; data?: Record; error?: any; - /** - * Defined for the final chunk of the corresponding `load` function - */ - uses?: Uses; } /** From 76f726e595b976cb179eec3c1d01ffd888fcd090 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Fri, 17 Feb 2023 20:56:18 +0100 Subject: [PATCH 17/62] too many braces --- packages/kit/src/runtime/server/data/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kit/src/runtime/server/data/index.js b/packages/kit/src/runtime/server/data/index.js index 22407335ba61..d4d95e028311 100644 --- a/packages/kit/src/runtime/server/data/index.js +++ b/packages/kit/src/runtime/server/data/index.js @@ -243,7 +243,7 @@ async function _get_data_json(event, options, nodes, next) { has_more: count !== 0, data: `{"type":"chunk","id":${id}${data ? `,"data":${data}` : ''}${ error ? `,"error":${error}` : '' - }\n` + }}\n` }, count === 0 ); From 0d95812705a1ccf3dafaa1b18b3ca51a217f3b5b Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Fri, 17 Feb 2023 21:24:42 +0100 Subject: [PATCH 18/62] these tests are killing me --- packages/kit/test/apps/basics/test/client.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js index c984e55cdd9b..2429cf04902f 100644 --- a/packages/kit/test/apps/basics/test/client.test.js +++ b/packages/kit/test/apps/basics/test/client.test.js @@ -708,7 +708,7 @@ test.describe('defer', () => { }); test('Works for server load functions (direkt hit)', async ({ page }) => { - await page.goto('/defer/server', { wait_for_started: false }); + page.goto('/defer/server'); // Write first assertion like this to control the retry interval. Else it might happen that // the test fails because the next retry is too late (probably uses a back-off strategy) From ff99eaad1b67ee66acf236e2c8eb1f96dafddec1 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 17 Feb 2023 19:08:35 -0500 Subject: [PATCH 19/62] combine streaming and promise unwrapping docs --- .../docs/20-core-concepts/20-load.md | 77 +++++++------------ 1 file changed, 26 insertions(+), 51 deletions(-) diff --git a/documentation/docs/20-core-concepts/20-load.md b/documentation/docs/20-core-concepts/20-load.md index 556c667db956..0d67a0a8d4e7 100644 --- a/documentation/docs/20-core-concepts/20-load.md +++ b/documentation/docs/20-core-concepts/20-load.md @@ -174,7 +174,7 @@ Universal `load` functions are called with a `LoadEvent`, which has a `data` pro A universal `load` function can return an object containing any values, including things like custom classes and component constructors. -A server `load` function must return data that can be serialized with [devalue](https://github.com/rich-harris/devalue) — anything that can be represented as JSON plus things like `BigInt`, `Date`, `Map`, `Set` and `RegExp`, or repeated/cyclical references — so that it can be transported over the network. +A server `load` function must return data that can be serialized with [devalue](https://github.com/rich-harris/devalue) — anything that can be represented as JSON plus things like `BigInt`, `Date`, `Map`, `Set` and `RegExp`, or repeated/cyclical references — so that it can be transported over the network. Your data can include [promises](#promises), in which case it will be streamed to the browsers. ### When to use which @@ -420,19 +420,23 @@ export function load({ locals }) { In the browser, you can also navigate programmatically outside of a `load` function using [`goto`](modules#$app-navigation-goto) from [`$app.navigation`](modules#$app-navigation). -## Promise unwrapping +## Promises -Top-level promises will be awaited, which makes it easy to return multiple promises without creating a waterfall: +Promises at the _top level_ of the returned object will be awaited, making it easy to return multiple promises without creating a waterfall. When using a server `load`, _nested_ promises will be streamed to the browser as they resolve: ```js -/// file: src/routes/+page.js -/** @type {import('./$types').PageLoad} */ +/// file: src/routes/+page.server.js +/** @type {import('./$types').PageServerLoad} */ export function load() { return { - a: Promise.resolve('a'), - b: Promise.resolve('b'), - c: { - value: Promise.resolve('c') + one: Promise.resolve(1), + two: Promise.resolve(2), + streamed: { + three: new Promise((fulfil) => { + setTimeout(() => { + fulfil(3) + }, 1000); + }) } }; } @@ -443,52 +447,23 @@ export function load() { -``` - -## Defer loading slow data - -Top level promises will be awaited, but nested promises won't, allowing you to defer loading slow data. -```js -/// file: +page.server.js -import { defer } from '@sveltejs/kit'; - -/** @type {import('./$types').PageLoad} */ -export function load({ fetch }) { - const fast = fetch('/api/responds/quickly'); - const slow = fetch('/api/takes/a/while'); - return { - fast, - deferred: { - slow - } - }; -} +

one: {data.one}

+

two: {data.two}

+

+ three: + {#await data.streamed.three} + ... + {:then value} + {value} + {:catch error} + {error.message} + {/await} +

``` -In the above example, the UI will be rendered as soon as `fast` has resolved. Use Svelte's `{#await}` to show meaningful fallback UI while the `slow` data is still loading: - -```svelte -/// file: +page.svelte - - -

{data.fast}

-{#await data.deferred.slow} -

Loading ...

-{:then result} -

{result}

-{:catch error} -

An error occurred: {error}

-{/await} -``` +> Streaming data will only work when JavaScript is enabled. You should avoid returning nested promises from a universal `load` function as these are _not_ streamed — instead, the promise is recreated when the function re-runs in the browser. ## Parallel loading From 468a61841b0d0247c618c09c9c57a16d37c35369 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 17 Feb 2023 22:41:49 -0500 Subject: [PATCH 20/62] add comment about non-streaming platforms --- documentation/docs/20-core-concepts/20-load.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/documentation/docs/20-core-concepts/20-load.md b/documentation/docs/20-core-concepts/20-load.md index 0d67a0a8d4e7..953875ca3283 100644 --- a/documentation/docs/20-core-concepts/20-load.md +++ b/documentation/docs/20-core-concepts/20-load.md @@ -463,6 +463,8 @@ export function load() {

``` +On platforms that do not support streaming, such as AWS Lambda, responses will be buffered. + > Streaming data will only work when JavaScript is enabled. You should avoid returning nested promises from a universal `load` function as these are _not_ streamed — instead, the promise is recreated when the function re-runs in the browser. ## Parallel loading From 386eaed7ee0c415421ac9db04d83183cd319c6d0 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 17 Feb 2023 22:45:07 -0500 Subject: [PATCH 21/62] typo --- packages/kit/src/utils/generators.js | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/kit/src/utils/generators.js b/packages/kit/src/utils/generators.js index fcce7dc9a38c..8e2f3fd49093 100644 --- a/packages/kit/src/utils/generators.js +++ b/packages/kit/src/utils/generators.js @@ -1,6 +1,6 @@ /** * This method is necessary because we can't yield from inside a callback, - * so we smooth other an internal less ergonomic callback API + * so we smooth over an internal less ergonomic callback API * @template {(...args: any) => void} Fn * @param {Fn} fn * @returns {Parameters extends [...infer InitPs, (value: infer Value, done: boolean) => void] ? (...args: InitPs) => AsyncGenerator : never} @@ -9,13 +9,16 @@ export function to_generator(fn) { // @ts-ignore yeah TS this return type is fucked up, I know return async function* (...args) { /** @type {(v: any) => void} */ - let fulfill; + let fulfil; + /** @type {(v: any) => void} */ let reject; + let promise = new Promise((f, r) => { - fulfill = f; + fulfil = f; reject = r; }); + // Ensure it runs after we enter the loop to not swallow the first eager result Promise.resolve() .then(() => @@ -26,10 +29,10 @@ export function to_generator(fn) { * @param {boolean} done */ (result, done) => { - fulfill({ result, done }); + fulfil({ result, done }); if (!done) { promise = new Promise((r) => { - fulfill = r; + fulfil = r; }); } } From bafabf70b85ecaf7318dca27c41b7e081eadf44b Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 17 Feb 2023 23:04:20 -0500 Subject: [PATCH 22/62] move warning to load_server_data, make it more situation-specific --- packages/kit/src/runtime/server/data/index.js | 10 ----- .../kit/src/runtime/server/page/load_data.js | 45 +++++++++++++++++++ .../kit/src/runtime/server/page/render.js | 10 ----- 3 files changed, 45 insertions(+), 20 deletions(-) diff --git a/packages/kit/src/runtime/server/data/index.js b/packages/kit/src/runtime/server/data/index.js index d4d95e028311..981996e95436 100644 --- a/packages/kit/src/runtime/server/data/index.js +++ b/packages/kit/src/runtime/server/data/index.js @@ -226,16 +226,6 @@ async function _get_data_json(event, options, nodes, next) { ); } - if ( - __SVELTEKIT_DEV__ && - stringify_uses(/** @type {import('types').ServerDataNode} */ (node)) !== - uses_str - ) { - console.warn( - 'Accessed dependencies after load function returned. These usages will not be tracked.' - ); - } - count -= 1; next( diff --git a/packages/kit/src/runtime/server/page/load_data.js b/packages/kit/src/runtime/server/page/load_data.js index 26a652967d7d..1be94011250d 100644 --- a/packages/kit/src/runtime/server/page/load_data.js +++ b/packages/kit/src/runtime/server/page/load_data.js @@ -1,5 +1,6 @@ import { disable_search, make_trackable } from '../../../utils/url.js'; import { unwrap_promises } from '../../../utils/promises.js'; +import { DEV } from 'esm-env'; /** * Calls the user's server `load` function. @@ -14,6 +15,8 @@ import { unwrap_promises } from '../../../utils/promises.js'; export async function load_server_data({ event, state, node, parent }) { if (!node?.server) return null; + let done = false; + const uses = { dependencies: new Set(), params: new Set(), @@ -23,6 +26,12 @@ export async function load_server_data({ event, state, node, parent }) { }; const url = make_trackable(event.url, () => { + if (DEV && done && !uses.url) { + console.warn( + `${node.server_id}: Accessing URL properties in a promise handler after \`load(...)\` has returned will not cause the function to re-run when the URL changes` + ); + } + uses.url = true; }); @@ -34,6 +43,13 @@ export async function load_server_data({ event, state, node, parent }) { ...event, fetch: (info, init) => { const url = new URL(info instanceof Request ? info.url : info, event.url); + + if (DEV && done && !uses.dependencies.has(url.href)) { + console.warn( + `${node.server_id}: Calling \`event.fetch(...)\` in a promise handler after \`load(...)\` has returned will not cause the function to re-run when the dependency is invalidated` + ); + } + uses.dependencies.add(url.href); return event.fetch(info, init); @@ -42,21 +58,48 @@ export async function load_server_data({ event, state, node, parent }) { depends: (...deps) => { for (const dep of deps) { const { href } = new URL(dep, event.url); + + if (DEV && done && !uses.dependencies.has(href)) { + console.warn( + `${node.server_id}: Calling \`depends(...)\` in a promise handler after \`load(...)\` has returned will not cause the function to re-run when the dependency is invalidated` + ); + } + uses.dependencies.add(href); } }, params: new Proxy(event.params, { get: (target, key) => { + if (DEV && done && !uses.params.has(key)) { + console.warn( + `${node.server_id}: Accessing \`params.${String( + key + )}\` in a promise handler after \`load(...)\` has returned will not cause the function to re-run when the param changes` + ); + } + uses.params.add(key); return target[/** @type {string} */ (key)]; } }), parent: async () => { + if (DEV && done && !uses.parent) { + console.warn( + `${node.server_id}: Calling \`parent(...)\` in a promise handler after \`load(...)\` has returned will not cause the function to re-run when parent data changes` + ); + } + uses.parent = true; return parent(); }, route: { get id() { + if (DEV && done && !uses.route) { + console.warn( + `${node.server_id}: Accessing \`route.id\` in a promise handler after \`load(...)\` has returned will not cause the function to re-run when the route changes` + ); + } + uses.route = true; return event.route.id; } @@ -69,6 +112,8 @@ export async function load_server_data({ event, state, node, parent }) { validate_load_response(data, /** @type {string} */ (event.route.id)); } + done = true; + return { type: 'data', data, diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js index 47f1ea54e3b9..19168b697d04 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -490,16 +490,6 @@ async function _get_data(event, nodes, next) { * @param {{data: any; error: any}} result */ async ({ data, error }) => { - if ( - __SVELTEKIT_DEV__ && - stringify_uses(/** @type {import('types').ServerDataNode} */ (node)) !== - uses_str - ) { - console.warn( - 'Accessed dependencies after load function returned. These usages will not be tracked.' - ); - } - count -= 1; let str; From 3df214144420917dfe27db0cae652c0f956b9cb9 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Fri, 17 Feb 2023 23:05:06 -0500 Subject: [PATCH 23/62] lol wut --- documentation/docs/20-core-concepts/20-load.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/docs/20-core-concepts/20-load.md b/documentation/docs/20-core-concepts/20-load.md index 953875ca3283..82040765d837 100644 --- a/documentation/docs/20-core-concepts/20-load.md +++ b/documentation/docs/20-core-concepts/20-load.md @@ -174,7 +174,7 @@ Universal `load` functions are called with a `LoadEvent`, which has a `data` pro A universal `load` function can return an object containing any values, including things like custom classes and component constructors. -A server `load` function must return data that can be serialized with [devalue](https://github.com/rich-harris/devalue) — anything that can be represented as JSON plus things like `BigInt`, `Date`, `Map`, `Set` and `RegExp`, or repeated/cyclical references — so that it can be transported over the network. Your data can include [promises](#promises), in which case it will be streamed to the browsers. +A server `load` function must return data that can be serialized with [devalue](https://github.com/rich-harris/devalue) — anything that can be represented as JSON plus things like `BigInt`, `Date`, `Map`, `Set` and `RegExp`, or repeated/cyclical references — so that it can be transported over the network. Your data can include [promises](#promises), in which case it will be streamed to browsers. ### When to use which From 6042d4c1a6d79eed6c7c85f5ec31c475c5557685 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sat, 18 Feb 2023 08:41:05 -0500 Subject: [PATCH 24/62] symmetry --- documentation/docs/20-core-concepts/20-load.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/documentation/docs/20-core-concepts/20-load.md b/documentation/docs/20-core-concepts/20-load.md index 82040765d837..dad6c308e07f 100644 --- a/documentation/docs/20-core-concepts/20-load.md +++ b/documentation/docs/20-core-concepts/20-load.md @@ -449,8 +449,12 @@ export function load() { export let data; -

one: {data.one}

-

two: {data.two}

+

+ one: {data.one} +

+

+ two: {data.two} +

three: {#await data.streamed.three} From cb03a878b572a2413be1b068334e8628f39c4723 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sat, 18 Feb 2023 08:48:15 -0500 Subject: [PATCH 25/62] tweak --- documentation/docs/20-core-concepts/20-load.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/docs/20-core-concepts/20-load.md b/documentation/docs/20-core-concepts/20-load.md index dad6c308e07f..21b2734bf18f 100644 --- a/documentation/docs/20-core-concepts/20-load.md +++ b/documentation/docs/20-core-concepts/20-load.md @@ -523,7 +523,7 @@ export async function load() { A `load` function that calls `await parent()` will also re-run if a parent `load` function is re-run. -Tracking happens until the load function returns its value. If you have nested promises that resolve later and which then use dependencies, then these usages will not be tracked. +Dependency tracking does not apply _after_ the `load` function has returned — for example, accessing `params.x` inside a nested [promise](#promises) will not cause the function to re-run when `params.x` changes. (Don't worry, you'll get a warning in development if you accidentally do this.) Instead, access the parameter in the main body of your `load` function. ### Manual invalidation From 5ac5bd01af2e6b76162a806f07507be306e983a2 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sat, 18 Feb 2023 08:57:52 -0500 Subject: [PATCH 26/62] simplify --- packages/kit/src/runtime/client/client.js | 24 +++++++---------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index a48630423675..073c43ef9f86 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -1761,7 +1761,7 @@ async function load_data(url, invalid) { return new Promise(async (resolve) => { /** - * @type {Map void; reject: (v: any) => void; uses: import('types').Uses }>} + * @type {Map void; reject: (v: any) => void; }>} * Map of deferred promises that will be resolved by a subsequent chunk of data */ const pending = new Map(); @@ -1770,22 +1770,12 @@ async function load_data(url, invalid) { /** * @param {any} data - * @param {import('types').Uses} uses */ - function deserialize(data, uses) { + function deserialize(data) { return devalue.unflatten(data, { Promise: (id) => { - /** @type {any} */ - const obj = { - id, - resolve: undefined, - reject: undefined, - uses - }; - pending.set(id, obj); - return new Promise((f, r) => { - obj.resolve = f; - obj.reject = r; + return new Promise((fulfil, reject) => { + pending.set(id, { resolve: fulfil, reject }); }); } }); @@ -1818,7 +1808,7 @@ async function load_data(url, invalid) { node.nodes?.forEach((/** @type {any} */ node) => { if (node?.type === 'data') { node.uses = deserialize_uses(node.uses); - node.data = deserialize(node.data, node.uses); + node.data = deserialize(node.data); } }); @@ -1830,9 +1820,9 @@ async function load_data(url, invalid) { // Shouldn't ever be undefined, but just in case if (entry) { if (error) { - entry.reject(deserialize(error, entry.uses)); + entry.reject(deserialize(error)); } else { - entry.resolve(deserialize(data, entry.uses)); + entry.resolve(deserialize(data)); } } pending.delete(id); From d8f7e04772519b30c7fd293db9018afa5a0d0f52 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sat, 18 Feb 2023 09:00:02 -0500 Subject: [PATCH 27/62] use conventional names --- packages/kit/src/runtime/client/client.js | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 073c43ef9f86..6a13eeaa499b 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -1761,10 +1761,10 @@ async function load_data(url, invalid) { return new Promise(async (resolve) => { /** - * @type {Map void; reject: (v: any) => void; }>} + * @type {Map void; reject: (v: any) => void; }>} * Map of deferred promises that will be resolved by a subsequent chunk of data */ - const pending = new Map(); + const deferreds = new Map(); const reader = /** @type {ReadableStream} */ (res.body).getReader(); const decoder = new TextDecoder(); @@ -1775,7 +1775,7 @@ async function load_data(url, invalid) { return devalue.unflatten(data, { Promise: (id) => { return new Promise((fulfil, reject) => { - pending.set(id, { resolve: fulfil, reject }); + deferreds.set(id, { fulfil, reject }); }); } }); @@ -1815,17 +1815,18 @@ async function load_data(url, invalid) { resolve(node); } else if (node.type === 'chunk') { // This is a subsequent chunk containing deferred data - let { id, data, error } = node; - const entry = pending.get(id); + const { id, data, error } = node; + const deferred = deferreds.get(id); + deferreds.delete(id); + // Shouldn't ever be undefined, but just in case - if (entry) { + if (deferred) { if (error) { - entry.reject(deserialize(error)); + deferred.reject(deserialize(error)); } else { - entry.resolve(deserialize(data)); + deferred.fulfil(deserialize(data)); } } - pending.delete(id); } } } From a08aa9f60934fc2e4ee2d8e75f91d599ac1e68e7 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sat, 18 Feb 2023 09:03:53 -0500 Subject: [PATCH 28/62] add a Deferred interface, remove belt and braces (could mask legitimate bugs) --- packages/kit/src/runtime/client/client.js | 15 ++++++--------- packages/kit/types/internal.d.ts | 5 +++++ 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 6a13eeaa499b..892a6dfb8fb1 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -1761,8 +1761,8 @@ async function load_data(url, invalid) { return new Promise(async (resolve) => { /** - * @type {Map void; reject: (v: any) => void; }>} * Map of deferred promises that will be resolved by a subsequent chunk of data + * @type {Map} */ const deferreds = new Map(); const reader = /** @type {ReadableStream} */ (res.body).getReader(); @@ -1816,16 +1816,13 @@ async function load_data(url, invalid) { } else if (node.type === 'chunk') { // This is a subsequent chunk containing deferred data const { id, data, error } = node; - const deferred = deferreds.get(id); + const deferred = /** @type {import('types').Deferred} */ (deferreds.get(id)); deferreds.delete(id); - // Shouldn't ever be undefined, but just in case - if (deferred) { - if (error) { - deferred.reject(deserialize(error)); - } else { - deferred.fulfil(deserialize(data)); - } + if (error) { + deferred.reject(deserialize(error)); + } else { + deferred.fulfil(deserialize(data)); } } } diff --git a/packages/kit/types/internal.d.ts b/packages/kit/types/internal.d.ts index 3570b68335dd..a3032c3b6a9e 100644 --- a/packages/kit/types/internal.d.ts +++ b/packages/kit/types/internal.d.ts @@ -78,6 +78,11 @@ export type CSRRoute = { leaf: [has_server_load: boolean, node_loader: CSRPageNodeLoader]; }; +export interface Deferred { + fulfil: (value: any) => void; + reject: (error: Error) => void; +} + export type GetParams = (match: RegExpExecArray) => Record; export interface ServerHooks { From 75fcd0f2f44cd76bf7486424536514e13f92e1da Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sat, 18 Feb 2023 09:06:05 -0500 Subject: [PATCH 29/62] if done is true, value is guaranteed to be undefined --- packages/kit/src/runtime/client/client.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 892a6dfb8fb1..e2e7e88f9913 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -1786,7 +1786,7 @@ async function load_data(url, invalid) { while (true) { // Format follows ndjson (each line is a JSON object) or regular JSON spec const { done, value } = await reader.read(); - if (done && !text && !value) break; + if (done && !text) break; text += !value && text ? '\n' : decoder.decode(value); // no value -> final chunk -> add a new line to trigger the last parse From 7e59edc0d3a24926a0c0979431214943b3780385 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sat, 18 Feb 2023 09:12:38 -0500 Subject: [PATCH 30/62] remove outdated comments --- packages/kit/src/runtime/client/client.js | 2 -- packages/kit/types/internal.d.ts | 3 --- 2 files changed, 5 deletions(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index e2e7e88f9913..1e4f6b248829 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -732,8 +732,6 @@ export function create_client({ target }) { const data_node = { type: 'data', data: node.data, - // It's important that we use the existing `uses` object here, so that - // potentially deferred data can manipulate the object later uses: node.uses, slash: node.slash }; diff --git a/packages/kit/types/internal.d.ts b/packages/kit/types/internal.d.ts index a3032c3b6a9e..28413b149c7f 100644 --- a/packages/kit/types/internal.d.ts +++ b/packages/kit/types/internal.d.ts @@ -219,9 +219,6 @@ export interface ServerDataNode { * which will be resolved later through chunk nodes. */ data: Record | null; - /** - * Defined if the `load` function didn't return a result containing promises. - */ uses: Uses; slash?: TrailingSlash; } From 16ee75dfba39fdf23c1e858a27f8f581a4191394 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sat, 18 Feb 2023 09:15:36 -0500 Subject: [PATCH 31/62] we can just reuse the object --- packages/kit/src/runtime/client/client.js | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 1e4f6b248829..e687dbfff36a 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -727,18 +727,8 @@ export function create_client({ target }) { * @returns {import('./types').DataNode | null} */ function create_data_node(node, previous) { - if (node?.type === 'data') { - /** @type {import('./types').DataNode} */ - const data_node = { - type: 'data', - data: node.data, - uses: node.uses, - slash: node.slash - }; - return data_node; - } else if (node?.type === 'skip') { - return previous ?? null; - } + if (node?.type === 'data') return node; + if (node?.type === 'skip') return previous ?? null; return null; } From 82083136f9326b1f9e1196da454e74b1da0d7a2a Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sat, 18 Feb 2023 10:21:44 -0500 Subject: [PATCH 32/62] use text/plain for easier inspecting --- packages/kit/src/runtime/server/data/index.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/kit/src/runtime/server/data/index.js b/packages/kit/src/runtime/server/data/index.js index 981996e95436..cc664a3ba6c8 100644 --- a/packages/kit/src/runtime/server/data/index.js +++ b/packages/kit/src/runtime/server/data/index.js @@ -136,7 +136,9 @@ export async function render_data( }), { headers: { - 'content-type': 'application/x-ndjson', + // text/plain isn't strictly correct, but it makes it easier to inspect + // the data, and doesn't affect how it is consumed by the client + 'content-type': 'text/plain', 'cache-control': 'private, no-store' } } From 0139576e8ec0a3343db4c8234c7f1ff80e8024c5 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sat, 18 Feb 2023 10:22:01 -0500 Subject: [PATCH 33/62] make var local --- packages/kit/src/runtime/server/page/render.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js index 19168b697d04..a3e09e89e2e3 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -473,8 +473,6 @@ async function _get_data(event, nodes, next) { try { for (const node of nodes) { - let uses_str = ''; - const replacer = /** @param {any} thing */ (thing) => { @@ -521,7 +519,7 @@ async function _get_data(event, nodes, next) { if (!node) { str = 'null'; } else { - uses_str = stringify_uses(node); + const uses_str = stringify_uses(node); str = `{"type":"data","data":${devalue.uneval(node.data, replacer)},${uses_str}${ node.slash ? `,"slash":${JSON.stringify(node.slash)}` : '' From e5a55da520ec8877acb80e9c7fbf6dd9038e65cc Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sat, 18 Feb 2023 10:23:35 -0500 Subject: [PATCH 34/62] add comment --- packages/kit/src/runtime/server/data/index.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/kit/src/runtime/server/data/index.js b/packages/kit/src/runtime/server/data/index.js index cc664a3ba6c8..15ed3c96adb5 100644 --- a/packages/kit/src/runtime/server/data/index.js +++ b/packages/kit/src/runtime/server/data/index.js @@ -121,6 +121,8 @@ export async function render_data( const { value: first } = await chunks.next(); if (!first?.has_more) { + // use a normal JSON response where possible, so we get `content-length` + // and can use browser JSON devtools for easier inspecting return json_response(/** @type {NonNullable} */ (first).data); } From 22a038422559bbdae9be38fe9d915788fad24b57 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sat, 18 Feb 2023 10:46:27 -0500 Subject: [PATCH 35/62] hoist reviver --- packages/kit/src/runtime/server/data/index.js | 100 +++++++++--------- 1 file changed, 49 insertions(+), 51 deletions(-) diff --git a/packages/kit/src/runtime/server/data/index.js b/packages/kit/src/runtime/server/data/index.js index 15ed3c96adb5..8685d98bd001 100644 --- a/packages/kit/src/runtime/server/data/index.js +++ b/packages/kit/src/runtime/server/data/index.js @@ -195,60 +195,58 @@ async function _get_data_json(event, options, nodes, next) { let count = 0; let strings = []; - try { - for (const node of nodes) { - let uses_str = ''; - - const revivers = { - /** @param {any} thing */ - Promise: (thing) => { - if (typeof thing?.then === 'function') { - const id = promise_id++; - count += 1; - - thing - .then(/** @param {any} d */ (d) => ({ d })) - .catch(/** @param {any} e */ (e) => ({ e })) - .then( - /** - * @param {{d: any; e: any}} result - */ - async ({ d, e }) => { - let data; - let error; - try { - if (e) { - error = devalue.stringify(e, revivers); - } else { - data = devalue.stringify(d, revivers); - } - } catch (e) { - error = await handle_error_and_jsonify( - event, - options, - new Error(clarify_devalue_error(event, /** @type {any} */ (e))) - ); - } - - count -= 1; - - next( - { - has_more: count !== 0, - data: `{"type":"chunk","id":${id}${data ? `,"data":${data}` : ''}${ - error ? `,"error":${error}` : '' - }}\n` - }, - count === 0 - ); + const revivers = { + /** @param {any} thing */ + Promise: (thing) => { + if (typeof thing?.then === 'function') { + const id = promise_id++; + count += 1; + + thing + .then(/** @param {any} d */ (d) => ({ d })) + .catch(/** @param {any} e */ (e) => ({ e })) + .then( + /** + * @param {{d: any; e: any}} result + */ + async ({ d, e }) => { + let data; + let error; + try { + if (e) { + error = devalue.stringify(e, revivers); + } else { + data = devalue.stringify(d, revivers); } + } catch (e) { + error = await handle_error_and_jsonify( + event, + options, + new Error(clarify_devalue_error(event, /** @type {any} */ (e))) + ); + } + + count -= 1; + + next( + { + has_more: count !== 0, + data: `{"type":"chunk","id":${id}${data ? `,"data":${data}` : ''}${ + error ? `,"error":${error}` : '' + }}\n` + }, + count === 0 ); + } + ); - return id; - } - } - }; + return id; + } + } + }; + try { + for (const node of nodes) { let str = ''; if (!node) { @@ -256,7 +254,7 @@ async function _get_data_json(event, options, nodes, next) { } else if (node.type === 'error' || node.type === 'skip') { str = JSON.stringify(node); } else { - uses_str = stringify_uses(node); + const uses_str = stringify_uses(node); str = `{"type":"data","data":${devalue.stringify(node.data, revivers)},${uses_str}${ node.slash ? `,"slash":${JSON.stringify(node.slash)}` : '' From 2a6ce005efefe59e5253d058fe97dbb22f4eff55 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sat, 18 Feb 2023 11:11:16 -0500 Subject: [PATCH 36/62] hoist replacer --- .../kit/src/runtime/server/page/render.js | 80 +++++++++---------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js index a3e09e89e2e3..7136c499538e 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -471,49 +471,49 @@ async function _get_data(event, nodes, next) { let count = 0; let strings = []; - try { - for (const node of nodes) { - const replacer = - /** @param {any} thing */ - (thing) => { - if (typeof thing?.then === 'function') { - const id = promise_id++; - count += 1; - - thing - .then(/** @param {any} data */ (data) => ({ data })) - .catch(/** @param {any} error */ (error) => ({ error })) - .then( - /** - * @param {{data: any; error: any}} result - */ - async ({ data, error }) => { - count -= 1; - - let str; - try { - str = devalue.uneval({ id, data, error }, replacer); - } catch (e) { - error = `new Error(${clarify_devalue_error(event, /** @type {any} */ (e))});`; - data = undefined; - str = devalue.uneval({ id, data, error }, replacer); - } - - next( - { - has_more: count !== 0, - // Needs to be a module script tag or else it's executed before the start script - data: `` - }, - count === 0 - ); - } + const replacer = + /** @param {any} thing */ + (thing) => { + if (typeof thing?.then === 'function') { + const id = promise_id++; + count += 1; + + thing + .then(/** @param {any} data */ (data) => ({ data })) + .catch(/** @param {any} error */ (error) => ({ error })) + .then( + /** + * @param {{data: any; error: any}} result + */ + async ({ data, error }) => { + count -= 1; + + let str; + try { + str = devalue.uneval({ id, data, error }, replacer); + } catch (e) { + error = `new Error(${clarify_devalue_error(event, /** @type {any} */ (e))});`; + data = undefined; + str = devalue.uneval({ id, data, error }, replacer); + } + + next( + { + has_more: count !== 0, + // Needs to be a module script tag or else it's executed before the start script + data: `` + }, + count === 0 ); + } + ); - return `$__sveltekit__.defer(${id})`; - } - }; + return `$__sveltekit__.defer(${id})`; + } + }; + try { + for (const node of nodes) { let str = ''; if (!node) { From e788e55c8b4a01e85f1c6f3700d6a44bca5e1b26 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sat, 18 Feb 2023 11:40:03 -0500 Subject: [PATCH 37/62] separate synchronously serialized data from subsequent chunks --- packages/kit/src/runtime/server/data/index.js | 48 ++++++------ packages/kit/src/runtime/server/page/index.js | 15 ++-- .../kit/src/runtime/server/page/render.js | 44 +++++------ packages/kit/src/utils/generators.js | 76 +++++++++---------- 4 files changed, 87 insertions(+), 96 deletions(-) diff --git a/packages/kit/src/runtime/server/data/index.js b/packages/kit/src/runtime/server/data/index.js index 8685d98bd001..20de13c65ef7 100644 --- a/packages/kit/src/runtime/server/data/index.js +++ b/packages/kit/src/runtime/server/data/index.js @@ -6,7 +6,7 @@ import { clarify_devalue_error, handle_error_and_jsonify, stringify_uses } from import { normalize_path } from '../../../utils/url.js'; import { text } from '../../../exports/index.js'; import * as devalue from 'devalue'; -import { to_generator } from '../../../utils/generators.js'; +import { create_async_iterator, to_generator } from '../../../utils/generators.js'; export const INVALIDATED_PARAM = 'x-sveltekit-invalidated'; @@ -117,21 +117,20 @@ export async function render_data( ) ); - const chunks = get_data_json(event, options, nodes); - const { value: first } = await chunks.next(); + const { data, chunks } = get_data_json(event, options, nodes); - if (!first?.has_more) { + if (!chunks) { // use a normal JSON response where possible, so we get `content-length` // and can use browser JSON devtools for easier inspecting - return json_response(/** @type {NonNullable} */ (first).data); + return json_response(data); } return new Response( new ReadableStream({ async start(controller) { - controller.enqueue(/** @type {NonNullable} */ (first).data); - for await (const next of chunks) { - controller.enqueue(next.data); + controller.enqueue(data); + for await (const chunk of chunks) { + controller.enqueue(chunk); } controller.close(); } @@ -180,21 +179,21 @@ export function redirect_json_response(redirect) { }); } -export const get_data_json = to_generator(_get_data_json); - /** - * The first chunk returns the devalue'd nodes with potentially pending promises. - * Subsequent chunks (if any) return the resolved promises. + * If the serialized data contains promises, `chunks` will be an + * async iterable containing their resolutions * @param {import('types').RequestEvent} event * @param {import('types').SSROptions} options * @param {Array} nodes - * @param {(result: {has_more: boolean; data: any}, done: boolean) => void} next + * @returns {{ data: string, chunks: AsyncIterable | null }} */ -async function _get_data_json(event, options, nodes, next) { +export function get_data_json(event, options, nodes) { let promise_id = 1; let count = 0; let strings = []; + const { iterator, push, done } = create_async_iterator(); + const revivers = { /** @param {any} thing */ Promise: (thing) => { @@ -228,15 +227,12 @@ async function _get_data_json(event, options, nodes, next) { count -= 1; - next( - { - has_more: count !== 0, - data: `{"type":"chunk","id":${id}${data ? `,"data":${data}` : ''}${ - error ? `,"error":${error}` : '' - }}\n` - }, - count === 0 + push( + `{"type":"chunk","id":${id}${data ? `,"data":${data}` : ''}${ + error ? `,"error":${error}` : '' + }}\n` ); + if (count === 0) done(); } ); @@ -264,10 +260,10 @@ async function _get_data_json(event, options, nodes, next) { strings.push(str); } - next( - { has_more: count !== 0, data: `{"type":"data","nodes":[${strings.join(',')}]}\n` }, - count === 0 - ); + return { + data: `{"type":"data","nodes":[${strings.join(',')}]}\n`, + chunks: count > 0 ? iterator : null + }; } catch (e) { throw new Error(clarify_devalue_error(event, /** @type {any} */ (e))); } diff --git a/packages/kit/src/runtime/server/page/index.js b/packages/kit/src/runtime/server/page/index.js index 2bd54bf0e88c..aa6497eb4036 100644 --- a/packages/kit/src/runtime/server/page/index.js +++ b/packages/kit/src/runtime/server/page/index.js @@ -287,18 +287,21 @@ export async function render_page(event, route, page, options, manifest, state, if (state.prerendering && should_prerender_data) { // ndjson format - let body = ''; - for await (const node of get_data_json( + let { data, chunks } = get_data_json( event, options, branch.map((node) => node?.server_data) - )) { - body += node.data; + ); + + if (chunks) { + for await (const chunk of chunks) { + data += chunk; + } } state.prerendering.dependencies.set(data_pathname, { - response: text(body), - body + response: text(data), + body: data }); } diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js index 7136c499538e..151c0fb2cbb6 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -10,7 +10,7 @@ import { uneval_action_response } from './actions.js'; import { clarify_devalue_error, stringify_uses } from '../utils.js'; import { version, public_env } from '../../shared.js'; import { text } from '../../../exports/index.js'; -import { to_generator } from '../../../utils/generators.js'; +import { create_async_iterator, to_generator } from '../../../utils/generators.js'; // TODO rename this function/module @@ -201,12 +201,11 @@ export async function render_response({ return `${resolved_assets}/${path}`; }; - const data_chunks = get_data( + const { data, chunks } = get_data( event, branch.map((b) => b.server_data) ); - const { value } = await data_chunks.next(); - const { data, has_more: needs_streaming } = /** @type {NonNullable} */ (value); + const serialized = { data, form: 'null', error: 'null' }; if (form_value) { @@ -291,7 +290,7 @@ export async function render_response({ opts.push(`hydrate: {\n\t\t\t\t\t${hydrate.join(',\n\t\t\t\t\t')}\n\t\t\t\t}`); } - const streaming = needs_streaming + const streaming = chunks ? `window.$__sveltekit__ = window.$__sveltekit__ || {}; const deferred = new Map(); @@ -434,7 +433,7 @@ export async function render_response({ } } - return !needs_streaming + return !chunks ? text(transformed, { status, headers @@ -443,9 +442,11 @@ export async function render_response({ new ReadableStream({ async start(controller) { controller.enqueue(transformed); - for await (const next of data_chunks) { - controller.enqueue(next.data); + for await (const chunk of chunks) { + console.log(`enqueueing ${chunk}`); + controller.enqueue(chunk); } + console.log('done'); controller.close(); } }), @@ -457,20 +458,20 @@ export async function render_response({ ); } -const get_data = to_generator(_get_data); - /** - * The first chunk returns the unevaluated data nodes, potentially with promise placeholders. - * Subsequent chunks (if any) return script tags that resolve those promises. + * If the serialized data contains promises, `chunks` will be an + * async iterable containing their resolutions * @param {import('types').RequestEvent} event * @param {Array} nodes - * @param {(result: {has_more: boolean; data: string}, done: boolean) => void} next + * @returns {{ data: string, chunks: AsyncIterable | null }} */ -async function _get_data(event, nodes, next) { +function get_data(event, nodes) { let promise_id = 1; let count = 0; let strings = []; + const { iterator, push, done } = create_async_iterator(); + const replacer = /** @param {any} thing */ (thing) => { @@ -497,14 +498,8 @@ async function _get_data(event, nodes, next) { str = devalue.uneval({ id, data, error }, replacer); } - next( - { - has_more: count !== 0, - // Needs to be a module script tag or else it's executed before the start script - data: `` - }, - count === 0 - ); + push(``); + if (count === 0) done(); } ); @@ -529,7 +524,10 @@ async function _get_data(event, nodes, next) { strings.push(str); } - next({ has_more: count !== 0, data: `[${strings.join(',')}]` }, count === 0); + return { + data: `[${strings.join(',')}]`, + chunks: count > 0 ? iterator : null + }; } catch (e) { throw new Error(clarify_devalue_error(event, /** @type {any} */ (e))); } diff --git a/packages/kit/src/utils/generators.js b/packages/kit/src/utils/generators.js index 8e2f3fd49093..f5fcce837c1d 100644 --- a/packages/kit/src/utils/generators.js +++ b/packages/kit/src/utils/generators.js @@ -1,50 +1,44 @@ /** - * This method is necessary because we can't yield from inside a callback, - * so we smooth over an internal less ergonomic callback API - * @template {(...args: any) => void} Fn - * @param {Fn} fn - * @returns {Parameters extends [...infer InitPs, (value: infer Value, done: boolean) => void] ? (...args: InitPs) => AsyncGenerator : never} + * @returns {import("types").Deferred & { promise: Promise }}} */ -export function to_generator(fn) { - // @ts-ignore yeah TS this return type is fucked up, I know - return async function* (...args) { - /** @type {(v: any) => void} */ - let fulfil; +function defer() { + let fulfil; + let reject; - /** @type {(v: any) => void} */ - let reject; + const promise = new Promise((f, r) => { + fulfil = f; + reject = r; + }); - let promise = new Promise((f, r) => { - fulfil = f; - reject = r; - }); + // @ts-expect-error + return { promise, fulfil, reject }; +} - // Ensure it runs after we enter the loop to not swallow the first eager result - Promise.resolve() - .then(() => - fn( - ...args, - /** - *@param {any} result - * @param {boolean} done - */ - (result, done) => { - fulfil({ result, done }); - if (!done) { - promise = new Promise((r) => { - fulfil = r; - }); - } - } - ) - ) - // catch and rethrow to avoid unhandled promise rejection - .catch((e) => reject(e)); +/** + * Create an async iterator and a function to push values into it + * @returns {{ + * iterator: AsyncIterable; + * push: (value: any) => void; + * done: () => void; + * }} + */ +export function create_async_iterator() { + let deferred = defer(); - while (true) { - const { result, done } = await promise; - yield result; - if (done) return undefined; + return { + iterator: { + [Symbol.asyncIterator]() { + return { + next: () => deferred.promise + }; + } + }, + push: (value) => { + deferred.fulfil({ value, done: false }); + deferred = defer(); + }, + done: () => { + deferred.fulfil({ done: true }); } }; } From 6163430c819238a3edb0a93ddb59ea954194d2f1 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sat, 18 Feb 2023 11:51:09 -0500 Subject: [PATCH 38/62] remove logs --- packages/kit/src/runtime/server/page/render.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js index 151c0fb2cbb6..5e2cb65f7d63 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -443,10 +443,8 @@ export async function render_response({ async start(controller) { controller.enqueue(transformed); for await (const chunk of chunks) { - console.log(`enqueueing ${chunk}`); controller.enqueue(chunk); } - console.log('done'); controller.close(); } }), From 6462818a8fe07e7b60d31b5f6789407dd01bdad9 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sat, 18 Feb 2023 11:57:38 -0500 Subject: [PATCH 39/62] simplify --- packages/kit/src/runtime/server/data/index.js | 24 +++++++------------ .../kit/src/runtime/server/page/render.js | 20 +++++----------- 2 files changed, 15 insertions(+), 29 deletions(-) diff --git a/packages/kit/src/runtime/server/data/index.js b/packages/kit/src/runtime/server/data/index.js index 20de13c65ef7..083867beeed6 100644 --- a/packages/kit/src/runtime/server/data/index.js +++ b/packages/kit/src/runtime/server/data/index.js @@ -242,23 +242,17 @@ export function get_data_json(event, options, nodes) { }; try { - for (const node of nodes) { - let str = ''; - - if (!node) { - str = 'null'; - } else if (node.type === 'error' || node.type === 'skip') { - str = JSON.stringify(node); - } else { - const uses_str = stringify_uses(node); - - str = `{"type":"data","data":${devalue.stringify(node.data, revivers)},${uses_str}${ - node.slash ? `,"slash":${JSON.stringify(node.slash)}` : '' - }}`; + const strings = nodes.map((node) => { + if (!node) return 'null'; + + if (node.type === 'error' || node.type === 'skip') { + return JSON.stringify(node); } - strings.push(str); - } + return `{"type":"data","data":${devalue.stringify(node.data, revivers)},${stringify_uses( + node + )}${node.slash ? `,"slash":${JSON.stringify(node.slash)}` : ''}}`; + }); return { data: `{"type":"data","nodes":[${strings.join(',')}]}\n`, diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js index 5e2cb65f7d63..a301396e6d9c 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -506,21 +506,13 @@ function get_data(event, nodes) { }; try { - for (const node of nodes) { - let str = ''; + const strings = nodes.map((node) => { + if (!node) return 'null'; - if (!node) { - str = 'null'; - } else { - const uses_str = stringify_uses(node); - - str = `{"type":"data","data":${devalue.uneval(node.data, replacer)},${uses_str}${ - node.slash ? `,"slash":${JSON.stringify(node.slash)}` : '' - }}`; - } - - strings.push(str); - } + return `{"type":"data","data":${devalue.uneval(node.data, replacer)},${stringify_uses(node)}${ + node.slash ? `,"slash":${JSON.stringify(node.slash)}` : '' + }}`; + }); return { data: `[${strings.join(',')}]`, From 1e7a079a03e5d6112e654b1474335051aa56207a Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sat, 18 Feb 2023 12:01:45 -0500 Subject: [PATCH 40/62] lint --- packages/kit/src/runtime/server/data/index.js | 3 +- .../kit/src/runtime/server/page/render.js | 66 +++++++++---------- 2 files changed, 33 insertions(+), 36 deletions(-) diff --git a/packages/kit/src/runtime/server/data/index.js b/packages/kit/src/runtime/server/data/index.js index 083867beeed6..7d597f61e294 100644 --- a/packages/kit/src/runtime/server/data/index.js +++ b/packages/kit/src/runtime/server/data/index.js @@ -6,7 +6,7 @@ import { clarify_devalue_error, handle_error_and_jsonify, stringify_uses } from import { normalize_path } from '../../../utils/url.js'; import { text } from '../../../exports/index.js'; import * as devalue from 'devalue'; -import { create_async_iterator, to_generator } from '../../../utils/generators.js'; +import { create_async_iterator } from '../../../utils/generators.js'; export const INVALIDATED_PARAM = 'x-sveltekit-invalidated'; @@ -190,7 +190,6 @@ export function redirect_json_response(redirect) { export function get_data_json(event, options, nodes) { let promise_id = 1; let count = 0; - let strings = []; const { iterator, push, done } = create_async_iterator(); diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js index a301396e6d9c..02273c46ce82 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -10,7 +10,7 @@ import { uneval_action_response } from './actions.js'; import { clarify_devalue_error, stringify_uses } from '../utils.js'; import { version, public_env } from '../../shared.js'; import { text } from '../../../exports/index.js'; -import { create_async_iterator, to_generator } from '../../../utils/generators.js'; +import { create_async_iterator } from '../../../utils/generators.js'; // TODO rename this function/module @@ -466,44 +466,42 @@ export async function render_response({ function get_data(event, nodes) { let promise_id = 1; let count = 0; - let strings = []; const { iterator, push, done } = create_async_iterator(); - const replacer = - /** @param {any} thing */ - (thing) => { - if (typeof thing?.then === 'function') { - const id = promise_id++; - count += 1; - - thing - .then(/** @param {any} data */ (data) => ({ data })) - .catch(/** @param {any} error */ (error) => ({ error })) - .then( - /** - * @param {{data: any; error: any}} result - */ - async ({ data, error }) => { - count -= 1; - - let str; - try { - str = devalue.uneval({ id, data, error }, replacer); - } catch (e) { - error = `new Error(${clarify_devalue_error(event, /** @type {any} */ (e))});`; - data = undefined; - str = devalue.uneval({ id, data, error }, replacer); - } - - push(``); - if (count === 0) done(); + /** @param {any} thing */ + function replacer(thing) { + if (typeof thing?.then === 'function') { + const id = promise_id++; + count += 1; + + thing + .then(/** @param {any} data */ (data) => ({ data })) + .catch(/** @param {any} error */ (error) => ({ error })) + .then( + /** + * @param {{data: any; error: any}} result + */ + async ({ data, error }) => { + count -= 1; + + let str; + try { + str = devalue.uneval({ id, data, error }, replacer); + } catch (e) { + error = `new Error(${clarify_devalue_error(event, /** @type {any} */ (e))});`; + data = undefined; + str = devalue.uneval({ id, data, error }, replacer); } - ); - return `$__sveltekit__.defer(${id})`; - } - }; + push(``); + if (count === 0) done(); + } + ); + + return `$__sveltekit__.defer(${id})`; + } + } try { const strings = nodes.map((node) => { From fc6dd7e1de2eba71f7f0655e6cc18aed18184239 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sat, 18 Feb 2023 22:58:34 -0500 Subject: [PATCH 41/62] put everything in a single non-module script --- packages/kit/src/runtime/client/start.js | 9 +- .../kit/src/runtime/server/page/render.js | 198 +++++++++--------- .../kit/test/prerendering/basics/test/test.js | 20 -- 3 files changed, 103 insertions(+), 124 deletions(-) diff --git a/packages/kit/src/runtime/client/start.js b/packages/kit/src/runtime/client/start.js index 0b28d108a9bc..25366af1edd3 100644 --- a/packages/kit/src/runtime/client/start.js +++ b/packages/kit/src/runtime/client/start.js @@ -4,15 +4,10 @@ import { init } from './singletons.js'; /** * @param {import('./types').SvelteKitApp} app - * @param {string} hash + * @param {HTMLElement} target * @param {Parameters[0]} [hydrate] */ -export async function start(app, hash, hydrate) { - const target = /** @type {HTMLElement} */ ( - /** @type {HTMLScriptElement} */ (document.querySelector(`[data-sveltekit-hydrate="${hash}"]`)) - .parentNode - ); - +export async function start(app, target, hydrate) { if (DEV && target === document.body) { console.warn( `Placing %sveltekit.body% directly inside is not recommended, as your app may break for users who have certain browser extensions installed.\n\nConsider wrapping it in an element:\n\n

\n %sveltekit.body%\n
` diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js index 391944def03d..72966115b488 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -190,14 +190,6 @@ export async function render_response({ prerender: !!state.prerendering }); - const init = `__sveltekit_${options.version_hash}={env:${s( - public_env - )},assets:${asset_expression}}`; - - csp.add_script(init); - - head += `${init}`; - const target = hash(body); /** @param {string} path */ @@ -211,21 +203,6 @@ export async function render_response({ return `${resolved_assets}/${path}`; }; - const { data, chunks } = get_data( - event, - branch.map((b) => b.server_data) - ); - - const serialized = { data, form: 'null', error: 'null' }; - - if (form_value) { - serialized.form = uneval_action_response(form_value, /** @type {string} */ (event.route.id)); - } - - if (error) { - serialized.error = devalue.uneval(error); - } - if (inline_styles.size > 0) { const content = Array.from(inline_styles.values()).join('\n'); @@ -273,53 +250,23 @@ export async function render_response({ } } - if (page_config.csr) { - const args = [`app`, `"${target}"`]; - - if (page_config.ssr) { - const hydrate = [ - `node_ids: [${branch.map(({ node }) => node.index).join(', ')}]`, - `data: ${serialized.data}`, - `form: ${serialized.form}`, - `error: ${serialized.error}` - ]; + const global = `__sveltekit_${options.version_hash}`; - if (status !== 200) { - hydrate.push(`status: ${status}`); - } - - if (options.embedded) { - hydrate.push(`params: ${devalue.uneval(event.params)}`, `route: ${s(event.route)}`); - } - - args.push(`{\n\t\t\t\t${hydrate.join(',\n\t\t\t\t')}\n\t\t\t}`); - } - - const streaming = chunks - ? `window.$__sveltekit__ = window.$__sveltekit__ || {}; - - const deferred = new Map(); - - $__sveltekit__.defer = (id) => new Promise((fulfil, reject) => { - deferred.set(id, { fulfil, reject }); - }); - - $__sveltekit__.resolve = ({ id, data, error }) => { - const { fulfil, reject } = deferred.get(id); - deferred.delete(id); - - if (error) reject(error); - else fulfil(data); - };` - : ''; + const { data, chunks } = get_data( + event, + branch.map((b) => b.server_data), + global + ); - const init_app = ` - import { start } from ${s(prefixed(client.start.file))}; - import * as app from ${s(prefixed(client.app.file))}; - ${streaming} - start(${args.join(', ')}); - `; + if (page_config.ssr && page_config.csr) { + body += `\n\t\t\t${fetched + .map((item) => + serialize_data(item, resolve_opts.filterSerializedResponseHeaders, !!state.prerendering) + ) + .join('\n\t\t\t')}`; + } + if (page_config.csr) { const included_modulepreloads = Array.from(modulepreloads, (dep) => prefixed(dep)).filter( (path) => resolve_opts.preload({ type: 'js', path }) ); @@ -333,43 +280,99 @@ export async function render_response({ head += `\n\t\t`; } - const attributes = ['type="module"', `data-sveltekit-hydrate="${target}"`]; + const blocks = []; - csp.add_script(init_app); + const properties = [ + `env: ${s(public_env)}`, + `assets: ${asset_expression}`, + `element: document.currentScript.parentElement` + ]; + + if (chunks) { + blocks.push(`const deferred = new Map();`); - if (csp.script_needs_nonce) { - attributes.push(`nonce="${csp.nonce}"`); + properties.push(`defer: (id) => new Promise((fulfil, reject) => { + deferred.set(id, { fulfil, reject }); + })`); + + properties.push(`resolve: ({ id, data, error }) => { + const { fulfil, reject } = deferred.get(id); + deferred.delete(id); + + if (error) reject(error); + else fulfil(data); + }`); } - body += `\n\t\t`; - } + blocks.push(`${global} = { + ${properties.join(',\n\t\t\t\t\t\t')} + };`); - if (page_config.ssr && page_config.csr) { - body += `\n\t${fetched - .map((item) => - serialize_data(item, resolve_opts.filterSerializedResponseHeaders, !!state.prerendering) - ) - .join('\n\t')}`; - } + const args = [`app`, `${global}.element`]; + + if (page_config.ssr) { + const serialized = { form: 'null', error: 'null' }; - if (options.service_worker) { - const opts = __SVELTEKIT_DEV__ ? `, { type: 'module' }` : ''; + blocks.push(`const data = ${data};`); - // we use an anonymous function instead of an arrow function to support - // older browsers (https://github.com/sveltejs/kit/pull/5417) - const init_service_worker = ` - if ('serviceWorker' in navigator) { - addEventListener('load', function () { - navigator.serviceWorker.register('${prefixed('service-worker.js')}'${opts}); - }); + if (form_value) { + serialized.form = uneval_action_response( + form_value, + /** @type {string} */ (event.route.id) + ); + } + + if (error) { + serialized.error = devalue.uneval(error); } - `; - // always include service worker unless it's turned off explicitly - csp.add_script(init_service_worker); + const hydrate = [ + `node_ids: [${branch.map(({ node }) => node.index).join(', ')}]`, + `data`, + `form: ${serialized.form}`, + `error: ${serialized.error}` + ]; + + if (status !== 200) { + hydrate.push(`status: ${status}`); + } + + if (options.embedded) { + hydrate.push(`params: ${devalue.uneval(event.params)}`, `route: ${s(event.route)}`); + } + + args.push(`{\n\t\t\t\t\t\t\t${hydrate.join(',\n\t\t\t\t\t\t\t')}\n\t\t\t\t\t\t}`); + } + + blocks.push(`Promise.all([ + import(${s(prefixed(client.start.file))}), + import(${s(prefixed(client.app.file))}) + ]).then(([kit, app]) => { + kit.start(${args.join(', ')}); + });`); + + if (options.service_worker) { + const opts = __SVELTEKIT_DEV__ ? `, { type: 'module' }` : ''; + + // we use an anonymous function instead of an arrow function to support + // older browsers (https://github.com/sveltejs/kit/pull/5417) + blocks.push(`if ('serviceWorker' in navigator) { + addEventListener('load', function () { + navigator.serviceWorker.register('${prefixed('service-worker.js')}'${opts}); + }); + }`); + } + + const init_app = ` + { + ${blocks.join('\n\n\t\t\t\t\t')} + } + `; + csp.add_script(init_app); - head += ` - ${init_service_worker}`; + body += `\n\t\t\t${init_app}\n\t\t`; } if (state.prerendering) { @@ -467,9 +470,10 @@ export async function render_response({ * async iterable containing their resolutions * @param {import('types').RequestEvent} event * @param {Array} nodes + * @param {string} global * @returns {{ data: string, chunks: AsyncIterable | null }} */ -function get_data(event, nodes) { +function get_data(event, nodes, global) { let promise_id = 1; let count = 0; @@ -500,12 +504,12 @@ function get_data(event, nodes) { str = devalue.uneval({ id, data, error }, replacer); } - push(``); + push(`\n`); if (count === 0) done(); } ); - return `$__sveltekit__.defer(${id})`; + return `${global}.defer(${id})`; } } diff --git a/packages/kit/test/prerendering/basics/test/test.js b/packages/kit/test/prerendering/basics/test/test.js index 6e5941290adc..8235e18df2d7 100644 --- a/packages/kit/test/prerendering/basics/test/test.js +++ b/packages/kit/test/prerendering/basics/test/test.js @@ -149,26 +149,6 @@ test('fetching missing content results in a 404', () => { assert.ok(content.includes('

status: 404

'), content); }); -test('targets the data-sveltekit-hydrate parent node', () => { - // this test ensures that we don't accidentally change the way - // the body is hydrated in a way that breaks apps that need - // to manipulate the markup in some way: - // https://github.com/sveltejs/kit/issues/4685 - const content = read('index.html'); - - const pattern = - /([^]+?)\n\t\t`; } + const headers = new Headers({ + 'x-sveltekit-page': 'true', + 'content-type': 'text/html' + }); + if (state.prerendering) { // TODO read headers set with setHeaders and convert into http-equiv where possible const http_equiv = []; @@ -389,6 +394,19 @@ export async function render_response({ if (http_equiv.length > 0) { head = http_equiv.join('\n') + head; } + } else { + const csp_header = csp.csp_provider.get_header(); + if (csp_header) { + headers.set('content-security-policy', csp_header); + } + const report_only_header = csp.report_only_provider.get_header(); + if (report_only_header) { + headers.set('content-security-policy-report-only', report_only_header); + } + + if (link_header_preloads.size) { + headers.set('link', Array.from(link_header_preloads).join(', ')); + } } // add the content after the script/css links so the link elements are parsed first @@ -409,6 +427,10 @@ export async function render_response({ done: true })) || ''; + if (!chunks) { + headers.set('etag', `"${hash(transformed)}"`); + } + if (DEV && page_config.csr) { if (transformed.split('