Skip to content

Commit fc7bce6

Browse files
PatrickGkonstantinov90teemingc
authored
fix: respect replaceState/keepFocus/noScroll when a navigation results in a redirect (#14424)
* respect replace_state on external navigation where applicable * changeset * added tests * fixed client navigation * prettier * changeset * take out trash * take out trash * take out trash * Apply suggestion from @eltigerchino * changeset * fix typo * Apply suggestion from @eltigerchino * Update .changeset/dirty-rats-divide.md * Delete packages/kit/test/apps/basics/src/routes/navigation/nonexistent/.gitkeep * add replaceState to invalidate * fix test * move tests to `goto` suite * change tests according to code review suggestions * take out trash * test fix * fix lint * fix typo * typedef * typedef * fix lint * refactor test * Revert "fix lint" This reverts commit 481e1ab. * remove opts from invalidate/All * always set `replaceState` to `true` when a load function throws a redirect while invalidating * call navigate directly and forward other state as well * sort imports like on main * we don't need an `opts` object on `native_navigation` * revert invalidate in tests * revert invalidate/All types * changelog * 20 --------- Co-authored-by: akonstantinov <[email protected]> Co-authored-by: Tee Ming <[email protected]>
1 parent 2332925 commit fc7bce6

File tree

10 files changed

+199
-22
lines changed

10 files changed

+199
-22
lines changed

.changeset/dirty-rats-divide.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sveltejs/kit': patch
3+
---
4+
5+
fix: respect `replaceState`/`keepFocus`/`noScroll` when a navigation results in a redirect

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

Lines changed: 44 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -149,9 +149,14 @@ function clear_onward_history(current_history_index, current_navigation_index) {
149149
* Returns a `Promise` that never resolves (to prevent any
150150
* subsequent work, e.g. history manipulation, from happening)
151151
* @param {URL} url
152+
* @param {boolean} [replace] If `true`, will replace the current `history` entry rather than creating a new one with `pushState`
152153
*/
153-
function native_navigation(url) {
154-
location.href = url.href;
154+
function native_navigation(url, replace = false) {
155+
if (replace) {
156+
location.replace(url.href);
157+
} else {
158+
location.href = url.href;
159+
}
155160
return new Promise(() => {});
156161
}
157162

@@ -388,7 +393,12 @@ async function _invalidate(include_load_functions = true, reset_page_state = tru
388393
if (!navigation_result || nav_token !== token) return;
389394

390395
if (navigation_result.type === 'redirect') {
391-
return _goto(new URL(navigation_result.location, current.url).href, {}, 1, nav_token);
396+
return _goto(
397+
new URL(navigation_result.location, current.url).href,
398+
{ replaceState: true },
399+
1,
400+
nav_token
401+
);
392402
}
393403

394404
// This is a bit hacky but allows us not having to pass that boolean around, making things harder to reason about
@@ -1532,10 +1542,11 @@ async function navigate({
15321542
route: { id: null }
15331543
}
15341544
),
1535-
404
1545+
404,
1546+
replace_state
15361547
);
15371548
} else {
1538-
return await native_navigation(url);
1549+
return await native_navigation(url, replace_state);
15391550
}
15401551
} else {
15411552
navigation_result = await server_fallback(
@@ -1546,7 +1557,8 @@ async function navigate({
15461557
params: {},
15471558
route: { id: null }
15481559
}),
1549-
404
1560+
404,
1561+
replace_state
15501562
);
15511563
}
15521564
}
@@ -1563,27 +1575,36 @@ async function navigate({
15631575

15641576
if (navigation_result.type === 'redirect') {
15651577
// whatwg fetch spec https://fetch.spec.whatwg.org/#http-redirect-fetch says to error after 20 redirects
1566-
if (redirect_count >= 20) {
1567-
navigation_result = await load_root_error_page({
1568-
status: 500,
1569-
error: await handle_error(new Error('Redirect loop'), {
1570-
url,
1571-
params: {},
1572-
route: { id: null }
1573-
}),
1574-
url,
1575-
route: { id: null }
1578+
if (redirect_count < 20) {
1579+
return navigate({
1580+
type,
1581+
url: new URL(navigation_result.location, url),
1582+
popped,
1583+
keepfocus,
1584+
noscroll,
1585+
replace_state,
1586+
state,
1587+
redirect_count: redirect_count + 1,
1588+
nav_token
15761589
});
1577-
} else {
1578-
await _goto(new URL(navigation_result.location, url).href, {}, redirect_count + 1, nav_token);
1579-
return false;
15801590
}
1591+
1592+
navigation_result = await load_root_error_page({
1593+
status: 500,
1594+
error: await handle_error(new Error('Redirect loop'), {
1595+
url,
1596+
params: {},
1597+
route: { id: null }
1598+
}),
1599+
url,
1600+
route: { id: null }
1601+
});
15811602
} else if (/** @type {number} */ (navigation_result.props.page.status) >= 400) {
15821603
const updated = await stores.updated.check();
15831604
if (updated) {
15841605
// Before reloading, try to update the service worker if it exists
15851606
await update_service_worker();
1586-
await native_navigation(url);
1607+
await native_navigation(url, replace_state);
15871608
}
15881609
}
15891610

@@ -1725,9 +1746,10 @@ async function navigate({
17251746
* @param {{ id: string | null }} route
17261747
* @param {App.Error} error
17271748
* @param {number} status
1749+
* @param {boolean} [replace_state]
17281750
* @returns {Promise<import('./types.js').NavigationFinished>}
17291751
*/
1730-
async function server_fallback(url, route, error, status) {
1752+
async function server_fallback(url, route, error, status, replace_state) {
17311753
if (url.origin === origin && url.pathname === location.pathname && !hydrated) {
17321754
// We would reload the same page we're currently on, which isn't hydrated,
17331755
// which means no SSR, which means we would end up in an endless loop
@@ -1747,7 +1769,7 @@ async function server_fallback(url, route, error, status) {
17471769
debugger; // eslint-disable-line
17481770
}
17491771

1750-
return await native_navigation(url);
1772+
return await native_navigation(url, replace_state);
17511773
}
17521774

17531775
if (import.meta.hot) {
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { redirect } from '@sveltejs/kit';
2+
3+
export const load = () => {
4+
return redirect(302, '/goto/loadreplace2');
5+
};
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { redirect } from '@sveltejs/kit';
2+
3+
export const load = () => {
4+
return redirect(302, '/goto/loadreplace3');
5+
};
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { redirect } from '@sveltejs/kit';
2+
3+
export const load = () => {
4+
return redirect(302, '/goto/testfinish');
5+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<h3>navigation test entry</h3>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<h3>navigation test finish</h3>
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { redirect } from '@sveltejs/kit';
2+
3+
export const load = ({ depends, url, untrack }) => {
4+
depends('app:goto');
5+
if (untrack(() => url.searchParams.get('redirect') !== null)) {
6+
return redirect(302, '/goto/loadreplace1');
7+
}
8+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<h3>navigation test start</h3>

packages/kit/test/apps/basics/test/client.test.js

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1364,6 +1364,130 @@ test.describe('goto', () => {
13641364
: 'goto: invalid URL';
13651365
await expect(page.locator('p')).toHaveText(message);
13661366
});
1367+
1368+
test.describe('navigation and redirects should be consistent between web native and sveltekit based', () => {
1369+
const testEntryPage = '/goto/testentry';
1370+
const testStartPage = '/goto/teststart';
1371+
const testFinishPage = '/goto/testfinish';
1372+
const nonexistentPage = '/goto/nonexistent';
1373+
const loadReplacePage = '/goto/loadreplace1';
1374+
1375+
test.beforeEach(async ({ page }) => {
1376+
await page.goto(testEntryPage);
1377+
await page.goto(testStartPage);
1378+
1379+
await expect(page).toHaveURL(testStartPage);
1380+
});
1381+
1382+
/**
1383+
* @param {string} from
1384+
* @param {string} to
1385+
* @returns {(page: import('@playwright/test').Page) => Promise<void>}
1386+
*/
1387+
function makeExpectGoback(from, to) {
1388+
return async (page) => {
1389+
await expect(page).toHaveURL(from);
1390+
await page.goBack();
1391+
await expect(page).toHaveURL(to);
1392+
};
1393+
}
1394+
1395+
test.describe('navigating outside the app on sameorigin', () => {
1396+
test.describe('without replace', () => {
1397+
const expectGoback = makeExpectGoback(nonexistentPage, testStartPage);
1398+
1399+
test('app.goto', async ({ app, page }) => {
1400+
// navigating to nonexistent page causes playwright's page context to be destroyed
1401+
// thus this call throws an error unless caught
1402+
await app.goto(nonexistentPage, { replaceState: false }).catch(() => {});
1403+
await expectGoback(page);
1404+
});
1405+
1406+
test('location.assign', async ({ page }) => {
1407+
await page.evaluate((url) => {
1408+
location.assign(url);
1409+
}, nonexistentPage);
1410+
await expectGoback(page);
1411+
});
1412+
});
1413+
1414+
test.describe('with replace', () => {
1415+
const expectGoback = makeExpectGoback(nonexistentPage, testEntryPage);
1416+
1417+
test('app.goto', async ({ app, page }) => {
1418+
// navigating to nonexistent page causes playwright's page context to be destroyed
1419+
// thus this call throws an error unless caught
1420+
await app.goto(nonexistentPage, { replaceState: true }).catch(() => {});
1421+
await expectGoback(page);
1422+
});
1423+
1424+
test('location.replace', async ({ page }) => {
1425+
await page.evaluate((url) => {
1426+
location.replace(url);
1427+
}, nonexistentPage);
1428+
await expectGoback(page);
1429+
});
1430+
});
1431+
});
1432+
1433+
test.describe('redirect after invalidation', () => {
1434+
test.beforeEach(async ({ app }) => {
1435+
await app.goto(`${testStartPage}?redirect`, { replaceState: true });
1436+
});
1437+
1438+
const expectGoback = makeExpectGoback(testFinishPage, testEntryPage);
1439+
1440+
test('app.invalidate', async ({ app, page }) => {
1441+
await app.invalidate('app:goto', { replaceState: true });
1442+
await expectGoback(page);
1443+
});
1444+
1445+
test('location.reload', async ({ page }) => {
1446+
await page.evaluate(() => {
1447+
location.reload();
1448+
});
1449+
await expectGoback(page);
1450+
});
1451+
});
1452+
1453+
test.describe('navigating through redirect chain', () => {
1454+
test.describe('without replace', () => {
1455+
const expectGoback = makeExpectGoback(testFinishPage, testStartPage);
1456+
1457+
test('app.goto', async ({ app, page }) => {
1458+
await app.goto(loadReplacePage, { replaceState: false });
1459+
1460+
await expectGoback(page);
1461+
});
1462+
1463+
test('location.assign', async ({ page }) => {
1464+
await page.evaluate((url) => {
1465+
location.assign(url);
1466+
}, loadReplacePage);
1467+
1468+
await expectGoback(page);
1469+
});
1470+
});
1471+
1472+
test.describe('with replace', () => {
1473+
const expectGoback = makeExpectGoback(testFinishPage, testEntryPage);
1474+
1475+
test('app.goto', async ({ app, page }) => {
1476+
await app.goto(loadReplacePage, { replaceState: true });
1477+
1478+
await expectGoback(page);
1479+
});
1480+
1481+
test('location.replace', async ({ page }) => {
1482+
await page.evaluate((url) => {
1483+
location.replace(url);
1484+
}, loadReplacePage);
1485+
1486+
await expectGoback(page);
1487+
});
1488+
});
1489+
});
1490+
});
13671491
});
13681492

13691493
test.describe('untrack', () => {

0 commit comments

Comments
 (0)