diff --git a/.changeset/strange-eyes-sort.md b/.changeset/strange-eyes-sort.md new file mode 100644 index 000000000000..70de048ab975 --- /dev/null +++ b/.changeset/strange-eyes-sort.md @@ -0,0 +1,5 @@ +--- +"@sveltejs/kit": minor +--- + +feat: allow for fine grained invalidation of search params diff --git a/documentation/docs/20-core-concepts/20-load.md b/documentation/docs/20-core-concepts/20-load.md index d582e9987904..b8f105fa5a2b 100644 --- a/documentation/docs/20-core-concepts/20-load.md +++ b/documentation/docs/20-core-concepts/20-load.md @@ -548,6 +548,8 @@ A `load` function that calls `await parent()` will also rerun if a parent `load` 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 rerun 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. +Accessing a query parameter is tracked independently from the rest of the url. For example, accessing `event.url.searchParams.get("query")` inside a `load` function will make that `load` function rerun only when the `query` search param changes: Navigating from `/search?query=svelte&page=1` to `/search?query=svelte&page=2` will not rerun the load function. + ### 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. @@ -597,6 +599,7 @@ To summarize, a `load` function will rerun in the following situations: - It references a property of `params` whose value has changed - It references a property of `url` (such as `url.pathname` or `url.search`) whose value has changed. Properties in `request.url` are _not_ tracked +- It calls `url.searchParams.get`, `url.searchParams.getAll` or `url.searchParams.has` and the specific search param passed to those functions changes. Accessing other properties of searchParams will have the same effect as accessing `url.search`. - It calls `await parent()` and a parent `load` function reran - It declared a dependency on a specific URL via [`fetch`](#making-fetch-requests) (universal load only) or [`depends`](types#public-types-loadevent), and that URL was marked invalid with [`invalidate(url)`](modules#$app-navigation-invalidate) - All active `load` functions were forcibly rerun with [`invalidateAll()`](modules#$app-navigation-invalidateall) diff --git a/packages/kit/src/core/sync/write_server.js b/packages/kit/src/core/sync/write_server.js index f3d29dfbb20b..7283b05ef38b 100644 --- a/packages/kit/src/core/sync/write_server.js +++ b/packages/kit/src/core/sync/write_server.js @@ -24,7 +24,8 @@ const server_template = ({ runtime_directory, template, error_page -}) => ` +}) => + ` import root from '../root.${isSvelte5Plus() ? 'js' : 'svelte'}'; import { set_building } from '__sveltekit/environment'; import { set_assets } from '__sveltekit/paths'; diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 08465d6b5342..584144dfcf91 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -443,7 +443,8 @@ export function create_client(app, target) { params: new Set(), parent: false, route: false, - url: false + url: false, + search_params: new Set() }; const node = await loader(); @@ -478,9 +479,15 @@ export function create_client(app, target) { } }), data: server_data_node?.data ?? null, - url: make_trackable(url, () => { - uses.url = true; - }), + url: make_trackable( + url, + () => { + uses.url = true; + }, + (search_param) => { + uses.search_params.add(search_param); + } + ), async fetch(resource, init) { /** @type {URL | string} */ let requested; @@ -576,10 +583,18 @@ export function create_client(app, target) { * @param {boolean} parent_changed * @param {boolean} route_changed * @param {boolean} url_changed + * @param {Set} search_params_changed * @param {import('types').Uses | undefined} uses * @param {Record} params */ - function has_changed(parent_changed, route_changed, url_changed, uses, params) { + function has_changed( + parent_changed, + route_changed, + url_changed, + search_params_changed, + uses, + params + ) { if (force_invalidation) return true; if (!uses) return false; @@ -588,6 +603,10 @@ export function create_client(app, target) { if (uses.route && route_changed) return true; if (uses.url && url_changed) return true; + for (const tracked_params of uses.search_params) { + if (search_params_changed.has(tracked_params)) return true; + } + for (const param of uses.params) { if (params[param] !== current.params[param]) return true; } @@ -610,6 +629,35 @@ export function create_client(app, target) { return null; } + /** + * + * @param {URL} [old_url] + * @param {URL} [new_url] + */ + function check_search_params_changed(old_url, new_url) { + const changed = new Set(); + const new_search_params = new URLSearchParams(new_url?.searchParams); + const old_search_params = old_url?.searchParams; + for (const key of new Set(old_search_params?.keys?.() ?? [])) { + const new_get_all = new_search_params.getAll(key); + const old_get_all = old_search_params?.getAll?.(key) ?? []; + if ( + // check if the two arrays contains the same values + !( + new_get_all.every((query_param) => old_get_all.includes(query_param)) && + old_get_all.every((query_param) => new_get_all.includes(query_param)) + ) + ) { + changed.add(key); + } + new_search_params.delete(key); + } + for (const [key] of new_search_params) { + changed.add(key); + } + return changed; + } + /** * @param {import('./types.js').NavigationIntent} intent * @returns {Promise} @@ -631,9 +679,9 @@ export function create_client(app, target) { /** @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; const route_changed = current.route ? route.id !== current.route.id : false; + const search_params_changed = check_search_params_changed(current.url, url); let parent_invalid = false; const invalid_server_nodes = loaders.map((loader, i) => { @@ -642,7 +690,14 @@ export function create_client(app, target) { const invalid = !!loader?.[0] && (previous?.loader !== loader[1] || - has_changed(parent_invalid, route_changed, url_changed, previous.server?.uses, params)); + has_changed( + parent_invalid, + route_changed, + url_changed, + search_params_changed, + previous.server?.uses, + params + )); if (invalid) { // For the next one @@ -685,7 +740,14 @@ export function create_client(app, target) { const valid = (!server_data_node || server_data_node.type === 'skip') && loader[1] === previous?.loader && - !has_changed(parent_changed, route_changed, url_changed, previous.universal?.uses, params); + !has_changed( + parent_changed, + route_changed, + url_changed, + search_params_changed, + previous.universal?.uses, + params + ); if (valid) return previous; parent_changed = true; @@ -1954,7 +2016,8 @@ function deserialize_uses(uses) { params: new Set(uses?.params ?? []), parent: !!uses?.parent, route: !!uses?.route, - url: !!uses?.url + url: !!uses?.url, + search_params: new Set(uses?.search_params ?? []) }; } diff --git a/packages/kit/src/runtime/server/page/load_data.js b/packages/kit/src/runtime/server/page/load_data.js index e24dad5b3bed..f625aa4858ce 100644 --- a/packages/kit/src/runtime/server/page/load_data.js +++ b/packages/kit/src/runtime/server/page/load_data.js @@ -31,18 +31,30 @@ export async function load_server_data({ params: new Set(), parent: false, route: false, - url: false + url: false, + search_params: new Set() }; - 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` - ); - } + 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; - }); + uses.url = true; + }, + (search_params) => { + if (DEV && done && uses.search_params.size === 0) { + 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.search_params.add(search_params); + } + ); if (state.prerendering) { disable_search(url); diff --git a/packages/kit/src/runtime/server/utils.js b/packages/kit/src/runtime/server/utils.js index 6e5a4ee1b6ad..55a98c4152cc 100644 --- a/packages/kit/src/runtime/server/utils.js +++ b/packages/kit/src/runtime/server/utils.js @@ -149,6 +149,10 @@ export function stringify_uses(node) { uses.push(`"dependencies":${JSON.stringify(Array.from(node.uses.dependencies))}`); } + if (node.uses && node.uses.search_params.size > 0) { + uses.push(`"search_params":${JSON.stringify(Array.from(node.uses.search_params))}`); + } + if (node.uses && node.uses.params.size > 0) { uses.push(`"params":${JSON.stringify(Array.from(node.uses.params))}`); } diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index ea3023245503..f5a4f8df0483 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -408,6 +408,7 @@ export interface Uses { parent: boolean; route: boolean; url: boolean; + search_params: Set; } export type ValidatedConfig = RecursiveRequired; diff --git a/packages/kit/src/utils/url.js b/packages/kit/src/utils/url.js index 4fb61b9baaac..f5b6f9439457 100644 --- a/packages/kit/src/utils/url.js +++ b/packages/kit/src/utils/url.js @@ -103,17 +103,47 @@ const tracked_url_properties = /** @type {const} */ ([ 'href', 'pathname', 'search', - 'searchParams', 'toString', 'toJSON' ]); +const tracked_search_params_properties = /** @type {const} */ ['get', 'getAll', 'has']; + +/** + * @param {URLSearchParams} search_params + * @param {() => void} callback + * @param {(search_param: string) => void} search_params_callback + */ +function tracked_search_params(search_params, callback, search_params_callback) { + return new Proxy(search_params, { + get(obj, key) { + if (typeof key === 'string' && tracked_search_params_properties.includes(key)) { + return (/**@type {string}*/ search_param) => { + search_params_callback(search_param); + // @ts-expect-error + return search_params[key].bind(search_params)(search_param); + }; + } + // if they try to access something different from what is in `tracked_search_params_properties` + // we track the whole url (entries, values, keys etc) + callback(); + let retval = Reflect.get(obj, key); + if (retval instanceof Function) { + retval = retval.bind(search_params); + } + return retval; + } + }); +} + /** * @param {URL} url * @param {() => void} callback + * @param {(search_param: string) => void} search_params_callback */ -export function make_trackable(url, callback) { +export function make_trackable(url, callback, search_params_callback) { const tracked = new URL(url); + const search_params_to_track = new URLSearchParams(tracked.searchParams); for (const property of tracked_url_properties) { Object.defineProperty(tracked, property, { @@ -127,6 +157,14 @@ export function make_trackable(url, callback) { }); } + Object.defineProperty(tracked, 'searchParams', { + get() { + return tracked_search_params(search_params_to_track, callback, search_params_callback); + }, + enumerable: true, + configurable: true + }); + if (!BROWSER) { // @ts-ignore tracked[Symbol.for('nodejs.util.inspect.custom')] = (depth, opts, inspect) => { diff --git a/packages/kit/src/utils/url.spec.js b/packages/kit/src/utils/url.spec.js index 166448227f2c..1436e9850c12 100644 --- a/packages/kit/src/utils/url.spec.js +++ b/packages/kit/src/utils/url.spec.js @@ -93,10 +93,13 @@ describe('normalize_path', (test) => { describe('make_trackable', (test) => { test('makes URL properties trackable', () => { let tracked = false; - - const url = make_trackable(new URL('https://kit.svelte.dev/docs'), () => { - tracked = true; - }); + const url = make_trackable( + new URL('https://kit.svelte.dev/docs'), + () => { + tracked = true; + }, + () => {} + ); url.origin; assert.isNotOk(tracked); @@ -106,13 +109,46 @@ describe('make_trackable', (test) => { }); test('throws an error when its hash property is accessed', () => { - const url = make_trackable(new URL('https://kit.svelte.dev/docs'), () => {}); + const url = make_trackable( + new URL('https://kit.svelte.dev/docs'), + () => {}, + () => {} + ); assert.throws( () => url.hash, /Cannot access event.url.hash. Consider using `\$page.url.hash` inside a component instead/ ); }); + + test('track each search param separately if accessed directly', () => { + let tracked = false; + const tracked_search_params = new Set(); + const url = make_trackable( + new URL('https://kit.svelte.dev/docs'), + () => { + tracked = true; + }, + (search_param) => { + tracked_search_params.add(search_param); + } + ); + + url.searchParams.get('test'); + assert.isNotOk(tracked); + assert.ok(tracked_search_params.has('test')); + + url.searchParams.getAll('test-getall'); + assert.isNotOk(tracked); + assert.ok(tracked_search_params.has('test-getall')); + + url.searchParams.has('test-has'); + assert.isNotOk(tracked); + assert.ok(tracked_search_params.has('test-has')); + + url.searchParams.entries(); + assert.ok(tracked); + }); }); describe('disable_search', (test) => { diff --git a/packages/kit/test/apps/basics/src/routes/load/invalidation/search-params/layout-server/+layout.server.js b/packages/kit/test/apps/basics/src/routes/load/invalidation/search-params/layout-server/+layout.server.js new file mode 100644 index 000000000000..c0aa5c8cdf12 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/load/invalidation/search-params/layout-server/+layout.server.js @@ -0,0 +1,8 @@ +let count = 0; + +export function load({ url: { searchParams } }) { + searchParams.get('test'); + return { + count: count++ + }; +} diff --git a/packages/kit/test/apps/basics/src/routes/load/invalidation/search-params/layout-server/+page.svelte b/packages/kit/test/apps/basics/src/routes/load/invalidation/search-params/layout-server/+page.svelte new file mode 100644 index 000000000000..120b9f19bb20 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/load/invalidation/search-params/layout-server/+page.svelte @@ -0,0 +1,7 @@ + + +count: {data.count} +Change test +Change another param diff --git a/packages/kit/test/apps/basics/src/routes/load/invalidation/search-params/layout/+layout.js b/packages/kit/test/apps/basics/src/routes/load/invalidation/search-params/layout/+layout.js new file mode 100644 index 000000000000..c0aa5c8cdf12 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/load/invalidation/search-params/layout/+layout.js @@ -0,0 +1,8 @@ +let count = 0; + +export function load({ url: { searchParams } }) { + searchParams.get('test'); + return { + count: count++ + }; +} diff --git a/packages/kit/test/apps/basics/src/routes/load/invalidation/search-params/layout/+page.svelte b/packages/kit/test/apps/basics/src/routes/load/invalidation/search-params/layout/+page.svelte new file mode 100644 index 000000000000..120b9f19bb20 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/load/invalidation/search-params/layout/+page.svelte @@ -0,0 +1,7 @@ + + +count: {data.count} +Change test +Change another param diff --git a/packages/kit/test/apps/basics/src/routes/load/invalidation/search-params/multiple-search-params/+page.js b/packages/kit/test/apps/basics/src/routes/load/invalidation/search-params/multiple-search-params/+page.js new file mode 100644 index 000000000000..bebe8b39e5a7 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/load/invalidation/search-params/multiple-search-params/+page.js @@ -0,0 +1,8 @@ +let count = 0; + +export function load({ url: { searchParams } }) { + searchParams.getAll('test'); + return { + count: count++ + }; +} diff --git a/packages/kit/test/apps/basics/src/routes/load/invalidation/search-params/multiple-search-params/+page.svelte b/packages/kit/test/apps/basics/src/routes/load/invalidation/search-params/multiple-search-params/+page.svelte new file mode 100644 index 000000000000..0c471c4e4168 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/load/invalidation/search-params/multiple-search-params/+page.svelte @@ -0,0 +1,7 @@ + + +count: {data.count} +Change test +Change second test diff --git a/packages/kit/test/apps/basics/src/routes/load/invalidation/search-params/page-server/+page.server.js b/packages/kit/test/apps/basics/src/routes/load/invalidation/search-params/page-server/+page.server.js new file mode 100644 index 000000000000..c0aa5c8cdf12 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/load/invalidation/search-params/page-server/+page.server.js @@ -0,0 +1,8 @@ +let count = 0; + +export function load({ url: { searchParams } }) { + searchParams.get('test'); + return { + count: count++ + }; +} diff --git a/packages/kit/test/apps/basics/src/routes/load/invalidation/search-params/page-server/+page.svelte b/packages/kit/test/apps/basics/src/routes/load/invalidation/search-params/page-server/+page.svelte new file mode 100644 index 000000000000..120b9f19bb20 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/load/invalidation/search-params/page-server/+page.svelte @@ -0,0 +1,7 @@ + + +count: {data.count} +Change test +Change another param diff --git a/packages/kit/test/apps/basics/src/routes/load/invalidation/search-params/page/+page.js b/packages/kit/test/apps/basics/src/routes/load/invalidation/search-params/page/+page.js new file mode 100644 index 000000000000..c0aa5c8cdf12 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/load/invalidation/search-params/page/+page.js @@ -0,0 +1,8 @@ +let count = 0; + +export function load({ url: { searchParams } }) { + searchParams.get('test'); + return { + count: count++ + }; +} diff --git a/packages/kit/test/apps/basics/src/routes/load/invalidation/search-params/page/+page.svelte b/packages/kit/test/apps/basics/src/routes/load/invalidation/search-params/page/+page.svelte new file mode 100644 index 000000000000..120b9f19bb20 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/load/invalidation/search-params/page/+page.svelte @@ -0,0 +1,7 @@ + + +count: {data.count} +Change test +Change another param diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js index aac4812f51c1..82522d550761 100644 --- a/packages/kit/test/apps/basics/test/client.test.js +++ b/packages/kit/test/apps/basics/test/client.test.js @@ -413,6 +413,66 @@ test.describe('Invalidation', () => { expect(await page.textContent('h1')).toBe('3'); }); + test('load function does not re-runs when another searchParams change (layout)', async ({ + page, + clicknav + }) => { + await page.goto('/load/invalidation/search-params/layout?test=0'); + expect(await page.textContent('span')).toBe('count: 0'); + await clicknav('[href="?test=1"]'); + expect(await page.textContent('span')).toBe('count: 1'); + await clicknav('[href="?test=1&another=another"]'); + expect(await page.textContent('span')).toBe('count: 1'); + }); + + test('load function does re-runs when one of multiple searchParams change (page)', async ({ + page, + clicknav + }) => { + await page.goto('/load/invalidation/search-params/multiple-search-params?test=0'); + expect(await page.textContent('span')).toBe('count: 0'); + await clicknav('[href="?test=1&test=2"]'); + expect(await page.textContent('span')).toBe('count: 1'); + await clicknav('[href="?test=1&test=3"]'); + expect(await page.textContent('span')).toBe('count: 2'); + }); + + test('load function does not re-runs when another searchParams change (layout-server)', async ({ + page, + clicknav + }) => { + await page.goto('/load/invalidation/search-params/layout-server?test=0'); + expect(await page.textContent('span')).toBe('count: 0'); + await clicknav('[href="?test=1"]'); + expect(await page.textContent('span')).toBe('count: 1'); + await clicknav('[href="?test=1&another=another"]'); + expect(await page.textContent('span')).toBe('count: 1'); + }); + + test('load function does not re-runs when another searchParams change (page)', async ({ + page, + clicknav + }) => { + await page.goto('/load/invalidation/search-params/page?test=0'); + expect(await page.textContent('span')).toBe('count: 0'); + await clicknav('[href="?test=1"]'); + expect(await page.textContent('span')).toBe('count: 1'); + await clicknav('[href="?test=1&another=another"]'); + expect(await page.textContent('span')).toBe('count: 1'); + }); + + test('load function does not re-runs when another searchParams change (page-server)', async ({ + page, + clicknav + }) => { + await page.goto('/load/invalidation/search-params/page-server?test=0'); + expect(await page.textContent('span')).toBe('count: 0'); + await clicknav('[href="?test=1"]'); + expect(await page.textContent('span')).toBe('count: 1'); + await clicknav('[href="?test=1&another=another"]'); + expect(await page.textContent('span')).toBe('count: 1'); + }); + test('server-only load functions are re-run following forced invalidation', async ({ page }) => { await page.goto('/load/invalidation/forced'); expect(await page.textContent('h1')).toBe('a: 0, b: 1');