From 04711e57140bd8481814f16b7f51c3a57bacea94 Mon Sep 17 00:00:00 2001 From: daishi Date: Fri, 27 Feb 2026 19:42:09 +0900 Subject: [PATCH 1/3] add failing test with createPortal --- e2e/fixtures/use-router/src/TestRouter.tsx | 12 ++++++ e2e/use-router.spec.ts | 43 ++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/e2e/fixtures/use-router/src/TestRouter.tsx b/e2e/fixtures/use-router/src/TestRouter.tsx index f5516e5d3b..2799887876 100644 --- a/e2e/fixtures/use-router/src/TestRouter.tsx +++ b/e2e/fixtures/use-router/src/TestRouter.tsx @@ -1,17 +1,29 @@ 'use client'; +import { createPortal } from 'react-dom'; import { Link, useRouter } from 'waku'; +const BodyPortal = () => { + return createPortal(
portal
, document.body); +}; + export default function TestRouter() { const router = useRouter(); const params = new URLSearchParams(router.query); const queryCount = parseInt(params.get('count') || '0'); const hashCount = parseInt(router.hash?.slice(1) || '0'); + const portal = params.get('portal') === '1'; return ( <> + {portal && }

Path: {router.path}

Query: {queryCount}

Hash: {hashCount}

+

+ + Go to dynamic portal + +

Increment query

diff --git a/e2e/use-router.spec.ts b/e2e/use-router.spec.ts index 3183967202..98d1536284 100644 --- a/e2e/use-router.spec.ts +++ b/e2e/use-router.spec.ts @@ -112,6 +112,49 @@ test.describe('useRouter', () => { await expect(page.getByTestId('query')).toHaveText('Query: 0'); }); + test('browser back/forward with portal page has no runtime errors', 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/dynamic.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}/static`); + await waitForHydration(page); + await page.click('text=Increment query (push)'); + await expect(page.getByTestId('query')).toHaveText('Query: 1'); + + await page.getByTestId('go-dynamic-portal').click(); + await expect(page.getByRole('heading', { name: 'Dynamic' })).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.poll(() => new URL(page.url()).pathname).toBe('/static'); + await expect + .poll(() => new URL(page.url()).searchParams.get('count')) + .toBe('1'); + }); + test('router.reload refetches dynamic route', async ({ page }) => { await page.goto(`http://localhost:${port}/dynamic`); await waitForHydration(page); From 0bb2380bb40bdc614f5cbda215485cc1648f2d09 Mon Sep 17 00:00:00 2001 From: daishi Date: Fri, 27 Feb 2026 19:47:48 +0900 Subject: [PATCH 2/3] move test --- .../src/components/route-state.tsx | 7 +++ .../router-client/src/pages/start.tsx | 5 +++ e2e/fixtures/use-router/src/TestRouter.tsx | 12 ----- e2e/router-client.spec.ts | 44 +++++++++++++++++++ e2e/use-router.spec.ts | 43 ------------------ 5 files changed, 56 insertions(+), 55 deletions(-) diff --git a/e2e/fixtures/router-client/src/components/route-state.tsx b/e2e/fixtures/router-client/src/components/route-state.tsx index e213d42253..3a03492a35 100644 --- a/e2e/fixtures/router-client/src/components/route-state.tsx +++ b/e2e/fixtures/router-client/src/components/route-state.tsx @@ -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( +
portal
, + document.body, + )}

{router.path}

{router.query}

{router.hash}

diff --git a/e2e/fixtures/router-client/src/pages/start.tsx b/e2e/fixtures/router-client/src/pages/start.tsx index 29d85da013..b97dda429e 100644 --- a/e2e/fixtures/router-client/src/pages/start.tsx +++ b/e2e/fixtures/router-client/src/pages/start.tsx @@ -11,6 +11,11 @@ export default function StartPage() { Go next

+

+ + Go next portal + +

Go query only diff --git a/e2e/fixtures/use-router/src/TestRouter.tsx b/e2e/fixtures/use-router/src/TestRouter.tsx index 2799887876..f5516e5d3b 100644 --- a/e2e/fixtures/use-router/src/TestRouter.tsx +++ b/e2e/fixtures/use-router/src/TestRouter.tsx @@ -1,29 +1,17 @@ 'use client'; -import { createPortal } from 'react-dom'; import { Link, useRouter } from 'waku'; -const BodyPortal = () => { - return createPortal(

portal
, document.body); -}; - export default function TestRouter() { const router = useRouter(); const params = new URLSearchParams(router.query); const queryCount = parseInt(params.get('count') || '0'); const hashCount = parseInt(router.hash?.slice(1) || '0'); - const portal = params.get('portal') === '1'; return ( <> - {portal && }

Path: {router.path}

Query: {queryCount}

Hash: {hashCount}

-

- - Go to dynamic portal - -

Increment query

diff --git a/e2e/router-client.spec.ts b/e2e/router-client.spec.ts index 279e4c9caa..a4cb0f3a02 100644 --- a/e2e/router-client.spec.ts +++ b/e2e/router-client.spec.ts @@ -249,6 +249,50 @@ 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('back/forward for path-change history scrolls to top', async ({ page, }) => { diff --git a/e2e/use-router.spec.ts b/e2e/use-router.spec.ts index 98d1536284..3183967202 100644 --- a/e2e/use-router.spec.ts +++ b/e2e/use-router.spec.ts @@ -112,49 +112,6 @@ test.describe('useRouter', () => { await expect(page.getByTestId('query')).toHaveText('Query: 0'); }); - test('browser back/forward with portal page has no runtime errors', 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/dynamic.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}/static`); - await waitForHydration(page); - await page.click('text=Increment query (push)'); - await expect(page.getByTestId('query')).toHaveText('Query: 1'); - - await page.getByTestId('go-dynamic-portal').click(); - await expect(page.getByRole('heading', { name: 'Dynamic' })).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.poll(() => new URL(page.url()).pathname).toBe('/static'); - await expect - .poll(() => new URL(page.url()).searchParams.get('count')) - .toBe('1'); - }); - test('router.reload refetches dynamic route', async ({ page }) => { await page.goto(`http://localhost:${port}/dynamic`); await waitForHydration(page); From f6f0b2306e3df2587fcd57a40cdbdb63a4dea44c Mon Sep 17 00:00:00 2001 From: daishi Date: Fri, 27 Feb 2026 20:05:56 +0900 Subject: [PATCH 3/3] add another test --- e2e/router-client.spec.ts | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/e2e/router-client.spec.ts b/e2e/router-client.spec.ts index d9b787ea0e..9beb4e3618 100644 --- a/e2e/router-client.spec.ts +++ b/e2e/router-client.spec.ts @@ -293,6 +293,39 @@ test.describe('router-client', () => { 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, }) => {