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

[fix] do not call server and update page.data unnecessarily #6311

Merged
merged 9 commits into from Aug 27, 2022
5 changes: 5 additions & 0 deletions .changeset/tasty-geese-hug.md
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': patch
---

[fix] do not call server and update `$page.data` unnecessarily
31 changes: 18 additions & 13 deletions packages/kit/src/core/sync/write_client_manifest.js
Expand Up @@ -48,23 +48,17 @@ export function write_client_manifest(manifest_data, output) {
.map((route) => {
if (route.page) {
const errors = route.page.errors.slice(1).map((n) => n ?? '');
const layouts = route.page.layouts.slice(1).map((n) => n ?? '');
const layouts = route.page.layouts.slice(1).map((n) => {
if (n == undefined) {
return '';
}
return get_node_id(manifest_data.nodes, n);
});

while (layouts.at(-1) === '') layouts.pop();
while (errors.at(-1) === '') errors.pop();

/** @type {import('types').PageNode | null} */
let current_node = route.leaf;

let uses_server_data = false;
while (current_node && !uses_server_data) {
uses_server_data = !!current_node?.server;
current_node = current_node?.parent ?? null;
}

// encode whether or not the route uses the server data
// using the ones' complement, to save space
const array = [`${uses_server_data ? '~' : ''}${route.page.leaf}`];
const array = [get_node_id(manifest_data.nodes, route.page.leaf)];

// only include non-root layout/error nodes if they exist
if (layouts.length > 0 || errors.length > 0) array.push(`[${layouts.join(',')}]`);
Expand All @@ -77,6 +71,7 @@ export function write_client_manifest(manifest_data, output) {
.join(',\n\t\t')}
}`.replace(/^\t/gm, '');

// String representation of __GENERATED__/client-manifest.js
write_if_changed(
`${output}/client-manifest.js`,
trim(`
Expand All @@ -90,3 +85,13 @@ export function write_client_manifest(manifest_data, output) {
`)
);
}

/**
* Encode whether or not the route uses the server data
* using the ones' complement, to save space
* @param {import('types').PageNode[]} nodes
* @param {number} id
*/
function get_node_id(nodes, id) {
return `${nodes[id].server ? '~' : ''}${id}`;
}
6 changes: 3 additions & 3 deletions packages/kit/src/runtime/client/ambient.d.ts
Expand Up @@ -8,9 +8,9 @@ declare module '__GENERATED__/client-manifest.js' {

/**
* A map of `[routeId: string]: [leaf, layouts, errors]` tuples, which
* is parsed into an array of routes on startup. The numbers refer to the
* indices in `nodes`. The route layout and error nodes are not referenced,
* they are always number 0 and 1 and always apply.
* is parsed into an array of routes on startup. The numbers refer to the indices in `nodes`.
* If the number is negative, it means it does use a server load function and the complement is the node index.
* The route layout and error nodes are not referenced, they are always number 0 and 1 and always apply.
*/
export const dictionary: Record<string, [leaf: number, layouts?: number[], errors?: number[]]>;

Expand Down
52 changes: 33 additions & 19 deletions packages/kit/src/runtime/client/client.js
Expand Up @@ -424,21 +424,37 @@ export function create_client({ target, base, trailing_slash }) {
};

let data = {};
let data_changed = false;
let data_changed = !page;
for (let i = 0; i < filtered.length; i += 1) {
data = { ...data, ...filtered[i].data };
const node = filtered[i];
data = { ...data, ...node.data };

// Only set props if the node actually updated. This prevents needless rerenders.
if (data_changed || !current.branch.some((node) => node === filtered[i])) {
if (data_changed || !current.branch.some((previous) => previous === node)) {
result.props[`data_${i}`] = data;
data_changed = true;
data_changed = data_changed || Object.keys(node.data ?? {}).length > 0;
}
}
if (!data_changed) {
// If nothing was added, and the object entries are the same length, this means
// that nothing was removed either and therefore the data is the same as the previous one.
// This would be more readable with a separate boolean but that would cost us some bytes.
data_changed = Object.keys(page.data).length !== Object.keys(data).length;
}

const page_changed =
!current.url || url.href !== current.url.href || current.error !== error || data_changed;

if (page_changed) {
result.props.page = { error, params, routeId, status, url, data };
result.props.page = {
error,
params,
routeId,
status,
url,
// The whole page store is updated, but this way the object reference stays the same
data: data_changed ? data : page.data
};

// TODO remove this for 1.0
/**
Expand Down Expand Up @@ -675,30 +691,29 @@ export function create_client({ target, base, trailing_slash }) {
params: Object.keys(params).filter((key) => current.params[key] !== params[key])
};

const loaders = [...layouts, leaf];

// preload modules to avoid waterfall, but handle rejections
// so they don't get reported to Sentry et al (we don't need
// to act on the failures at this point)
[...errors, ...layouts, leaf].forEach((loader) => loader?.().catch(() => {}));

const loaders = [...layouts, leaf];

// To avoid waterfalls when someone awaits a parent, compute as much as possible here already
errors.forEach((loader) => loader?.().catch(() => {}));
loaders.forEach((loader) => loader?.[1]().catch(() => {}));

/** @type {import('types').ServerData | null} */
let server_data = null;

const invalid_server_nodes = loaders.reduce((acc, loader, i) => {
const previous = current.branch[i];
const invalid =
loader &&
(previous?.loader !== loader ||
!!loader?.[0] &&
(previous?.loader !== loader[1] ||
has_changed(changed, acc.some(Boolean), previous.server?.uses));

acc.push(invalid);
return acc;
}, /** @type {boolean[]} */ ([]));

if (route.uses_server_data && invalid_server_nodes.some(Boolean)) {
if (invalid_server_nodes.some(Boolean)) {
try {
const res = await native_fetch(
`${url.pathname}${url.pathname.endsWith('/') ? '' : '/'}__data.json${url.search}`,
Expand Down Expand Up @@ -741,7 +756,7 @@ export function create_client({ target, base, trailing_slash }) {
// re-use data from previous load if it's still valid
const valid =
can_reuse_server_data &&
loader === previous?.loader &&
loader[1] === previous?.loader &&
!has_changed(changed, parent_changed, previous.shared?.uses);
if (valid) return previous;

Expand All @@ -757,7 +772,7 @@ export function create_client({ target, base, trailing_slash }) {
}

return load_node({
loader,
loader: loader[1],
url,
params,
routeId: route.id,
Expand Down Expand Up @@ -801,11 +816,10 @@ export function create_client({ target, base, trailing_slash }) {

let j = i;
while (!branch[j]) j -= 1;

try {
error_loaded = {
node: await errors[i](),
loader: errors[i],
node: await /** @type {import('types').CSRPageNodeLoader } */ (errors[i])(),
loader: /** @type {import('types').CSRPageNodeLoader } */ (errors[i]),
data: {},
server: null,
shared: null
Expand Down Expand Up @@ -1087,7 +1101,7 @@ export function create_client({ target, base, trailing_slash }) {
: routes;

const promises = matching.map((r) => {
return Promise.all([...r.layouts, r.leaf].map((load) => load?.()));
return Promise.all([...r.layouts, r.leaf].map((load) => load?.[1]()));
});

await Promise.all(promises);
Expand Down
22 changes: 14 additions & 8 deletions packages/kit/src/runtime/client/parse.js
Expand Up @@ -10,11 +10,6 @@ export function parse(nodes, dictionary, matchers) {
return Object.entries(dictionary).map(([id, [leaf, layouts, errors]]) => {
const { pattern, names, types } = parse_route_id(id);

// whether or not the route uses the server data is
// encoded using the ones' complement, to save space
const uses_server_data = leaf < 0;
if (uses_server_data) leaf = ~leaf;

const route = {
id,
/** @param {string} path */
Expand All @@ -23,9 +18,8 @@ export function parse(nodes, dictionary, matchers) {
if (match) return exec(match, names, types, matchers);
},
errors: [1, ...(errors || [])].map((n) => nodes[n]),
layouts: [0, ...(layouts || [])].map((n) => nodes[n]),
leaf: nodes[leaf],
uses_server_data
layouts: [0, ...(layouts || [])].map(create_loader),
leaf: create_loader(leaf)
};

// bit of a hack, but ensures that layout/error node lists are the same
Expand All @@ -38,4 +32,16 @@ export function parse(nodes, dictionary, matchers) {

return route;
});

/**
* @param {number} id
* @returns {[boolean, import('types').CSRPageNodeLoader]}
*/
function create_loader(id) {
// whether or not the route uses the server data is
// encoded using the ones' complement, to save space
const uses_server_data = id < 0;
if (uses_server_data) id = ~id;
return [uses_server_data, nodes[id]];
}
}
10 changes: 2 additions & 8 deletions packages/kit/test/apps/basics/src/routes/+layout.js
@@ -1,12 +1,6 @@
import { error } from '@sveltejs/kit';

/** @type {import('@sveltejs/kit').Load} */
export async function load({ fetch, url }) {
if (url.pathname.startsWith('/errors/error-in-layout')) {
const res = await fetch('/errors/error-in-layout/non-existent');
throw error(res.status);
}

export async function load() {
// Do NOT make this load function depend on something which would cause it to rerun
return {
foo: {
bar: 'Custom layout'
Expand Down
1 change: 1 addition & 0 deletions packages/kit/test/apps/basics/src/routes/+layout.server.js
@@ -1,4 +1,5 @@
export async function load() {
// Do NOT make this load function depend on something which would cause it to rerun
return {
rootlayout: 'rootlayout'
};
Expand Down
9 changes: 9 additions & 0 deletions packages/kit/test/apps/basics/src/routes/errors/+layout.js
@@ -0,0 +1,9 @@
import { error } from '@sveltejs/kit';

/** @type {import('@sveltejs/kit').Load} */
export async function load({ fetch, url }) {
if (url.pathname.startsWith('/errors/error-in-layout')) {
const res = await fetch('/errors/error-in-layout/non-existent');
throw error(res.status);
}
}
@@ -0,0 +1 @@
<slot />;
@@ -0,0 +1,14 @@
<script>
import { page } from '$app/stores';
let previous = $page.data;
let count = 0;
$: {
if (previous !== $page.data) {
count++;
}
}
</script>

<p>$page.data was updated {count} time(s)</p>
<a href="/store/data/unchanged/a">a</a>
<a href="/store/data/unchanged/b">b</a>
@@ -0,0 +1 @@
Page A
@@ -0,0 +1 @@
Page B
18 changes: 13 additions & 5 deletions packages/kit/test/apps/basics/test/client.test.js
Expand Up @@ -598,11 +598,19 @@ test('Can use browser-only global on client-only page', async ({ page, read_erro
expect(read_errors('/no-ssr/browser-only-global')).toBe(undefined);
});

test('can use $app/stores from anywhere on client', async ({ page }) => {
await page.goto('/store/client-access');
await expect(page.locator('h1')).toHaveText('undefined');
await page.click('button');
await expect(page.locator('h1')).toHaveText('/store/client-access');
test.describe('$app/stores', () => {
test('can use $app/stores from anywhere on client', async ({ page }) => {
await page.goto('/store/client-access');
await expect(page.locator('h1')).toHaveText('undefined');
await page.click('button');
await expect(page.locator('h1')).toHaveText('/store/client-access');
});

test('$page.data does not update if data is unchanged', async ({ page, app }) => {
await page.goto('/store/data/unchanged/a');
await app.goto('/store/data/unchanged/b');
await expect(page.locator('p')).toHaveText('$page.data was updated 0 time(s)');
});
});

test.describe.serial('Invalidation', () => {
Expand Down
11 changes: 7 additions & 4 deletions packages/kit/types/internal.d.ts
Expand Up @@ -71,13 +71,16 @@ export interface CSRPageNode {

export type CSRPageNodeLoader = () => Promise<CSRPageNode>;

/**
* Definition of a client side route.
* The boolean in the tuples indicates whether the route has a server load.
*/
export type CSRRoute = {
id: string;
exec: (path: string) => undefined | Record<string, string>;
errors: CSRPageNodeLoader[];
layouts: CSRPageNodeLoader[];
leaf: CSRPageNodeLoader;
uses_server_data: boolean;
errors: Array<CSRPageNodeLoader | undefined>;
layouts: Array<[boolean, CSRPageNodeLoader] | undefined>;
leaf: [boolean, CSRPageNodeLoader];
};

export type GetParams = (match: RegExpExecArray) => Record<string, string>;
Expand Down