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/router-client.spec.ts b/e2e/router-client.spec.ts index 65769b0d51..9beb4e3618 100644 --- a/e2e/router-client.spec.ts +++ b/e2e/router-client.spec.ts @@ -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, }) => {