Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: stream non-essential data #8901

Merged
merged 66 commits into from
Feb 20, 2023
Merged
Show file tree
Hide file tree
Changes from 52 commits
Commits
Show all changes
66 commits
Select commit Hold shift + click to select a range
6dd0fd1
defer for client-side navigations
dummdidumm Feb 6, 2023
fa7837c
docs
dummdidumm Feb 6, 2023
e920b19
changeset
dummdidumm Feb 6, 2023
a8486d0
Merge branch 'master' into defer
dummdidumm Feb 10, 2023
c8a7173
needs lots of cleanup, but works for client-side navigations
dummdidumm Feb 12, 2023
8e07a4a
remove defer
dummdidumm Feb 12, 2023
62a28bb
deduplicate
dummdidumm Feb 12, 2023
3ef4814
extract common logic into generator and reuse
dummdidumm Feb 13, 2023
dae3ce7
ssr
dummdidumm Feb 13, 2023
40b2917
update changelog
dummdidumm Feb 15, 2023
74af48d
fixes
dummdidumm Feb 15, 2023
42a8051
update docs
dummdidumm Feb 15, 2023
188c50f
try this
dummdidumm Feb 15, 2023
831677d
this fucking timing stuff
dummdidumm Feb 15, 2023
718ef66
fingers crossed
dummdidumm Feb 15, 2023
73892d9
incapable of getting ten lines of code right without intellisense
dummdidumm Feb 17, 2023
1da068e
remove unpredictable uses tracking in favor of docs and warning
dummdidumm Feb 17, 2023
76f726e
too many braces
dummdidumm Feb 17, 2023
0d95812
these tests are killing me
dummdidumm Feb 17, 2023
ff99eaa
combine streaming and promise unwrapping docs
Rich-Harris Feb 18, 2023
468a618
add comment about non-streaming platforms
Rich-Harris Feb 18, 2023
386eaed
typo
Rich-Harris Feb 18, 2023
bafabf7
move warning to load_server_data, make it more situation-specific
Rich-Harris Feb 18, 2023
3df2141
lol wut
Rich-Harris Feb 18, 2023
6042d4c
symmetry
Rich-Harris Feb 18, 2023
cb03a87
tweak
Rich-Harris Feb 18, 2023
5ac5bd0
simplify
Rich-Harris Feb 18, 2023
d8f7e04
use conventional names
Rich-Harris Feb 18, 2023
a08aa9f
add a Deferred interface, remove belt and braces (could mask legitima…
Rich-Harris Feb 18, 2023
75fcd0f
if done is true, value is guaranteed to be undefined
Rich-Harris Feb 18, 2023
7e59edc
remove outdated comments
Rich-Harris Feb 18, 2023
16ee75d
we can just reuse the object
Rich-Harris Feb 18, 2023
8208313
use text/plain for easier inspecting
Rich-Harris Feb 18, 2023
0139576
make var local
Rich-Harris Feb 18, 2023
e5a55da
add comment
Rich-Harris Feb 18, 2023
22a0384
hoist reviver
Rich-Harris Feb 18, 2023
2a6ce00
hoist replacer
Rich-Harris Feb 18, 2023
e788e55
separate synchronously serialized data from subsequent chunks
Rich-Harris Feb 18, 2023
6163430
remove logs
Rich-Harris Feb 18, 2023
6462818
simplify
Rich-Harris Feb 18, 2023
1e7a079
lint
Rich-Harris Feb 18, 2023
bf9186d
merge master
Rich-Harris Feb 19, 2023
fc6dd7e
put everything in a single non-module script
Rich-Harris Feb 19, 2023
478272c
rename tests
Rich-Harris Feb 19, 2023
4392eeb
lint
Rich-Harris Feb 19, 2023
b41ae6f
small tweak to aid minifiability
Rich-Harris Feb 19, 2023
5a86922
oops
Rich-Harris Feb 19, 2023
32e2a56
for now, skip streaming tests with vite preview
Rich-Harris Feb 19, 2023
a4a40e1
remove only
Rich-Harris Feb 19, 2023
8c42ed7
squelch erroneous access warnings
Rich-Harris Feb 19, 2023
f396527
only set etag when no streaming
Rich-Harris Feb 19, 2023
3f33746
warn if streaming when csr === false
Rich-Harris Feb 19, 2023
e2516bf
rename file
Rich-Harris Feb 19, 2023
bdbec71
doh
Rich-Harris Feb 19, 2023
df6baa9
Update documentation/docs/20-core-concepts/20-load.md
Rich-Harris Feb 20, 2023
f08cbc0
Update documentation/docs/20-core-concepts/20-load.md
Rich-Harris Feb 20, 2023
ff712bb
Update documentation/docs/20-core-concepts/20-load.md
Rich-Harris Feb 20, 2023
9b9a148
Update documentation/docs/20-core-concepts/20-load.md
Rich-Harris Feb 20, 2023
0670e58
Update documentation/docs/20-core-concepts/20-load.md
Rich-Harris Feb 20, 2023
0f76654
add some juicy keywords
Rich-Harris Feb 20, 2023
e94b491
error handling
Rich-Harris Feb 20, 2023
f60f70b
fix bad link
Rich-Harris Feb 20, 2023
4de6bcf
Merge branch 'master' into defer
Rich-Harris Feb 20, 2023
f79a3ca
Merge branch 'master' into defer
Rich-Harris Feb 20, 2023
b930196
fix some stuff
Rich-Harris Feb 20, 2023
9cec539
generous timeouts
Rich-Harris Feb 20, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ten-mice-brush.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': minor
---

feat: implement streaming promises for server load functions
49 changes: 36 additions & 13 deletions documentation/docs/20-core-concepts/20-load.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 browsers.

### When to use which

Expand Down Expand Up @@ -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
Rich-Harris marked this conversation as resolved.
Show resolved Hide resolved

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:
Rich-Harris marked this conversation as resolved.
Show resolved Hide resolved

```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);
})
}
};
}
Expand All @@ -443,13 +447,30 @@ export function load() {
<script>
/** @type {import('./$types').PageData} */
export let data;

console.log(data.a); // 'a'
console.log(data.b); // 'b'
console.log(data.c.value); // `Promise {...}`
</script>

<p>
one: {data.one}
</p>
<p>
two: {data.two}
</p>
<p>
three:
{#await data.streamed.three}
...
Rich-Harris marked this conversation as resolved.
Show resolved Hide resolved
{:then value}
{value}
{:catch error}
{error.message}
{/await}
</p>
```

On platforms that do not support streaming, such as AWS Lambda, responses will be buffered.
Rich-Harris marked this conversation as resolved.
Show resolved Hide resolved

> 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.
Rich-Harris marked this conversation as resolved.
Show resolved Hide resolved

## 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.
Expand Down Expand Up @@ -502,6 +523,8 @@ export async function load() {

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

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

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.
Expand Down
2 changes: 1 addition & 1 deletion packages/kit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.29.0",
Expand Down
131 changes: 94 additions & 37 deletions packages/kit/src/runtime/client/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,11 @@ function update_scroll_positions(index) {
}

/**
* @param {{
* app: import('./types').SvelteKitApp;
* target: HTMLElement;
* }} opts
* @param {import('./types').SvelteKitApp} app
* @param {HTMLElement} target
* @returns {import('./types').Client}
*/
export function create_client({ app, target }) {
export function create_client(app, target) {
const routes = parse(app);

const default_layout_loader = app.nodes[0];
Expand Down Expand Up @@ -735,22 +733,8 @@ export function create_client({ app, target }) {
* @returns {import('./types').DataNode | null}
*/
function create_data_node(node, previous) {
if (node?.type === 'data') {
return {
type: 'data',
data: node.data,
uses: {
dependencies: new Set(node.uses.dependencies ?? []),
params: new Set(node.uses.params ?? []),
parent: !!node.uses.parent,
route: !!node.uses.route,
url: !!node.uses.url
},
slash: node.slash
};
} else if (node?.type === 'skip') {
return previous ?? null;
}
if (node?.type === 'data') return node;
if (node?.type === 'skip') return previous ?? null;
return null;
}

Expand All @@ -773,7 +757,7 @@ export function create_client({ app, 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;
Expand Down Expand Up @@ -1724,6 +1708,10 @@ export function create_client({ app, 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: app.nodes[n],
Expand Down Expand Up @@ -1774,7 +1762,7 @@ export function create_client({ app, target }) {
/**
* @param {URL} url
* @param {boolean[]} invalid
* @returns {Promise<import('types').ServerData>}
* @returns {Promise<import('types').ServerNodesResponse |import('types').ServerRedirectNode>}
*/
async function load_data(url, invalid) {
const data_url = new URL(url);
Expand All @@ -1788,29 +1776,98 @@ 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) => {
/**
* Map of deferred promises that will be resolved by a subsequent chunk of data
* @type {Map<string, import('types').Deferred>}
*/
const deferreds = new Map();
const reader = /** @type {ReadableStream<Uint8Array>} */ (res.body).getReader();
const decoder = new TextDecoder();

/**
* @param {any} data
*/
function deserialize(data) {
return devalue.unflatten(data, {
Promise: (id) => {
return new Promise((fulfil, reject) => {
deferreds.set(id, { fulfil, reject });
});
}
});
}

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) 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 pending promises) chunk
node.nodes?.forEach((/** @type {any} */ node) => {
if (node?.type === 'data') {
node.uses = deserialize_uses(node.uses);
node.data = deserialize(node.data);
}
});

resolve(node);
} else if (node.type === 'chunk') {
// This is a subsequent chunk containing deferred data
const { id, data, error } = node;
const deferred = /** @type {import('types').Deferred} */ (deferreds.get(id));
deferreds.delete(id);

if (error) {
deferred.reject(deserialize(error));
} else {
deferred.fulfil(deserialize(data));
}
}
}
}
});

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
};
}

function reset_focus() {
Expand Down
11 changes: 3 additions & 8 deletions packages/kit/src/runtime/client/start.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,17 @@ import { init } from './singletons.js';

/**
* @param {import('./types').SvelteKitApp} app
* @param {string} hash
* @param {HTMLElement} target
* @param {Parameters<import('./types').Client['_hydrate']>[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 <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>`
);
}

const client = create_client({ app, target });
const client = create_client(app, target);

init({ client });

Expand Down
Loading