diff --git a/.changeset/shallow-history-navigation-hooks.md b/.changeset/shallow-history-navigation-hooks.md new file mode 100644 index 000000000000..d734aab31c0f --- /dev/null +++ b/.changeset/shallow-history-navigation-hooks.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +fix: run navigation hooks when restoring shallow history entries diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index b8ad78e8fb79..bb5d737b15f0 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -79,6 +79,7 @@ import { capture_error } from '../../messages/internal/shared.js'; * state: string; * persistState: boolean; * resetIndex: number; + * hashOriginIndex?: number; * }} HistoryMetadata */ @@ -103,7 +104,7 @@ const resetters = new Set(); // popstate it's too late to access the options or update the focus position associated with the // state we're navigating from /** - * @type {Record} + * @type {Record} */ const history_info = storage.get(HISTORY_INFO_KEY) ?? {}; @@ -166,12 +167,13 @@ function capture_scroll(index) { /** * @param {number} index - * @param {Pick} options + * @param {Pick} options */ function set_history_options(index, options) { history_info[index] = { ...history_info[index], - resetIndex: options.resetIndex + resetIndex: options.resetIndex, + hashOriginIndex: options.hashOriginIndex ?? index }; } @@ -2241,6 +2243,7 @@ async function run_on_navigate_callbacks(navigation) { after_navigate.push(cleanup); after_navigate.forEach((fn) => after_navigate_callbacks.add(fn)); + return cleanup; } } @@ -3355,12 +3358,62 @@ function _start_router() { } : null; + const block = () => { + current_history_index = previous_history_index; + current_navigation_index = previous_navigation_index; + current_reset_index = previous_reset_index; + history.go(-delta); + }; + if (shallow) { // We don't need to navigate, we just need to update scroll and/or state. - // This happens with hash links and `pushState`/`replaceState`. The + // This happens with hash links and shallow routing. The // exception is if we haven't navigated yet, since we could have // got here after a modal navigation then a reload + // The token above aborted any in-flight navigation. A shallow restoration + // starts its own lifecycle, while ordinary hash links remain hook-free. + is_navigating = false; + set_navigation(null); + + const nav = + (page.shallow || shallow_target) && + source_info?.hashOriginIndex !== (history_metadata.hashOriginIndex ?? history_index) + ? _before_navigate({ + url, + type: 'popstate', + delta, + event, + scroll, + shallow: true, + target: shallow_target ?? { + params: current.params, + route: current.route ? { id: current.route.id } : null, + url + } + }) + : undefined; + + if (nav === null) { + block(); + return; + } + + const previous_snapshot_registrations = current_registrations(); + if (nav) { + is_navigating = true; + set_navigation(nav.navigation); + updating = true; + const cleanup = await run_on_navigate_callbacks( + /** @type {OnNavigate} */ (nav.navigation) + ); + if (navigation_token !== token) { + cleanup?.(); + nav.reject(new Error('navigation aborted')); + return; + } + } + blur_active_element(reset); update_page({ @@ -3370,11 +3423,16 @@ function _start_router() { update_url(url); - if (reset && scroll) scrollTo(scroll.x, scroll.y); - restore_navigation_snapshot(current_history_index, current_registrations()); - // the token above aborted any in-flight navigation, and nothing else will clear it - is_navigating = false; + if (nav) { + const finished = await finish_navigation(nav, token, url, scroll, reset, settled()); + if (!finished) return; + } else if (reset && scroll) { + scrollTo(scroll.x, scroll.y); + } + + restore_navigation_snapshot(current_history_index, previous_snapshot_registrations); set_navigation(null); + if (nav) updating = false; return; } @@ -3388,12 +3446,7 @@ function _start_router() { delta, shallow: shallow_target }, - block: () => { - current_history_index = previous_history_index; - current_navigation_index = previous_navigation_index; - current_reset_index = previous_reset_index; - history.go(-delta); - }, + block, nav_token: token, event }); @@ -3427,13 +3480,19 @@ function _start_router() { [HISTORY_METADATA_KEY]: { ...history_metadata, historyIndex: ++current_history_index, - navigationIndex: current_navigation_index + navigationIndex: current_navigation_index, + // Hash links inherit their originating entry, including shallow state. + // Traversing within this group must not run navigation hooks. + hashOriginIndex: history_metadata.hashOriginIndex ?? history_metadata.historyIndex } }, '', location.href ); - set_history_options(current_history_index, history_metadata); + set_history_options( + current_history_index, + /** @type {HistoryMetadata} */ (get_history_metadata()) + ); } }); diff --git a/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/+layout.svelte b/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/+layout.svelte index 625c29c41956..2984f264fa89 100644 --- a/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/+layout.svelte +++ b/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/+layout.svelte @@ -18,12 +18,16 @@ type: navigation.type }); - if (navigation.to?.url.searchParams.has('cancel')) { + if ( + navigation.to?.url.searchParams.has('cancel') || + (navigation.type === 'popstate' && navigation.to?.url.searchParams.has('cancel-popstate')) + ) { navigation.cancel(); } else { - void navigation.complete.then(() => { - window.shallow_navigation_log.push({ hook: 'complete' }); - }); + void navigation.complete.then( + () => window.shallow_navigation_log.push({ hook: 'complete' }), + () => window.shallow_navigation_log.push({ hook: 'aborted' }) + ); } }); @@ -35,6 +39,19 @@ type: navigation.type }); } + + if (navigation.type === 'popstate' && navigation.to?.url.searchParams.has('defer')) { + return new Promise((resolve) => { + document.addEventListener( + 'continue-navigation', + () => + resolve(() => { + window.shallow_navigation_log.push({ hook: 'cleanup' }); + }), + { once: true } + ); + }); + } }); afterNavigate((navigation) => { diff --git a/packages/kit/test/apps/basics/test/playwright/client.test.js b/packages/kit/test/apps/basics/test/playwright/client.test.js index 09b135b4a88b..bf79ff6c93c4 100644 --- a/packages/kit/test/apps/basics/test/playwright/client.test.js +++ b/packages/kit/test/apps/basics/test/playwright/client.test.js @@ -2070,6 +2070,177 @@ test.describe('Shallow routing', () => { await expect(page.locator('[data-id="shallow"]')).toHaveText('null'); }); + for (const button of ['one', 'params']) { + test(`Runs shallow history navigation hooks (${button})`, async ({ page }) => { + await page.goto('/shallow-routing/push-state'); + const now = await page.locator('[data-id="now"]').textContent(); + await page.locator(`[data-id="${button}"]`).click(); + await expect.poll(() => page.evaluate(() => window.shallow_navigation_log.length)).toBe(4); + await page.evaluate(() => (window.shallow_navigation_log = [])); + + await page.goBack(); + await expect(page.locator('p')).toHaveText('active: false'); + await expect + .poll(() => page.evaluate(() => window.shallow_navigation_log)) + .toEqual([ + { + hook: 'before', + params: {}, + path: '/shallow-routing/push-state', + route: '/shallow-routing/push-state', + shallow: true, + type: 'popstate' + }, + { hook: 'on', shallow: true, type: 'popstate' }, + { hook: 'after', shallow: true, state: 'active: false', type: 'popstate' }, + { hook: 'complete' } + ]); + await expect(page.locator('[data-id="now"]')).toHaveText(now ?? ''); + await page.evaluate(() => (window.shallow_navigation_log = [])); + + await page.goForward(); + await expect(page.locator('p')).toHaveText('active: true'); + await expect.poll(() => page.evaluate(() => window.shallow_navigation_log.length)).toBe(4); + expect(await page.evaluate(() => window.shallow_navigation_log)).toEqual([ + { + hook: 'before', + params: button === 'params' ? { param: 'hello' } : {}, + path: '/shallow-routing/push-state' + (button === 'params' ? '/hello' : ''), + route: '/shallow-routing/push-state' + (button === 'params' ? '/[param]' : ''), + shallow: true, + type: 'popstate' + }, + { hook: 'on', shallow: true, type: 'popstate' }, + { hook: 'after', shallow: true, state: 'active: true', type: 'popstate' }, + { hook: 'complete' } + ]); + await expect(page.locator('[data-id="now"]')).toHaveText(now ?? ''); + }); + } + + test('Keeps hash-only history traversal hook-free in a shallow context', async ({ + page, + app + }) => { + await page.goto('/shallow-routing/push-state'); + await app.goto('', { shallow: true, state: { active: true } }); + await page.evaluate(() => (window.shallow_navigation_log = [])); + + // Native hash links inherit the shallow entry's state and rendered URL. + for (const hash of ['#one', '#two']) { + await page.evaluate((hash) => { + const a = document.createElement('a'); + a.href = hash; + document.body.append(a); + a.click(); + a.remove(); + }, hash); + await expect(page).toHaveURL(new RegExp(`${hash}$`)); + } + + await page.goBack(); + await expect(page).toHaveURL(/#one$/); + await page.goBack(); + await expect(page).toHaveURL('/shallow-routing/push-state'); + await page.goForward(); + await expect(page).toHaveURL(/#one$/); + await page.goForward(); + await expect(page).toHaveURL(/#two$/); + await expect(page.locator('p')).toHaveText('active: true'); + expect(await page.evaluate(() => window.shallow_navigation_log)).toEqual([]); + + // A shallow goto that changes only the hash must still run hooks, including + // on traversal. URL/state equality alone cannot distinguish these entries. + await app.goto('#three', { shallow: true, state: { active: true } }); + await page.evaluate(() => (window.shallow_navigation_log = [])); + await page.goBack(); + await expect(page).toHaveURL(/#two$/); + await expect + .poll(() => page.evaluate(() => window.shallow_navigation_log.map((n) => n.hook))) + .toEqual(['before', 'on', 'after', 'complete']); + await page.evaluate(() => (window.shallow_navigation_log = [])); + await page.goForward(); + await expect(page).toHaveURL(/#three$/); + await expect + .poll(() => page.evaluate(() => window.shallow_navigation_log.map((n) => n.hook))) + .toEqual(['before', 'on', 'after', 'complete']); + }); + + test('Waits for onNavigate when restoring shallow history', async ({ page }) => { + await page.goto('/shallow-routing/push-state?defer'); + await page.locator('[data-id="one"]').click(); + await expect.poll(() => page.evaluate(() => window.shallow_navigation_log.length)).toBe(4); + await page.evaluate(() => (window.shallow_navigation_log = [])); + + await page.goBack(); + await expect + .poll(() => page.evaluate(() => window.shallow_navigation_log.map((n) => n.hook))) + .toEqual(['before', 'on']); + await expect(page.locator('p')).toHaveText('active: true'); + + await page.evaluate(() => document.dispatchEvent(new Event('continue-navigation'))); + await expect(page.locator('p')).toHaveText('active: false'); + await expect + .poll(() => page.evaluate(() => window.shallow_navigation_log.map((n) => n.hook))) + .toEqual(['before', 'on', 'after', 'cleanup', 'complete']); + + await page.evaluate(() => (window.shallow_navigation_log = [])); + await page.locator('[data-id="one"]').click(); + await expect + .poll(() => page.evaluate(() => window.shallow_navigation_log.map((n) => n.hook))) + .toEqual(['before', 'on', 'after', 'complete']); + }); + + test('Does not commit a superseded shallow history restoration', async ({ page, app }) => { + await page.goto('/shallow-routing/push-state?defer'); + await page.locator('[data-id="one"]').click(); + await expect.poll(() => page.evaluate(() => window.shallow_navigation_log.length)).toBe(4); + await page.evaluate(() => (window.shallow_navigation_log = [])); + + await page.goBack(); + await expect + .poll(() => page.evaluate(() => window.shallow_navigation_log.map((n) => n.hook))) + .toEqual(['before', 'on']); + await app.goto('/shallow-routing/push-state', { shallow: true, state: { active: true } }); + await page.evaluate(() => document.dispatchEvent(new Event('continue-navigation'))); + await expect + .poll(() => page.evaluate(() => window.shallow_navigation_log.map((n) => n.hook))) + .toEqual(['before', 'on', 'on', 'after', 'aborted']); + await expect(page.locator('p')).toHaveText('active: true'); + + await page.evaluate(() => (window.shallow_navigation_log = [])); + await app.goto('/shallow-routing/push-state', { shallow: true }); + expect(await page.evaluate(() => window.shallow_navigation_log.map((n) => n.hook))).toEqual([ + 'before', + 'on', + 'after', + 'complete' + ]); + }); + + test('Can cancel shallow history restoration', async ({ page, baseURL }) => { + await page.goto('/shallow-routing/push-state?cancel-popstate'); + await page.locator('[data-id="params"]').click(); + await expect.poll(() => page.evaluate(() => window.shallow_navigation_log.length)).toBe(4); + const entry = await page.evaluate(() => history.state); + await page.evaluate(() => (window.shallow_navigation_log = [])); + + await page.goBack(); + await expect.poll(() => page.evaluate(() => history.state)).toEqual(entry); + await expect(page).toHaveURL(`${baseURL}/shallow-routing/push-state/hello`); + await expect(page.locator('p')).toHaveText('active: true'); + expect(await page.evaluate(() => window.shallow_navigation_log)).toEqual([ + { + hook: 'before', + params: {}, + path: '/shallow-routing/push-state', + route: '/shallow-routing/push-state', + shallow: true, + type: 'popstate' + } + ]); + }); + test('serializes custom objects in state', async ({ page }) => { await page.goto('/shallow-routing/push-state/foo'); await expect(page.locator('[data-testid=foo]')).toHaveText('foo: nope');