Skip to content
Closed
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
7 changes: 7 additions & 0 deletions e2e/fixtures/router-client/src/components/route-state.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
'use client';

import { createPortal } from 'react-dom';
import { useRouter } from 'waku';

export function RouteState() {
const router = useRouter();
const portal = new URLSearchParams(router.query).get('portal') === '1';
return (
<>
{portal &&
createPortal(
<div data-testid="portal-marker">portal</div>,
document.body,
)}
Comment on lines +11 to +15
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The portal is created on every render without checking if document.body is available. In SSR contexts or during hydration, document.body may not be defined, which will cause a runtime error. Add a guard to ensure this only runs on the client side, such as checking if typeof document !== 'undefined' before calling createPortal.

Copilot uses AI. Check for mistakes.
<p data-testid="route-path">{router.path}</p>
<p data-testid="route-query">{router.query}</p>
<p data-testid="route-hash">{router.hash}</p>
Expand Down
5 changes: 5 additions & 0 deletions e2e/fixtures/router-client/src/pages/start.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ export default function StartPage() {
Go next
</Link>
</p>
<p>
<Link to="/next?portal=1" data-testid="go-next-portal">
Go next portal
</Link>
</p>
<p>
<Link to="/start?from=query-only" data-testid="go-query-only">
Go query only
Expand Down
77 changes: 77 additions & 0 deletions e2e/router-client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,83 @@ test.describe('router-client', () => {
expect(await getScrollToCalls(page)).toHaveLength(0);
});

test('portal route with popstate churn preserves prior query history entry', async ({
page,
}) => {
const runtimeErrors: string[] = [];
let delayedRequestCount = 0;
page.on('pageerror', (error) => {
runtimeErrors.push(error.message);
});
page.on('console', (msg) => {
if (msg.type() === 'error' && msg.text().includes('removeChild')) {
runtimeErrors.push(msg.text());
}
});
await page.route('**/RSC/R/next.txt**', async (route) => {
if (route.request().url().includes('query=portal%3D1')) {
delayedRequestCount += 1;
await new Promise((resolve) => setTimeout(resolve, 200));
}
await route.continue();
});

await page.goto(`http://localhost:${port}/start`);
await waitForHydration(page);

await page.getByTestId('router-push-query-only').click();
await expect(page.getByTestId('route-path')).toHaveText('/start');
await expect(page.getByTestId('route-query')).toHaveText('from=query-only');

await page.getByTestId('go-next-portal').click();
await expect(page.getByRole('heading', { name: 'Next' })).toBeVisible();
await expect(page.getByTestId('portal-marker')).toBeVisible();

await page.evaluate(() => {
window.history.back();
window.history.forward();
window.history.back();
});

expect(delayedRequestCount).toBeGreaterThan(0);
expect(runtimeErrors).toEqual([]);
await expect(page.getByTestId('route-path')).toHaveText('/start');
await expect(page.getByTestId('route-query')).toHaveText('from=query-only');
});

test('non-portal route with popstate churn preserves prior query history entry', async ({
page,
}) => {
let delayedRequestCount = 0;
await page.route('**/RSC/R/next.txt**', async (route) => {
if (route.request().url().includes('query=x%3D1')) {
delayedRequestCount += 1;
await new Promise((resolve) => setTimeout(resolve, 200));
}
await route.continue();
});

await page.goto(`http://localhost:${port}/start`);
await waitForHydration(page);

await page.getByTestId('router-push-query-only').click();
await expect(page.getByTestId('route-path')).toHaveText('/start');
await expect(page.getByTestId('route-query')).toHaveText('from=query-only');

await page.getByTestId('go-next').click();
await expect(page.getByRole('heading', { name: 'Next' })).toBeVisible();

await page.evaluate(() => {
window.history.back();
window.history.forward();
window.history.back();
});

expect(delayedRequestCount).toBeGreaterThan(0);
await expect(page.getByTestId('route-path')).toHaveText('/start');
await expect(page.getByTestId('route-query')).toHaveText('from=query-only');
});

test('back/forward for path-change history scrolls to top', async ({
page,
}) => {
Expand Down
Loading