Skip to content

Commit 81cedcf

Browse files
committed
fix(html): leave URLs rewritten through the History API alone
`history.pushState()` and `replaceState()` fire a `navigate` event as well, and the router intercepted it whenever the new URL was a page of the site. A script rewriting the URL in place (dropping tracking parameters, say) had the page fetched again and swapped in over itself, remounting every island. Those are same-document pushes and replaces, which the router now leaves alone. Back and forward between pages navigated to client-side are same-document too, but traversals, so they are still intercepted. Assisted-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent d407004 commit 81cedcf

3 files changed

Lines changed: 36 additions & 3 deletions

File tree

‎e2e/client-side-navigation.spec.js‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,21 @@ test.describe('Client-side navigation', () => {
149149
expect(await page.evaluate(() => window.__document)).toBeUndefined();
150150
});
151151

152+
test('leaves the URL to scripts that rewrite it', async ({ page }) => {
153+
// As a script dropping tracking parameters does
154+
const swapped = await page.evaluate(async () => {
155+
const { body } = document;
156+
157+
history.replaceState(null, '', `${location.pathname}?utm_source=docs`);
158+
await navigation.transition?.finished;
159+
160+
return document.body !== body;
161+
});
162+
163+
await expect(page).toHaveURL(/\/assert\?utm_source=docs$/);
164+
expect(swapped).toBe(false);
165+
});
166+
152167
test('loads the page in full when it cannot swap it in', async ({ page }) => {
153168
// The router's copy is the page as another build writes it, with router
154169
// data this one cannot read; the full load gets the real page

‎packages/react/src/html/ui/__tests__/router.test.mjs‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ describe('shouldIntercept', () => {
6363
*/
6464
const event = overrides => ({
6565
canIntercept: true,
66+
destination: { sameDocument: false },
6667
hashChange: false,
6768
downloadRequest: null,
6869
formData: null,
@@ -75,9 +76,24 @@ describe('shouldIntercept', () => {
7576
});
7677

7778
it('intercepts going back and forward between pages', () => {
78-
assert.ok(
79-
shouldIntercept(event({ navigationType: 'traverse' }), url, root)
80-
);
79+
// Pages navigated to client-side share the document
80+
const traverse = event({
81+
navigationType: 'traverse',
82+
destination: { sameDocument: true },
83+
});
84+
85+
assert.ok(shouldIntercept(traverse, url, root));
86+
});
87+
88+
it('leaves history.pushState() and replaceState() alone', () => {
89+
for (const navigationType of ['push', 'replace']) {
90+
const update = event({
91+
navigationType,
92+
destination: { sameDocument: true },
93+
});
94+
95+
assert.ok(!shouldIntercept(update, url, root), navigationType);
96+
}
8197
});
8298

8399
it('never intercepts a reload, which is always a full load', () => {

‎packages/react/src/html/ui/router.mjs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ export const shouldFollowLink = link =>
6565
*/
6666
export const shouldIntercept = (event, url, root) =>
6767
event.canIntercept &&
68+
// `history.pushState()` and `replaceState()` change the URL in place
69+
(event.navigationType === 'traverse' || !event.destination.sameDocument) &&
6870
!event.hashChange &&
6971
event.downloadRequest === null &&
7072
!event.formData &&

0 commit comments

Comments
 (0)