Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/shallow-history-navigation-hooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': patch
---

fix: run navigation hooks when restoring shallow history entries
91 changes: 75 additions & 16 deletions packages/kit/src/runtime/client/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ import { capture_error } from '../../messages/internal/shared.js';
* state: string;
* persistState: boolean;
* resetIndex: number;
* hashOriginIndex?: number;
* }} HistoryMetadata
*/

Expand All @@ -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<number, { scroll?: { x: number; y: number }; resetIndex?: number }>}
* @type {Record<number, { scroll?: { x: number; y: number }; resetIndex?: number; hashOriginIndex?: number }>}
*/
const history_info = storage.get(HISTORY_INFO_KEY) ?? {};

Expand Down Expand Up @@ -166,12 +167,13 @@ function capture_scroll(index) {

/**
* @param {number} index
* @param {Pick<HistoryMetadata, 'resetIndex'>} options
* @param {Pick<HistoryMetadata, 'resetIndex' | 'hashOriginIndex'>} options
*/
function set_history_options(index, options) {
history_info[index] = {
...history_info[index],
resetIndex: options.resetIndex
resetIndex: options.resetIndex,
hashOriginIndex: options.hashOriginIndex ?? index
};
}

Expand Down Expand Up @@ -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;
}
}

Expand Down Expand Up @@ -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({
Expand All @@ -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;
}

Expand All @@ -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
});
Expand Down Expand Up @@ -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())
);
}
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
);
}
});

Expand All @@ -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) => {
Expand Down
171 changes: 171 additions & 0 deletions packages/kit/test/apps/basics/test/playwright/client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -510,7 +510,7 @@
});

test.describe('$app/state', () => {
test('can use $app/state from anywhere on client', async ({ page }) => {

Check warning on line 513 in packages/kit/test/apps/basics/test/playwright/client.test.js

View workflow job for this annotation

GitHub Actions / e2e (test:svelte-async:dev, js, 1/2)

flaky test: can use $app/state from anywhere on client

retries: 2
await page.goto('/state/client-access');
await expect(page.locator('h1')).toHaveText('undefined');
await page.locator('button').click();
Expand Down Expand Up @@ -1201,7 +1201,7 @@
await expect(page.getByText('slow navigation', { exact: true })).toBeVisible();
});

test('data-sveltekit-preload repeatedly works on the same anchor element', async ({

Check warning on line 1204 in packages/kit/test/apps/basics/test/playwright/client.test.js

View workflow job for this annotation

GitHub Actions / e2e (test:svelte-async:dev, js, 1/2)

flaky test: data-sveltekit-preload repeatedly works on the same anchor element

retries: 2
page,
clicknav
}) => {
Expand Down Expand Up @@ -2070,6 +2070,177 @@
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');
Expand Down
Loading