Skip to content

Commit c60c9ff

Browse files
[fix] tighten up navigation and invalidation logic (#6924)
* [fix] tighten up navigation and invalidation logic - Fixes #6844, invalidation is now only reset after navigation settled - Fixes #5305, newer invalidations are no longer swalloed by ongoing older ones - Fixes #6902, the pending prefetch is reset upon invalidation * fix redirect token logic * batch synchronous invalidations * tests * tweak load cache timing and reduce code a little * Apply suggestions from code review Co-authored-by: Rich Harris <[email protected]> * lint Co-authored-by: Rich Harris <[email protected]> Co-authored-by: Rich Harris <[email protected]>
1 parent 737160d commit c60c9ff

File tree

11 files changed

+190
-39
lines changed

11 files changed

+190
-39
lines changed

Diff for: .changeset/silent-wasps-sniff.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sveltejs/kit': patch
3+
---
4+
5+
[fix] tighten up navigation and invalidation logic

Diff for: packages/kit/src/runtime/client/client.js

+49-38
Original file line numberDiff line numberDiff line change
@@ -73,11 +73,8 @@ export function create_client({ target, base, trailing_slash }) {
7373
/** @type {Array<((url: URL) => boolean)>} */
7474
const invalidated = [];
7575

76-
/** @type {{id: string | null, promise: Promise<import('./types').NavigationResult | undefined> | null}} */
77-
const load_cache = {
78-
id: null,
79-
promise: null
80-
};
76+
/** @type {{id: string, promise: Promise<import('./types').NavigationResult | undefined>} | null} */
77+
let load_cache = null;
8178

8279
const callbacks = {
8380
/** @type {Array<(navigation: import('types').Navigation & { cancel: () => void }) => void>} */
@@ -91,18 +88,13 @@ export function create_client({ target, base, trailing_slash }) {
9188
let current = {
9289
branch: [],
9390
error: null,
94-
session_id: 0,
9591
// @ts-ignore - we need the initial value to be null
9692
url: null
9793
};
9894

9995
let started = false;
10096
let autoscroll = true;
10197
let updating = false;
102-
let session_id = 1;
103-
104-
/** @type {Promise<void> | null} */
105-
let invalidating = null;
10698
let force_invalidation = false;
10799

108100
/** @type {import('svelte').SvelteComponent} */
@@ -140,31 +132,38 @@ export function create_client({ target, base, trailing_slash }) {
140132
/** @type {{}} */
141133
let token;
142134

143-
function invalidate() {
144-
if (!invalidating) {
145-
const url = new URL(location.href);
146-
147-
invalidating = Promise.resolve().then(async () => {
148-
const intent = get_navigation_intent(url, true);
149-
await update(intent, url, []);
150-
151-
invalidating = null;
152-
force_invalidation = false;
153-
});
154-
}
155-
156-
return invalidating;
135+
/** @type {Promise<void> | null} */
136+
let pending_invalidate;
137+
138+
async function invalidate() {
139+
// Accept all invalidations as they come, don't swallow any while another invalidation
140+
// is running because subsequent invalidations may make earlier ones outdated,
141+
// but batch multiple synchronous invalidations.
142+
pending_invalidate = pending_invalidate || Promise.resolve();
143+
await pending_invalidate;
144+
pending_invalidate = null;
145+
146+
const url = new URL(location.href);
147+
const intent = get_navigation_intent(url, true);
148+
// Clear prefetch, it might be affected by the invalidation.
149+
// Also solves an edge case where a prefetch is triggered, the navigation for it
150+
// was then triggered and is still running while the invalidation kicks in,
151+
// at which point the invalidation should take over and "win".
152+
load_cache = null;
153+
await update(intent, url, []);
157154
}
158155

159156
/**
160157
* @param {string | URL} url
161158
* @param {{ noscroll?: boolean; replaceState?: boolean; keepfocus?: boolean; state?: any }} opts
162159
* @param {string[]} redirect_chain
160+
* @param {{}} [nav_token]
163161
*/
164162
async function goto(
165163
url,
166164
{ noscroll = false, replaceState = false, keepfocus = false, state = {} },
167-
redirect_chain
165+
redirect_chain,
166+
nav_token
168167
) {
169168
if (typeof url === 'string') {
170169
url = new URL(url, get_base_uri(document));
@@ -179,6 +178,7 @@ export function create_client({ target, base, trailing_slash }) {
179178
state,
180179
replaceState
181180
},
181+
nav_token,
182182
accepted: () => {},
183183
blocked: () => {},
184184
type: 'goto'
@@ -193,8 +193,7 @@ export function create_client({ target, base, trailing_slash }) {
193193
throw new Error('Attempted to prefetch a URL that does not belong to this app');
194194
}
195195

196-
load_cache.promise = load_route(intent);
197-
load_cache.id = intent.id;
196+
load_cache = { id: intent.id, promise: load_route(intent) };
198197

199198
return load_cache.promise;
200199
}
@@ -205,10 +204,11 @@ export function create_client({ target, base, trailing_slash }) {
205204
* @param {URL} url
206205
* @param {string[]} redirect_chain
207206
* @param {{hash?: string, scroll: { x: number, y: number } | null, keepfocus: boolean, details: { replaceState: boolean, state: any } | null}} [opts]
207+
* @param {{}} [nav_token] To distinguish between different navigation events and determine the latest. Needed for example for redirects to keep the original token
208208
* @param {() => void} [callback]
209209
*/
210-
async function update(intent, url, redirect_chain, opts, callback) {
211-
const current_token = (token = {});
210+
async function update(intent, url, redirect_chain, opts, nav_token = {}, callback) {
211+
token = nav_token;
212212
let navigation_result = intent && (await load_route(intent));
213213

214214
if (
@@ -239,9 +239,7 @@ export function create_client({ target, base, trailing_slash }) {
239239
url = intent?.url || url;
240240

241241
// abort if user navigated during update
242-
if (token !== current_token) return false;
243-
244-
invalidated.length = 0;
242+
if (token !== nav_token) return false;
245243

246244
if (navigation_result.type === 'redirect') {
247245
if (redirect_chain.length > 10 || redirect_chain.includes(url.pathname)) {
@@ -252,7 +250,12 @@ export function create_client({ target, base, trailing_slash }) {
252250
routeId: null
253251
});
254252
} else {
255-
goto(new URL(navigation_result.location, url).href, {}, [...redirect_chain, url.pathname]);
253+
goto(
254+
new URL(navigation_result.location, url).href,
255+
{},
256+
[...redirect_chain, url.pathname],
257+
nav_token
258+
);
256259
return false;
257260
}
258261
} else if (navigation_result.props?.page?.status >= 400) {
@@ -262,6 +265,11 @@ export function create_client({ target, base, trailing_slash }) {
262265
}
263266
}
264267

268+
// reset invalidation only after a finished navigation. If there are redirects or
269+
// additional invalidations, they should get the same invalidation treatment
270+
invalidated.length = 0;
271+
force_invalidation = false;
272+
265273
updating = true;
266274

267275
if (opts && opts.details) {
@@ -271,6 +279,9 @@ export function create_client({ target, base, trailing_slash }) {
271279
history[details.replaceState ? 'replaceState' : 'pushState'](details.state, '', url);
272280
}
273281

282+
// reset prefetch synchronously after the history state has been set to avoid race conditions
283+
load_cache = null;
284+
274285
if (started) {
275286
current = navigation_result.state;
276287

@@ -334,8 +345,6 @@ export function create_client({ target, base, trailing_slash }) {
334345
await tick();
335346
}
336347

337-
load_cache.promise = null;
338-
load_cache.id = null;
339348
autoscroll = true;
340349

341350
if (navigation_result.props.page) {
@@ -410,8 +419,7 @@ export function create_client({ target, base, trailing_slash }) {
410419
params,
411420
branch,
412421
error,
413-
route,
414-
session_id
422+
route
415423
},
416424
props: {
417425
components: filtered.map((branch_node) => branch_node.node.component)
@@ -683,7 +691,7 @@ export function create_client({ target, base, trailing_slash }) {
683691
* @returns {Promise<import('./types').NavigationResult | undefined>}
684692
*/
685693
async function load_route({ id, invalidating, url, params, route }) {
686-
if (load_cache.id === id && load_cache.promise) {
694+
if (load_cache?.id === id) {
687695
return load_cache.promise;
688696
}
689697

@@ -981,6 +989,7 @@ export function create_client({ target, base, trailing_slash }) {
981989
* } | null;
982990
* type: import('types').NavigationType;
983991
* delta?: number;
992+
* nav_token?: {};
984993
* accepted: () => void;
985994
* blocked: () => void;
986995
* }} opts
@@ -993,6 +1002,7 @@ export function create_client({ target, base, trailing_slash }) {
9931002
details,
9941003
type,
9951004
delta,
1005+
nav_token,
9961006
accepted,
9971007
blocked
9981008
}) {
@@ -1050,6 +1060,7 @@ export function create_client({ target, base, trailing_slash }) {
10501060
keepfocus,
10511061
details
10521062
},
1063+
nav_token,
10531064
() => {
10541065
callbacks.after_navigate.forEach((fn) => fn(navigation));
10551066
stores.navigating.set(null);

Diff for: packages/kit/src/runtime/client/types.d.ts

-1
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,5 @@ export interface NavigationState {
8080
error: App.PageError | null;
8181
params: Record<string, string>;
8282
route: CSRRoute | null;
83-
session_id: number;
8483
url: URL;
8584
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { redirect } from '@sveltejs/kit';
2+
import { get } from 'svelte/store';
3+
import { get_layout, redirect_state } from './state';
4+
5+
/** @type {import('./$types').LayoutLoad} */
6+
export function load({ depends }) {
7+
depends('invalid:layout');
8+
9+
if (get(redirect_state) === 'running') {
10+
redirect_state.set('done');
11+
throw redirect(307, '/load/invalidation/multiple/redirect');
12+
}
13+
14+
return new Promise((resolve) =>
15+
setTimeout(
16+
() =>
17+
resolve({
18+
count_layout: get_layout(),
19+
redirect_mode: get(redirect_state)
20+
}),
21+
Math.random() * 500
22+
)
23+
);
24+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<script>
2+
import { invalidate, invalidateAll } from '$app/navigation';
3+
import { page } from '$app/stores';
4+
import { increment_layout, increment_page } from './state';
5+
6+
/** @param {string} str */
7+
async function update(str) {
8+
if (str !== 'page') {
9+
increment_layout();
10+
}
11+
if (str !== 'layout') {
12+
increment_page();
13+
}
14+
15+
if (str === 'all') {
16+
invalidateAll();
17+
} else {
18+
invalidate(`invalid:${str}`);
19+
}
20+
}
21+
</script>
22+
23+
<button class="layout" on:click={() => update('layout')}>Invalidate layout</button>
24+
<button class="page" on:click={() => update('page')}>Invalidate page</button>
25+
<button class="all" on:click={() => update('all')}>Invalidate all</button>
26+
27+
<p>layout: {$page.data.count_layout}, page: {$page.data.count_page}</p>
28+
29+
<slot />
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { get_page } from './state';
2+
3+
/** @type {import('./$types').PageLoad} */
4+
export function load({ depends }) {
5+
depends('invalid:page');
6+
return new Promise((resolve) =>
7+
setTimeout(
8+
() =>
9+
resolve({
10+
count_page: get_page()
11+
}),
12+
Math.random() * 500
13+
)
14+
);
15+
}

Diff for: packages/kit/test/apps/basics/src/routes/load/invalidation/multiple/+page.svelte

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { redirect } from '@sveltejs/kit';
2+
import { redirect_state } from '../state';
3+
4+
/** @type {import('./$types').PageLoad} */
5+
export async function load({ parent }) {
6+
const { redirect_mode } = await parent();
7+
if (redirect_mode === 'start') {
8+
redirect_state.set('running');
9+
throw redirect(307, '/load/invalidation/multiple');
10+
}
11+
if (redirect_mode === 'running') {
12+
throw new Error('Shouldnt get redirected here with state "running"');
13+
}
14+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<script>
2+
import { invalidateAll } from '$app/navigation';
3+
import { redirect_state } from '../state';
4+
5+
function redirect() {
6+
redirect_state.set('start');
7+
invalidateAll();
8+
}
9+
</script>
10+
11+
<button class="redirect" on:click={redirect}>redirect</button>
12+
<p class="redirect-state">Redirect state: {$redirect_state}</p>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { writable } from 'svelte/store';
2+
3+
let count_layout = 0;
4+
let count_page = 0;
5+
6+
export const redirect_state = writable('initial');
7+
8+
export function increment_layout() {
9+
count_layout++;
10+
}
11+
12+
export function increment_page() {
13+
count_page++;
14+
}
15+
16+
export function get_layout() {
17+
return count_layout;
18+
}
19+
20+
export function get_page() {
21+
return count_page;
22+
}

Diff for: packages/kit/test/apps/basics/test/client.test.js

+20
Original file line numberDiff line numberDiff line change
@@ -761,6 +761,26 @@ test.describe.serial('Invalidation', () => {
761761
expect(await page.textContent('h1')).toBe('a: 4, b: 5');
762762
});
763763

764+
test('multiple invalidations run concurrently', async ({ page, request }) => {
765+
await page.goto('/load/invalidation/multiple');
766+
expect(await page.textContent('p')).toBe('layout: 0, page: 0');
767+
768+
await page.click('button.layout');
769+
await page.click('button.layout');
770+
await page.click('button.page');
771+
await page.click('button.page');
772+
await page.click('button.layout');
773+
await page.click('button.page');
774+
await page.click('button.all');
775+
await expect(page.locator('p')).toHaveText('layout: 4, page: 4');
776+
});
777+
778+
test('invalidateAll persists through redirects', async ({ page }) => {
779+
await page.goto('/load/invalidation/multiple/redirect');
780+
await page.click('button.redirect');
781+
await expect(page.locator('p.redirect-state')).toHaveText('Redirect state: done');
782+
});
783+
764784
test('+layout(.server).js is re-run when server dep is invalidated', async ({ page }) => {
765785
await page.goto('/load/invalidation/depends');
766786
const server = await page.textContent('p.server');

0 commit comments

Comments
 (0)