Skip to content

Commit 6421015

Browse files
Rich-Harrispaoloricciutidummdidumm
authored
feat: fine grained search param invalidation (#11258)
* feat: allow for fine grained invalidation of search params * add other instances of search params functions and fix bindings * fix tests for make_trackable * old and new url could be null or undefined * update test * try with logs to debug tests * remove only * more logs * access searchParams before adding the getter and remove logs * add e2e tests for fine grained invalidation * fix linting * Add docs * update docs * add feature flag to avoid breaking change * Create strange-eyes-sort.md * fix config tests and typescript * Update packages/kit/src/exports/public.d.ts Co-authored-by: Simon H <[email protected]> * Update packages/kit/src/utils/url.spec.js Co-authored-by: Simon H <[email protected]> * Update packages/kit/src/runtime/server/respond.js Co-authored-by: Simon H <[email protected]> * Update documentation/docs/20-core-concepts/20-load.md Co-authored-by: Simon H <[email protected]> * Update documentation/docs/20-core-concepts/20-load.md Co-authored-by: Simon H <[email protected]> * remove todos and function check * update check_search_params_changed logic to handle multiple search params * specify where to set fineGrainedSearchParamsInvalidation * use vite define to access fine grained config * remove config since will be on the 2.0 milestone * more conservative warning * shrink code slightly * only create tracked searchParams once * this feels more readable * simplify * simplify * fewer tests * ugh prettier * tweak docs * tweak * tweak * oops, backwards --------- Co-authored-by: paoloricciuti <[email protected]> Co-authored-by: Simon H <[email protected]> Co-authored-by: Rich Harris <[email protected]>
1 parent 02f31e3 commit 6421015

File tree

13 files changed

+221
-26
lines changed

13 files changed

+221
-26
lines changed

.changeset/strange-eyes-sort.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: allow for fine grained invalidation of search params

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -565,6 +565,8 @@ A `load` function that calls `await parent()` will also rerun if a parent `load`
565565

566566
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.
567567

568+
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`.
569+
568570
### Manual invalidation
569571

570572
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.
@@ -614,6 +616,7 @@ To summarize, a `load` function will rerun in the following situations:
614616

615617
- It references a property of `params` whose value has changed
616618
- It references a property of `url` (such as `url.pathname` or `url.search`) whose value has changed. Properties in `request.url` are _not_ tracked
619+
- It calls `url.searchParams.get(...)`, `url.searchParams.getAll(...)` or `url.searchParams.has(...)` and the parameter in question changes. Accessing other properties of `url.searchParams` will have the same effect as accessing `url.search`.
617620
- It calls `await parent()` and a parent `load` function reran
618621
- 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)
619622
- All active `load` functions were forcibly rerun with [`invalidateAll()`](modules#$app-navigation-invalidateall)

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

Lines changed: 64 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -438,7 +438,8 @@ export function create_client(app, target) {
438438
params: new Set(),
439439
parent: false,
440440
route: false,
441-
url: false
441+
url: false,
442+
search_params: new Set()
442443
};
443444

444445
const node = await loader();
@@ -473,9 +474,11 @@ export function create_client(app, target) {
473474
}
474475
}),
475476
data: server_data_node?.data ?? null,
476-
url: make_trackable(url, () => {
477-
uses.url = true;
478-
}),
477+
url: make_trackable(
478+
url,
479+
() => (uses.url = true),
480+
(param) => uses.search_params.add(param)
481+
),
479482
async fetch(resource, init) {
480483
/** @type {URL | string} */
481484
let requested;
@@ -570,10 +573,18 @@ export function create_client(app, target) {
570573
* @param {boolean} parent_changed
571574
* @param {boolean} route_changed
572575
* @param {boolean} url_changed
576+
* @param {Set<string>} search_params_changed
573577
* @param {import('types').Uses | undefined} uses
574578
* @param {Record<string, string>} params
575579
*/
576-
function has_changed(parent_changed, route_changed, url_changed, uses, params) {
580+
function has_changed(
581+
parent_changed,
582+
route_changed,
583+
url_changed,
584+
search_params_changed,
585+
uses,
586+
params
587+
) {
577588
if (force_invalidation) return true;
578589

579590
if (!uses) return false;
@@ -582,6 +593,10 @@ export function create_client(app, target) {
582593
if (uses.route && route_changed) return true;
583594
if (uses.url && url_changed) return true;
584595

596+
for (const tracked_params of uses.search_params) {
597+
if (search_params_changed.has(tracked_params)) return true;
598+
}
599+
585600
for (const param of uses.params) {
586601
if (params[param] !== current.params[param]) return true;
587602
}
@@ -604,6 +619,31 @@ export function create_client(app, target) {
604619
return null;
605620
}
606621

622+
/**
623+
*
624+
* @param {URL | null} old_url
625+
* @param {URL} new_url
626+
*/
627+
function diff_search_params(old_url, new_url) {
628+
if (!old_url) return new Set(new_url.searchParams.keys());
629+
630+
const changed = new Set([...old_url.searchParams.keys(), ...new_url.searchParams.keys()]);
631+
632+
for (const key of changed) {
633+
const old_values = old_url.searchParams.getAll(key);
634+
const new_values = new_url.searchParams.getAll(key);
635+
636+
if (
637+
old_values.every((value) => new_values.includes(value)) &&
638+
new_values.every((value) => old_values.includes(value))
639+
) {
640+
changed.delete(key);
641+
}
642+
}
643+
644+
return changed;
645+
}
646+
607647
/**
608648
* @param {import('./types.js').NavigationIntent} intent
609649
* @returns {Promise<import('./types.js').NavigationResult>}
@@ -625,9 +665,9 @@ export function create_client(app, target) {
625665

626666
/** @type {import('types').ServerNodesResponse | import('types').ServerRedirectNode | null} */
627667
let server_data = null;
628-
629668
const url_changed = current.url ? id !== current.url.pathname + current.url.search : false;
630669
const route_changed = current.route ? route.id !== current.route.id : false;
670+
const search_params_changed = diff_search_params(current.url, url);
631671

632672
let parent_invalid = false;
633673
const invalid_server_nodes = loaders.map((loader, i) => {
@@ -636,7 +676,14 @@ export function create_client(app, target) {
636676
const invalid =
637677
!!loader?.[0] &&
638678
(previous?.loader !== loader[1] ||
639-
has_changed(parent_invalid, route_changed, url_changed, previous.server?.uses, params));
679+
has_changed(
680+
parent_invalid,
681+
route_changed,
682+
url_changed,
683+
search_params_changed,
684+
previous.server?.uses,
685+
params
686+
));
640687

641688
if (invalid) {
642689
// For the next one
@@ -679,7 +726,14 @@ export function create_client(app, target) {
679726
const valid =
680727
(!server_data_node || server_data_node.type === 'skip') &&
681728
loader[1] === previous?.loader &&
682-
!has_changed(parent_changed, route_changed, url_changed, previous.universal?.uses, params);
729+
!has_changed(
730+
parent_changed,
731+
route_changed,
732+
url_changed,
733+
search_params_changed,
734+
previous.universal?.uses,
735+
params
736+
);
683737
if (valid) return previous;
684738

685739
parent_changed = true;
@@ -1960,7 +2014,8 @@ function deserialize_uses(uses) {
19602014
params: new Set(uses?.params ?? []),
19612015
parent: !!uses?.parent,
19622016
route: !!uses?.route,
1963-
url: !!uses?.url
2017+
url: !!uses?.url,
2018+
search_params: new Set(uses?.search_params ?? [])
19642019
};
19652020
}
19662021

packages/kit/src/runtime/server/page/load_data.js

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,18 +22,31 @@ export async function load_server_data({ event, state, node, parent }) {
2222
params: new Set(),
2323
parent: false,
2424
route: false,
25-
url: false
25+
url: false,
26+
search_params: new Set()
2627
};
2728

28-
const url = make_trackable(event.url, () => {
29-
if (DEV && done && !uses.url) {
30-
console.warn(
31-
`${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`
32-
);
33-
}
29+
const url = make_trackable(
30+
event.url,
31+
() => {
32+
if (DEV && done && !uses.url) {
33+
console.warn(
34+
`${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`
35+
);
36+
}
3437

35-
uses.url = true;
36-
});
38+
uses.url = true;
39+
},
40+
(param) => {
41+
if (DEV && done && !uses.search_params.has(param)) {
42+
console.warn(
43+
`${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`
44+
);
45+
}
46+
47+
uses.search_params.add(param);
48+
}
49+
);
3750

3851
if (state.prerendering) {
3952
disable_search(url);

packages/kit/src/runtime/server/utils.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,10 @@ export function stringify_uses(node) {
149149
uses.push(`"dependencies":${JSON.stringify(Array.from(node.uses.dependencies))}`);
150150
}
151151

152+
if (node.uses && node.uses.search_params.size > 0) {
153+
uses.push(`"search_params":${JSON.stringify(Array.from(node.uses.search_params))}`);
154+
}
155+
152156
if (node.uses && node.uses.params.size > 0) {
153157
uses.push(`"params":${JSON.stringify(Array.from(node.uses.params))}`);
154158
}

packages/kit/src/types/internal.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,7 @@ export interface Uses {
407407
parent: boolean;
408408
route: boolean;
409409
url: boolean;
410+
search_params: Set<string>;
410411
}
411412

412413
export type ValidatedConfig = RecursiveRequired<Config>;

packages/kit/src/utils/url.js

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,18 +104,40 @@ const tracked_url_properties = /** @type {const} */ ([
104104
'href',
105105
'pathname',
106106
'search',
107-
'searchParams',
108107
'toString',
109108
'toJSON'
110109
]);
111110

112111
/**
113112
* @param {URL} url
114113
* @param {() => void} callback
114+
* @param {(search_param: string) => void} search_params_callback
115115
*/
116-
export function make_trackable(url, callback) {
116+
export function make_trackable(url, callback, search_params_callback) {
117117
const tracked = new URL(url);
118118

119+
Object.defineProperty(tracked, 'searchParams', {
120+
value: new Proxy(tracked.searchParams, {
121+
get(obj, key) {
122+
if (key === 'get' || key === 'getAll' || key === 'has') {
123+
return (/**@type {string}*/ param) => {
124+
search_params_callback(param);
125+
return obj[key](param);
126+
};
127+
}
128+
129+
// if they try to access something different from what is in `tracked_search_params_properties`
130+
// we track the whole url (entries, values, keys etc)
131+
callback();
132+
133+
const value = Reflect.get(obj, key);
134+
return typeof value === 'function' ? value.bind(obj) : value;
135+
}
136+
}),
137+
enumerable: true,
138+
configurable: true
139+
});
140+
119141
for (const property of tracked_url_properties) {
120142
Object.defineProperty(tracked, property, {
121143
get() {

packages/kit/src/utils/url.spec.js

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -97,26 +97,62 @@ describe('normalize_path', (test) => {
9797
describe('make_trackable', (test) => {
9898
test('makes URL properties trackable', () => {
9999
let tracked = false;
100-
101-
const url = make_trackable(new URL('https://kit.svelte.dev/docs'), () => {
102-
tracked = true;
103-
});
100+
const url = make_trackable(
101+
new URL('https://kit.svelte.dev/docs'),
102+
() => {
103+
tracked = true;
104+
},
105+
() => {}
106+
);
104107

105108
url.origin;
106-
assert.isNotOk(tracked);
109+
assert.ok(!tracked);
107110

108111
url.pathname;
109112
assert.ok(tracked);
110113
});
111114

112115
test('throws an error when its hash property is accessed', () => {
113-
const url = make_trackable(new URL('https://kit.svelte.dev/docs'), () => {});
116+
const url = make_trackable(
117+
new URL('https://kit.svelte.dev/docs'),
118+
() => {},
119+
() => {}
120+
);
114121

115122
assert.throws(
116123
() => url.hash,
117124
/Cannot access event.url.hash. Consider using `\$page.url.hash` inside a component instead/
118125
);
119126
});
127+
128+
test('track each search param separately if accessed directly', () => {
129+
let tracked = false;
130+
const tracked_search_params = new Set();
131+
const url = make_trackable(
132+
new URL('https://kit.svelte.dev/docs'),
133+
() => {
134+
tracked = true;
135+
},
136+
(search_param) => {
137+
tracked_search_params.add(search_param);
138+
}
139+
);
140+
141+
url.searchParams.get('test');
142+
assert.ok(!tracked);
143+
assert.ok(tracked_search_params.has('test'));
144+
145+
url.searchParams.getAll('test-getall');
146+
assert.ok(!tracked);
147+
assert.ok(tracked_search_params.has('test-getall'));
148+
149+
url.searchParams.has('test-has');
150+
assert.ok(!tracked);
151+
assert.ok(tracked_search_params.has('test-has'));
152+
153+
url.searchParams.entries();
154+
assert.ok(tracked);
155+
});
120156
});
121157

122158
describe('disable_search', (test) => {
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
let count = 0;
2+
3+
export function load({ url }) {
4+
url.searchParams.get('a');
5+
return {
6+
count: count++
7+
};
8+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<script>
2+
export let data;
3+
</script>
4+
5+
<span>count: {data.count}</span>
6+
7+
<a data-id="tracked" href="?a={data.count + 1}">Change tracked parameter</a>
8+
<a data-id="untracked" href="?a={data.count}&b={data.count + 1}">Change untracked parameter</a>
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
let count = 0;
2+
3+
export function load({ url }) {
4+
url.searchParams.get('a');
5+
return {
6+
count: count++
7+
};
8+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<script>
2+
export let data;
3+
</script>
4+
5+
<span>count: {data.count}</span>
6+
7+
<a data-id="tracked" href="?a={data.count + 1}">Change tracked parameter</a>
8+
<a data-id="untracked" href="?a={data.count}&b={data.count + 1}">Change untracked parameter</a>

0 commit comments

Comments
 (0)