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,
}) => {