diff --git a/.changeset/fast-eyes-deny.md b/.changeset/fast-eyes-deny.md new file mode 100644 index 000000000000..bfc7e2051d0e --- /dev/null +++ b/.changeset/fast-eyes-deny.md @@ -0,0 +1,5 @@ +--- +"@sveltejs/kit": minor +--- + +feat: add untrack to load diff --git a/documentation/docs/20-core-concepts/20-load.md b/documentation/docs/20-core-concepts/20-load.md index 4bd6b3f3cd90..7448cf521c5f 100644 --- a/documentation/docs/20-core-concepts/20-load.md +++ b/documentation/docs/20-core-concepts/20-load.md @@ -172,7 +172,7 @@ A `load` function is invoked at runtime, unless you [prerender](page-options#pre ### Input -Both universal and server `load` functions have access to properties describing the request (`params`, `route` and `url`) and various functions (`fetch`, `setHeaders`, `parent` and `depends`). These are described in the following sections. +Both universal and server `load` functions have access to properties describing the request (`params`, `route` and `url`) and various functions (`fetch`, `setHeaders`, `parent`, `depends` and `untrack`). These are described in the following sections. Server `load` functions are called with a `ServerLoadEvent`, which inherits `clientAddress`, `cookies`, `locals`, `platform` and `request` from `RequestEvent`. @@ -574,6 +574,21 @@ Dependency tracking does not apply _after_ the `load` function has returned — Search parameters are tracked independently from the rest of the url. For example, accessing `event.url.searchParams.get("x")` inside a `load` function will make that `load` function re-run when navigating from `?x=1` to `?x=2`, but not when navigating from `?x=1&y=1` to `?x=1&y=2`. +### Untracking dependencies + +In rare cases, you may wish to exclude something from the dependency tracking mechanism. You can do this with the provided `untrack` function: + +```js +/// file: src/routes/+page.js +/** @type {import('./$types').PageLoad} */ +export async function load({ untrack, url }) { + // Untrack url.pathname so that path changes don't trigger a rerun + if (untrack(() => url.pathname === '/')) { + return { message: 'Welcome!' }; + } +} +``` + ### Manual invalidation You can also rerun `load` functions that apply to the current page using [`invalidate(url)`](modules#$app-navigation-invalidate), which reruns all `load` functions that depend on `url`, and [`invalidateAll()`](modules#$app-navigation-invalidateall), which reruns every `load` function. Server load functions will never automatically depend on a fetched `url` to avoid leaking secrets to the client. diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index 36f8a6cc9d39..7caa0c4bf038 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -791,6 +791,20 @@ export interface LoadEvent< * ``` */ depends(...deps: Array<`${string}:${string}`>): void; + /** + * Use this function to opt out of dependency tracking for everything that is synchronously called within the callback. Example: + * + * ```js + * /// file: src/routes/+page.server.js + * export async function load({ untrack, url }) { + * // Untrack url.pathname so that path changes don't trigger a rerun + * if (untrack(() => url.pathname === '/')) { + * return { message: 'Welcome!' }; + * } + * } + * ``` + */ + untrack(fn: () => T): T; } export interface NavigationEvent< @@ -1196,6 +1210,20 @@ export interface ServerLoadEvent< * ``` */ depends(...deps: string[]): void; + /** + * Use this function to opt out of dependency tracking for everything that is synchronously called within the callback. Example: + * + * ```js + * /// file: src/routes/+page.js + * export async function load({ untrack, url }) { + * // Untrack url.pathname so that path changes don't trigger a rerun + * if (untrack(() => url.pathname === '/')) { + * return { message: 'Welcome!' }; + * } + * } + * ``` + */ + untrack(fn: () => T): T; } /** diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index c778c18d7932..6db0ec702efc 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -501,6 +501,8 @@ export function create_client(app, target) { /** @type {Record | null} */ let data = null; + let is_tracking = true; + /** @type {import('types').Uses} */ const uses = { dependencies: new Set(), @@ -532,21 +534,33 @@ export function create_client(app, target) { const load_input = { route: new Proxy(route, { get: (target, key) => { - uses.route = true; + if (is_tracking) { + uses.route = true; + } return target[/** @type {'id'} */ (key)]; } }), params: new Proxy(params, { get: (target, key) => { - uses.params.add(/** @type {string} */ (key)); + if (is_tracking) { + uses.params.add(/** @type {string} */ (key)); + } return target[/** @type {string} */ (key)]; } }), data: server_data_node?.data ?? null, url: make_trackable( url, - () => (uses.url = true), - (param) => uses.search_params.add(param) + () => { + if (is_tracking) { + uses.url = true; + } + }, + (param) => { + if (is_tracking) { + uses.search_params.add(param); + } + } ), async fetch(resource, init) { /** @type {URL | string} */ @@ -583,7 +597,9 @@ export function create_client(app, target) { // we must fixup relative urls so they are resolved from the target page const resolved = new URL(requested, url); - depends(resolved.href); + if (is_tracking) { + depends(resolved.href); + } // match ssr serialized data url, which is important to find cached responses if (resolved.origin === url.origin) { @@ -598,8 +614,18 @@ export function create_client(app, target) { setHeaders: () => {}, // noop depends, parent() { - uses.parent = true; + if (is_tracking) { + uses.parent = true; + } return parent(); + }, + untrack(fn) { + is_tracking = false; + try { + return fn(); + } finally { + is_tracking = true; + } } }; diff --git a/packages/kit/src/runtime/server/page/load_data.js b/packages/kit/src/runtime/server/page/load_data.js index 1b02c9fc51e5..60f7d6570436 100644 --- a/packages/kit/src/runtime/server/page/load_data.js +++ b/packages/kit/src/runtime/server/page/load_data.js @@ -16,6 +16,7 @@ export async function load_server_data({ event, state, node, parent }) { if (!node?.server) return null; let done = false; + let is_tracking = true; const uses = { dependencies: new Set(), @@ -35,7 +36,9 @@ export async function load_server_data({ event, state, node, parent }) { ); } - uses.url = true; + if (is_tracking) { + uses.url = true; + } }, (param) => { if (DEV && done && !uses.search_params.has(param)) { @@ -44,7 +47,9 @@ export async function load_server_data({ event, state, node, parent }) { ); } - uses.search_params.add(param); + if (is_tracking) { + uses.search_params.add(param); + } } ); @@ -63,6 +68,7 @@ export async function load_server_data({ event, state, node, parent }) { ); } + // Note: server fetches are not added to uses.depends due to security concerns return event.fetch(info, init); }, /** @param {string[]} deps */ @@ -93,7 +99,9 @@ export async function load_server_data({ event, state, node, parent }) { ); } - uses.params.add(key); + if (is_tracking) { + uses.params.add(key); + } return target[/** @type {string} */ (key)]; } }), @@ -104,7 +112,9 @@ export async function load_server_data({ event, state, node, parent }) { ); } - uses.parent = true; + if (is_tracking) { + uses.parent = true; + } return parent(); }, route: new Proxy(event.route, { @@ -117,11 +127,21 @@ export async function load_server_data({ event, state, node, parent }) { ); } - uses.route = true; + if (is_tracking) { + uses.route = true; + } return target[/** @type {'id'} */ (key)]; } }), - url + url, + untrack(fn) { + is_tracking = false; + try { + return fn(); + } finally { + is_tracking = true; + } + } }); if (__SVELTEKIT_DEV__) { @@ -176,7 +196,8 @@ export async function load_data({ fetch: create_universal_fetch(event, state, fetched, csr, resolve_opts), setHeaders: event.setHeaders, depends: () => {}, - parent + parent, + untrack: (fn) => fn() }); if (__SVELTEKIT_DEV__) { diff --git a/packages/kit/test/apps/basics/src/routes/untrack/server/+layout.server.js b/packages/kit/test/apps/basics/src/routes/untrack/server/+layout.server.js new file mode 100644 index 000000000000..96084b5f4c27 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/untrack/server/+layout.server.js @@ -0,0 +1,5 @@ +export function load({ url }) { + return { + url: url.pathname + }; +} diff --git a/packages/kit/test/apps/basics/src/routes/untrack/server/[x]/+page.server.js b/packages/kit/test/apps/basics/src/routes/untrack/server/[x]/+page.server.js new file mode 100644 index 000000000000..b4ffba554340 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/untrack/server/[x]/+page.server.js @@ -0,0 +1,12 @@ +export function load({ params, parent, url, untrack }) { + untrack(() => { + params.x; + parent(); + url.pathname; + url.search; + }); + + return { + id: Math.random() + }; +} diff --git a/packages/kit/test/apps/basics/src/routes/untrack/server/[x]/+page.svelte b/packages/kit/test/apps/basics/src/routes/untrack/server/[x]/+page.svelte new file mode 100644 index 000000000000..b14eaf830616 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/untrack/server/[x]/+page.svelte @@ -0,0 +1,7 @@ + + +

{data.url}

+

{data.id}

+2 diff --git a/packages/kit/test/apps/basics/src/routes/untrack/universal/+layout.js b/packages/kit/test/apps/basics/src/routes/untrack/universal/+layout.js new file mode 100644 index 000000000000..96084b5f4c27 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/untrack/universal/+layout.js @@ -0,0 +1,5 @@ +export function load({ url }) { + return { + url: url.pathname + }; +} diff --git a/packages/kit/test/apps/basics/src/routes/untrack/universal/[x]/+page.js b/packages/kit/test/apps/basics/src/routes/untrack/universal/[x]/+page.js new file mode 100644 index 000000000000..b4ffba554340 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/untrack/universal/[x]/+page.js @@ -0,0 +1,12 @@ +export function load({ params, parent, url, untrack }) { + untrack(() => { + params.x; + parent(); + url.pathname; + url.search; + }); + + return { + id: Math.random() + }; +} diff --git a/packages/kit/test/apps/basics/src/routes/untrack/universal/[x]/+page.svelte b/packages/kit/test/apps/basics/src/routes/untrack/universal/[x]/+page.svelte new file mode 100644 index 000000000000..f91394c6160b --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/untrack/universal/[x]/+page.svelte @@ -0,0 +1,7 @@ + + +

{data.url}

+

{data.id}

+2 diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js index 7ebc90b310ae..fdf76661f67e 100644 --- a/packages/kit/test/apps/basics/test/client.test.js +++ b/packages/kit/test/apps/basics/test/client.test.js @@ -914,6 +914,26 @@ test.describe('goto', () => { }); }); +test.describe('untrack', () => { + test('untracks server load function', async ({ page }) => { + await page.goto('/untrack/server/1'); + expect(await page.textContent('p.url')).toBe('/untrack/server/1'); + const id = await page.textContent('p.id'); + await page.click('a[href="/untrack/server/2"]'); + expect(await page.textContent('p.url')).toBe('/untrack/server/2'); + expect(await page.textContent('p.id')).toBe(id); + }); + + test('untracks universal load function', async ({ page }) => { + await page.goto('/untrack/universal/1'); + expect(await page.textContent('p.url')).toBe('/untrack/universal/1'); + const id = await page.textContent('p.id'); + await page.click('a[href="/untrack/universal/2"]'); + expect(await page.textContent('p.url')).toBe('/untrack/universal/2'); + expect(await page.textContent('p.id')).toBe(id); + }); +}); + test.describe('Shallow routing', () => { test('Pushes state to the current URL', async ({ page }) => { await page.goto('/shallow-routing/push-state'); diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 07840d091f5c..0e2b7713afbd 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -773,6 +773,20 @@ declare module '@sveltejs/kit' { * ``` */ depends(...deps: Array<`${string}:${string}`>): void; + /** + * Use this function to opt out of dependency tracking for everything that is synchronously called within the callback. Example: + * + * ```js + * /// file: src/routes/+page.server.js + * export async function load({ untrack, url }) { + * // Untrack url.pathname so that path changes don't trigger a rerun + * if (untrack(() => url.pathname === '/')) { + * return { message: 'Welcome!' }; + * } + * } + * ``` + */ + untrack(fn: () => T): T; } export interface NavigationEvent< @@ -1178,6 +1192,20 @@ declare module '@sveltejs/kit' { * ``` */ depends(...deps: string[]): void; + /** + * Use this function to opt out of dependency tracking for everything that is synchronously called within the callback. Example: + * + * ```js + * /// file: src/routes/+page.js + * export async function load({ untrack, url }) { + * // Untrack url.pathname so that path changes don't trigger a rerun + * if (untrack(() => url.pathname === '/')) { + * return { message: 'Welcome!' }; + * } + * } + * ``` + */ + untrack(fn: () => T): T; } /**