Skip to content

Commit 38fd7f2

Browse files
dummdidummRich-Harrisgeoffrich
authored
feat: stream non-essential data (#8901)
* defer for client-side navigations * docs * changeset * needs lots of cleanup, but works for client-side navigations * remove defer * deduplicate * extract common logic into generator and reuse * ssr * update changelog * fixes * update docs * try this * this fucking timing stuff * fingers crossed * incapable of getting ten lines of code right without intellisense * remove unpredictable uses tracking in favor of docs and warning * too many braces * these tests are killing me * combine streaming and promise unwrapping docs * add comment about non-streaming platforms * typo * move warning to load_server_data, make it more situation-specific * lol wut * symmetry * tweak * simplify * use conventional names * add a Deferred interface, remove belt and braces (could mask legitimate bugs) * if done is true, value is guaranteed to be undefined * remove outdated comments * we can just reuse the object * use text/plain for easier inspecting * make var local * add comment * hoist reviver * hoist replacer * separate synchronously serialized data from subsequent chunks * remove logs * simplify * lint * put everything in a single non-module script * rename tests * lint * small tweak to aid minifiability * oops * for now, skip streaming tests with vite preview * remove only * squelch erroneous access warnings * only set etag when no streaming * warn if streaming when csr === false * rename file * doh * Update documentation/docs/20-core-concepts/20-load.md Co-authored-by: Geoff Rich <[email protected]> * Update documentation/docs/20-core-concepts/20-load.md Co-authored-by: Geoff Rich <[email protected]> * Update documentation/docs/20-core-concepts/20-load.md * Update documentation/docs/20-core-concepts/20-load.md * Update documentation/docs/20-core-concepts/20-load.md * add some juicy keywords * error handling * fix bad link * fix some stuff * generous timeouts --------- Co-authored-by: Rich Harris <[email protected]> Co-authored-by: Rich Harris <[email protected]> Co-authored-by: Geoff Rich <[email protected]>
1 parent 9c626a9 commit 38fd7f2

File tree

22 files changed

+818
-286
lines changed

22 files changed

+818
-286
lines changed

.changeset/ten-mice-brush.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sveltejs/kit': minor
3+
---
4+
5+
feat: implement streaming promises for server load functions

documentation/docs/20-core-concepts/20-load.md

Lines changed: 38 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ Universal `load` functions are called with a `LoadEvent`, which has a `data` pro
174174

175175
A universal `load` function can return an object containing any values, including things like custom classes and component constructors.
176176

177-
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.
177+
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](#streaming-with-promises), in which case it will be streamed to browsers.
178178

179179
### When to use which
180180

@@ -420,36 +420,59 @@ export function load({ locals }) {
420420
421421
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).
422422

423-
## Promise unwrapping
423+
## Streaming with promises
424424

425-
Top-level promises will be awaited, which makes it easy to return multiple promises without creating a waterfall:
425+
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. This is useful if you have slow, non-essential data, since you can start rendering the page before all the data is available:
426426

427427
```js
428-
/// file: src/routes/+page.js
429-
/** @type {import('./$types').PageLoad} */
428+
/// file: src/routes/+page.server.js
429+
/** @type {import('./$types').PageServerLoad} */
430430
export function load() {
431431
return {
432-
a: Promise.resolve('a'),
433-
b: Promise.resolve('b'),
434-
c: {
435-
value: Promise.resolve('c')
432+
one: Promise.resolve(1),
433+
two: Promise.resolve(2),
434+
streamed: {
435+
three: new Promise((fulfil) => {
436+
setTimeout(() => {
437+
fulfil(3)
438+
}, 1000);
439+
})
436440
}
437441
};
438442
}
439443
```
440444

445+
This is useful for creating skeleton loading states, for example:
446+
441447
```svelte
442448
/// file: src/routes/+page.svelte
443449
<script>
444450
/** @type {import('./$types').PageData} */
445451
export let data;
446-
447-
console.log(data.a); // 'a'
448-
console.log(data.b); // 'b'
449-
console.log(data.c.value); // `Promise {...}`
450452
</script>
453+
454+
<p>
455+
one: {data.one}
456+
</p>
457+
<p>
458+
two: {data.two}
459+
</p>
460+
<p>
461+
three:
462+
{#await data.streamed.three}
463+
Loading...
464+
{:then value}
465+
{value}
466+
{:catch error}
467+
{error.message}
468+
{/await}
469+
</p>
451470
```
452471

472+
On platforms that do not support streaming, such as AWS Lambda, responses will be buffered. This means the page will only render once all promises resolve.
473+
474+
> Streaming data will only work when JavaScript is enabled. You should avoid returning nested promises from a universal `load` function if the page is server rendered, as these are _not_ streamed — instead, the promise is recreated when the function re-runs in the browser.
475+
453476
## Parallel loading
454477

455478
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.
@@ -502,6 +525,8 @@ export async function load() {
502525

503526
A `load` function that calls `await parent()` will also re-run if a parent `load` function is re-run.
504527

528+
Dependency tracking does not apply _after_ the `load` function has returned — for example, accessing `params.x` inside a nested [promise](#streaming-with-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.
529+
505530
### Manual invalidation
506531

507532
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.

packages/kit/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
"@sveltejs/vite-plugin-svelte": "^2.0.0",
1414
"@types/cookie": "^0.5.1",
1515
"cookie": "^0.5.0",
16-
"devalue": "^4.2.3",
16+
"devalue": "^4.3.0",
1717
"esm-env": "^1.0.0",
1818
"kleur": "^4.1.5",
1919
"magic-string": "^0.29.0",

packages/kit/src/runtime/client/client.js

Lines changed: 94 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,11 @@ function update_scroll_positions(index) {
5252
}
5353

5454
/**
55-
* @param {{
56-
* app: import('./types').SvelteKitApp;
57-
* target: HTMLElement;
58-
* }} opts
55+
* @param {import('./types').SvelteKitApp} app
56+
* @param {HTMLElement} target
5957
* @returns {import('./types').Client}
6058
*/
61-
export function create_client({ app, target }) {
59+
export function create_client(app, target) {
6260
const routes = parse(app);
6361

6462
const default_layout_loader = app.nodes[0];
@@ -735,22 +733,8 @@ export function create_client({ app, target }) {
735733
* @returns {import('./types').DataNode | null}
736734
*/
737735
function create_data_node(node, previous) {
738-
if (node?.type === 'data') {
739-
return {
740-
type: 'data',
741-
data: node.data,
742-
uses: {
743-
dependencies: new Set(node.uses.dependencies ?? []),
744-
params: new Set(node.uses.params ?? []),
745-
parent: !!node.uses.parent,
746-
route: !!node.uses.route,
747-
url: !!node.uses.url
748-
},
749-
slash: node.slash
750-
};
751-
} else if (node?.type === 'skip') {
752-
return previous ?? null;
753-
}
736+
if (node?.type === 'data') return node;
737+
if (node?.type === 'skip') return previous ?? null;
754738
return null;
755739
}
756740

@@ -773,7 +757,7 @@ export function create_client({ app, target }) {
773757
errors.forEach((loader) => loader?.().catch(() => {}));
774758
loaders.forEach((loader) => loader?.[1]().catch(() => {}));
775759

776-
/** @type {import('types').ServerData | null} */
760+
/** @type {import('types').ServerNodesResponse | import('types').ServerRedirectNode | null} */
777761
let server_data = null;
778762

779763
const url_changed = current.url ? id !== current.url.pathname + current.url.search : false;
@@ -1724,6 +1708,10 @@ export function create_client({ app, target }) {
17241708
try {
17251709
const branch_promises = node_ids.map(async (n, i) => {
17261710
const server_data_node = server_data_nodes[i];
1711+
// Type isn't completely accurate, we still need to deserialize uses
1712+
if (server_data_node?.uses) {
1713+
server_data_node.uses = deserialize_uses(server_data_node.uses);
1714+
}
17271715

17281716
return load_node({
17291717
loader: app.nodes[n],
@@ -1774,7 +1762,7 @@ export function create_client({ app, target }) {
17741762
/**
17751763
* @param {URL} url
17761764
* @param {boolean[]} invalid
1777-
* @returns {Promise<import('types').ServerData>}
1765+
* @returns {Promise<import('types').ServerNodesResponse |import('types').ServerRedirectNode>}
17781766
*/
17791767
async function load_data(url, invalid) {
17801768
const data_url = new URL(url);
@@ -1788,29 +1776,98 @@ async function load_data(url, invalid) {
17881776
);
17891777

17901778
const res = await native_fetch(data_url.href);
1791-
const data = await res.json();
17921779

17931780
if (!res.ok) {
17941781
// error message is a JSON-stringified string which devalue can't handle at the top level
17951782
// turn it into a HttpError to not call handleError on the client again (was already handled on the server)
1796-
throw new HttpError(res.status, data);
1783+
throw new HttpError(res.status, await res.json());
17971784
}
17981785

1799-
// revive devalue-flattened data
1800-
data.nodes?.forEach((/** @type {any} */ node) => {
1801-
if (node?.type === 'data') {
1802-
node.data = devalue.unflatten(node.data);
1803-
node.uses = {
1804-
dependencies: new Set(node.uses.dependencies ?? []),
1805-
params: new Set(node.uses.params ?? []),
1806-
parent: !!node.uses.parent,
1807-
route: !!node.uses.route,
1808-
url: !!node.uses.url
1809-
};
1786+
return new Promise(async (resolve) => {
1787+
/**
1788+
* Map of deferred promises that will be resolved by a subsequent chunk of data
1789+
* @type {Map<string, import('types').Deferred>}
1790+
*/
1791+
const deferreds = new Map();
1792+
const reader = /** @type {ReadableStream<Uint8Array>} */ (res.body).getReader();
1793+
const decoder = new TextDecoder();
1794+
1795+
/**
1796+
* @param {any} data
1797+
*/
1798+
function deserialize(data) {
1799+
return devalue.unflatten(data, {
1800+
Promise: (id) => {
1801+
return new Promise((fulfil, reject) => {
1802+
deferreds.set(id, { fulfil, reject });
1803+
});
1804+
}
1805+
});
1806+
}
1807+
1808+
let text = '';
1809+
1810+
while (true) {
1811+
// Format follows ndjson (each line is a JSON object) or regular JSON spec
1812+
const { done, value } = await reader.read();
1813+
if (done && !text) break;
1814+
1815+
text += !value && text ? '\n' : decoder.decode(value); // no value -> final chunk -> add a new line to trigger the last parse
1816+
1817+
while (true) {
1818+
const split = text.indexOf('\n');
1819+
if (split === -1) {
1820+
break;
1821+
}
1822+
1823+
const node = JSON.parse(text.slice(0, split));
1824+
text = text.slice(split + 1);
1825+
1826+
if (node.type === 'redirect') {
1827+
return resolve(node);
1828+
}
1829+
1830+
if (node.type === 'data') {
1831+
// This is the first (and possibly only, if no pending promises) chunk
1832+
node.nodes?.forEach((/** @type {any} */ node) => {
1833+
if (node?.type === 'data') {
1834+
node.uses = deserialize_uses(node.uses);
1835+
node.data = deserialize(node.data);
1836+
}
1837+
});
1838+
1839+
resolve(node);
1840+
} else if (node.type === 'chunk') {
1841+
// This is a subsequent chunk containing deferred data
1842+
const { id, data, error } = node;
1843+
const deferred = /** @type {import('types').Deferred} */ (deferreds.get(id));
1844+
deferreds.delete(id);
1845+
1846+
if (error) {
1847+
deferred.reject(deserialize(error));
1848+
} else {
1849+
deferred.fulfil(deserialize(data));
1850+
}
1851+
}
1852+
}
18101853
}
18111854
});
18121855

1813-
return data;
1856+
// TODO edge case handling necessary? stream() read fails?
1857+
}
1858+
1859+
/**
1860+
* @param {any} uses
1861+
* @return {import('types').Uses}
1862+
*/
1863+
function deserialize_uses(uses) {
1864+
return {
1865+
dependencies: new Set(uses?.dependencies ?? []),
1866+
params: new Set(uses?.params ?? []),
1867+
parent: !!uses?.parent,
1868+
route: !!uses?.route,
1869+
url: !!uses?.url
1870+
};
18141871
}
18151872

18161873
function reset_focus() {

packages/kit/src/runtime/client/start.js

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,17 @@ import { init } from './singletons.js';
44

55
/**
66
* @param {import('./types').SvelteKitApp} app
7-
* @param {string} hash
7+
* @param {HTMLElement} target
88
* @param {Parameters<import('./types').Client['_hydrate']>[0]} [hydrate]
99
*/
10-
export async function start(app, hash, hydrate) {
11-
const target = /** @type {HTMLElement} */ (
12-
/** @type {HTMLScriptElement} */ (document.querySelector(`[data-sveltekit-hydrate="${hash}"]`))
13-
.parentNode
14-
);
15-
10+
export async function start(app, target, hydrate) {
1611
if (DEV && target === document.body) {
1712
console.warn(
1813
`Placing %sveltekit.body% directly inside <body> 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<div style="display: contents">\n %sveltekit.body%\n</div>`
1914
);
2015
}
2116

22-
const client = create_client({ app, target });
17+
const client = create_client(app, target);
2318

2419
init({ client });
2520

0 commit comments

Comments
 (0)