From 7b8f7e99744829504bf274f58b925c31946a44bb Mon Sep 17 00:00:00 2001 From: Sheraff Date: Fri, 20 Mar 2026 19:45:27 +0100 Subject: [PATCH 1/7] fix(solid-router): preserve head assets on history state updates --- .../solid-router/src/headContentUtils.tsx | 11 +- packages/solid-router/tests/Scripts.test.tsx | 114 +++++++++++++++++- 2 files changed, 120 insertions(+), 5 deletions(-) diff --git a/packages/solid-router/src/headContentUtils.tsx b/packages/solid-router/src/headContentUtils.tsx index b633723a8f6..1b30fcc50b7 100644 --- a/packages/solid-router/src/headContentUtils.tsx +++ b/packages/solid-router/src/headContentUtils.tsx @@ -1,5 +1,5 @@ import * as Solid from 'solid-js' -import { escapeHtml } from '@tanstack/router-core' +import { escapeHtml, replaceEqualDeep } from '@tanstack/router-core' import { useRouter } from './useRouter' import type { RouterManagedTag } from '@tanstack/router-core' @@ -179,8 +179,8 @@ export const useTags = () => { })), ) - return () => - uniqBy( + return Solid.createMemo((prev: Array | undefined) => { + const next = uniqBy( [ ...meta(), ...preloadLinks(), @@ -192,6 +192,11 @@ export const useTags = () => { return JSON.stringify(d) }, ) + if (prev === undefined) { + return next + } + return replaceEqualDeep(prev, next) + }) } export function uniqBy(arr: Array, fn: (item: T) => string) { diff --git a/packages/solid-router/tests/Scripts.test.tsx b/packages/solid-router/tests/Scripts.test.tsx index 6ffa0bd645d..a86ef6e54ba 100644 --- a/packages/solid-router/tests/Scripts.test.tsx +++ b/packages/solid-router/tests/Scripts.test.tsx @@ -1,9 +1,17 @@ -import { describe, expect, test } from 'vitest' -import { render } from '@solidjs/testing-library' +import { afterEach, describe, expect, test } from 'vitest' +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@solidjs/testing-library' import { HeadContent, + Outlet, RouterProvider, + createBrowserHistory, createMemoryHistory, createRootRoute, createRoute, @@ -11,6 +19,11 @@ import { } from '../src' import { Scripts } from '../src/Scripts' +afterEach(() => { + window.history.replaceState(null, 'root', '/') + cleanup() +}) + describe('ssr scripts', () => { test('it works', async () => { const rootRoute = createRootRoute({ @@ -194,4 +207,101 @@ describe('ssr HeadContent', () => { { property: 'og:image', content: 'index-image.jpg' }, ]) }) + + test('keeps manifest stylesheet links mounted when history state changes', async () => { + const history = createBrowserHistory() + + try { + const rootRoute = createRootRoute({ + component: () => { + return ( + <> + + + + + ) + }, + }) + + const indexRoute = createRoute({ + path: '/', + getParentRoute: () => rootRoute, + component: () =>
Index
, + }) + + const router = createRouter({ + history, + routeTree: rootRoute.addChildren([indexRoute]), + }) + + router.ssr = { + manifest: { + routes: { + [rootRoute.id]: { + assets: [ + { + tag: 'link', + attrs: { + rel: 'stylesheet', + href: '/main.css', + }, + }, + ], + }, + }, + }, + } as any + + await router.load() + + render(() => ) + + const getStylesheetLink = () => + Array.from( + document.head.querySelectorAll('link[rel="stylesheet"]'), + ).find((link) => link.getAttribute('href') === '/main.css') + + await waitFor(() => { + expect(getStylesheetLink()).toBeInstanceOf(HTMLLinkElement) + }) + + const initialLink = getStylesheetLink() + expect(initialLink).toBeInstanceOf(HTMLLinkElement) + + fireEvent.click(screen.getByRole('button', { name: 'Replace state' })) + + await waitFor(() => { + expect( + (router.state.location.state as { slideId?: string }).slideId, + ).toBe('slide-2') + }) + + expect(getStylesheetLink()).toBe(initialLink) + expect( + Array.from( + document.head.querySelectorAll('link[rel="stylesheet"]'), + ).filter((link) => link.getAttribute('href') === '/main.css'), + ).toHaveLength(1) + } finally { + history.destroy() + document.head + .querySelectorAll('link[rel="stylesheet"]') + .forEach((link) => { + if (link.getAttribute('href') === '/main.css') { + link.remove() + } + }) + } + }) }) From f12ac8db71f99bb7d5ebeeaae4988505d392dfd5 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Fri, 20 Mar 2026 19:53:45 +0100 Subject: [PATCH 2/7] test(solid-router): cover Link head asset stability --- packages/solid-router/tests/Scripts.test.tsx | 91 ++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/packages/solid-router/tests/Scripts.test.tsx b/packages/solid-router/tests/Scripts.test.tsx index a86ef6e54ba..ee7b6289430 100644 --- a/packages/solid-router/tests/Scripts.test.tsx +++ b/packages/solid-router/tests/Scripts.test.tsx @@ -9,6 +9,7 @@ import { import { HeadContent, + Link, Outlet, RouterProvider, createBrowserHistory, @@ -116,6 +117,96 @@ describe('ssr scripts', () => { '', ) }) + + test('keeps manifest stylesheet links mounted when navigating with Link', async () => { + const history = createBrowserHistory() + + try { + const rootRoute = createRootRoute({ + component: () => { + return ( + <> + + + + ) + }, + }) + + const indexRoute = createRoute({ + path: '/', + getParentRoute: () => rootRoute, + component: () => Go to about page, + }) + + const aboutRoute = createRoute({ + path: '/about', + getParentRoute: () => rootRoute, + component: () =>
About
, + }) + + const router = createRouter({ + history, + routeTree: rootRoute.addChildren([indexRoute, aboutRoute]), + }) + + router.ssr = { + manifest: { + routes: { + [rootRoute.id]: { + assets: [ + { + tag: 'link', + attrs: { + rel: 'stylesheet', + href: '/main.css', + }, + }, + ], + }, + }, + }, + } as any + + await router.load() + + render(() => ) + + const getStylesheetLink = () => + Array.from( + document.head.querySelectorAll('link[rel="stylesheet"]'), + ).find((link) => link.getAttribute('href') === '/main.css') + + await waitFor(() => { + expect(getStylesheetLink()).toBeInstanceOf(HTMLLinkElement) + }) + + const initialLink = getStylesheetLink() + expect(initialLink).toBeInstanceOf(HTMLLinkElement) + + fireEvent.click(screen.getByRole('link', { name: 'Go to about page' })) + + await waitFor(() => { + expect(router.state.location.pathname).toBe('/about') + }) + + expect(getStylesheetLink()).toBe(initialLink) + expect( + Array.from( + document.head.querySelectorAll('link[rel="stylesheet"]'), + ).filter((link) => link.getAttribute('href') === '/main.css'), + ).toHaveLength(1) + } finally { + history.destroy() + document.head + .querySelectorAll('link[rel="stylesheet"]') + .forEach((link) => { + if (link.getAttribute('href') === '/main.css') { + link.remove() + } + }) + } + }) }) describe('ssr HeadContent', () => { From 2ec0e55fb6eef9858f54d0872d82c7e804ccb14c Mon Sep 17 00:00:00 2001 From: Sheraff Date: Fri, 20 Mar 2026 20:04:01 +0100 Subject: [PATCH 3/7] test(router): cover head asset stability in react and vue --- packages/react-router/tests/Scripts.test.tsx | 192 +++++++++++++++++- packages/vue-router/tests/Scripts.test.tsx | 200 ++++++++++++++++++- 2 files changed, 388 insertions(+), 4 deletions(-) diff --git a/packages/react-router/tests/Scripts.test.tsx b/packages/react-router/tests/Scripts.test.tsx index a3f31a3a8af..285137996a4 100644 --- a/packages/react-router/tests/Scripts.test.tsx +++ b/packages/react-router/tests/Scripts.test.tsx @@ -1,11 +1,21 @@ -import { describe, expect, test } from 'vitest' -import { act, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, test } from 'vitest' +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react' +import { createPortal } from 'react-dom' import ReactDOMServer from 'react-dom/server' import { HeadContent, + Link, Outlet, RouterProvider, + createBrowserHistory, createMemoryHistory, createRootRoute, createRoute, @@ -13,6 +23,11 @@ import { } from '../src' import { Scripts } from '../src/Scripts' +afterEach(() => { + window.history.replaceState(null, 'root', '/') + cleanup() +}) + describe('ssr scripts', () => { test('it works', async () => { const rootRoute = createRootRoute({ @@ -327,6 +342,179 @@ describe('ssr HeadContent', () => { `Index`, ) }) + + test('keeps manifest stylesheet links mounted when history state changes', async () => { + const history = createBrowserHistory() + + try { + const rootRoute = createRootRoute({ + component: () => { + return ( + <> + {createPortal(, document.head)} + + + + ) + }, + }) + + const indexRoute = createRoute({ + path: '/', + getParentRoute: () => rootRoute, + component: () =>
Index
, + }) + + const router = createRouter({ + history, + routeTree: rootRoute.addChildren([indexRoute]), + }) + + router.ssr = { + manifest: { + routes: { + [rootRoute.id]: { + assets: [ + { + tag: 'link', + attrs: { + rel: 'stylesheet', + href: '/main.css', + }, + }, + ], + }, + }, + }, + } as any + + await router.load() + + await act(() => render()) + + const getStylesheetLink = () => + Array.from(document.querySelectorAll('link[rel="stylesheet"]')).find( + (link) => link.getAttribute('href') === '/main.css', + ) + + await waitFor(() => { + expect(getStylesheetLink()).toBeInstanceOf(HTMLLinkElement) + }) + + const initialLink = getStylesheetLink() + expect(initialLink).toBeInstanceOf(HTMLLinkElement) + + fireEvent.click(screen.getByRole('button', { name: 'Replace state' })) + + await waitFor(() => { + expect( + (router.state.location.state as { slideId?: string }).slideId, + ).toBe('slide-2') + }) + + expect(getStylesheetLink()).toBe(initialLink) + expect( + Array.from(document.querySelectorAll('link[rel="stylesheet"]')).filter( + (link) => link.getAttribute('href') === '/main.css', + ), + ).toHaveLength(1) + } finally { + history.destroy() + } + }) + + test('keeps manifest stylesheet links mounted when navigating with Link', async () => { + const history = createBrowserHistory() + + try { + const rootRoute = createRootRoute({ + component: () => { + return ( + <> + {createPortal(, document.head)} + + + ) + }, + }) + + const indexRoute = createRoute({ + path: '/', + getParentRoute: () => rootRoute, + component: () => Go to about page, + }) + + const aboutRoute = createRoute({ + path: '/about', + getParentRoute: () => rootRoute, + component: () =>
About
, + }) + + const router = createRouter({ + history, + routeTree: rootRoute.addChildren([indexRoute, aboutRoute]), + }) + + router.ssr = { + manifest: { + routes: { + [rootRoute.id]: { + assets: [ + { + tag: 'link', + attrs: { + rel: 'stylesheet', + href: '/main.css', + }, + }, + ], + }, + }, + }, + } as any + + await router.load() + + await act(() => render()) + + const getStylesheetLink = () => + Array.from(document.querySelectorAll('link[rel="stylesheet"]')).find( + (link) => link.getAttribute('href') === '/main.css', + ) + + await waitFor(() => { + expect(getStylesheetLink()).toBeInstanceOf(HTMLLinkElement) + }) + + const initialLink = getStylesheetLink() + expect(initialLink).toBeInstanceOf(HTMLLinkElement) + + fireEvent.click(screen.getByRole('link', { name: 'Go to about page' })) + + await waitFor(() => { + expect(router.state.location.pathname).toBe('/about') + }) + + expect(getStylesheetLink()).toBe(initialLink) + expect( + Array.from(document.querySelectorAll('link[rel="stylesheet"]')).filter( + (link) => link.getAttribute('href') === '/main.css', + ), + ).toHaveLength(1) + } finally { + history.destroy() + } + }) }) describe('data script rendering', () => { diff --git a/packages/vue-router/tests/Scripts.test.tsx b/packages/vue-router/tests/Scripts.test.tsx index 577795a9db7..755e6053a33 100644 --- a/packages/vue-router/tests/Scripts.test.tsx +++ b/packages/vue-router/tests/Scripts.test.tsx @@ -1,9 +1,19 @@ -import { describe, expect, test } from 'vitest' -import { render } from '@testing-library/vue' +import { afterEach, describe, expect, test } from 'vitest' +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/vue' +import { Teleport } from 'vue' import { HeadContent, + Link, + Outlet, RouterProvider, + createBrowserHistory, createMemoryHistory, createRootRoute, createRoute, @@ -11,6 +21,11 @@ import { } from '../src' import { Scripts } from '../src/Scripts' +afterEach(() => { + window.history.replaceState(null, 'root', '/') + cleanup() +}) + describe('ssr scripts', () => { test('it works', async () => { const rootRoute = createRootRoute({ @@ -198,4 +213,185 @@ describe('ssr HeadContent', () => { { property: 'og:image', content: 'index-image.jpg' }, ]) }) + + test('keeps manifest stylesheet links mounted when history state changes', async () => { + const history = createBrowserHistory() + + try { + const rootRoute = createRootRoute({ + component: () => { + return ( + <> + + + + + + + ) + }, + }) + + const indexRoute = createRoute({ + path: '/', + getParentRoute: () => rootRoute, + component: () =>
Index
, + }) + + const router = createRouter({ + history, + routeTree: rootRoute.addChildren([indexRoute]), + }) + + router.ssr = { + manifest: { + routes: { + [rootRoute.id]: { + assets: [ + { + tag: 'link', + attrs: { + rel: 'stylesheet', + href: '/main.css', + }, + }, + ], + }, + }, + }, + } as any + + await router.load() + + render() + + const getStylesheetLink = () => + Array.from(document.querySelectorAll('link[rel="stylesheet"]')).find( + (link) => link.getAttribute('href') === '/main.css', + ) + + await waitFor(() => { + expect(getStylesheetLink()).toBeInstanceOf(HTMLLinkElement) + }) + + const initialLink = getStylesheetLink() + expect(initialLink).toBeInstanceOf(HTMLLinkElement) + + await fireEvent.click( + screen.getByRole('button', { name: 'Replace state' }), + ) + + await waitFor(() => { + expect( + (router.state.location.state as { slideId?: string }).slideId, + ).toBe('slide-2') + }) + + expect(getStylesheetLink()).toBe(initialLink) + expect( + Array.from(document.querySelectorAll('link[rel="stylesheet"]')).filter( + (link) => link.getAttribute('href') === '/main.css', + ), + ).toHaveLength(1) + } finally { + history.destroy() + } + }) + + test('keeps manifest stylesheet links mounted when navigating with Link', async () => { + const history = createBrowserHistory() + + try { + const rootRoute = createRootRoute({ + component: () => { + return ( + <> + + + + + + ) + }, + }) + + const indexRoute = createRoute({ + path: '/', + getParentRoute: () => rootRoute, + component: () => Go to about page, + }) + + const aboutRoute = createRoute({ + path: '/about', + getParentRoute: () => rootRoute, + component: () =>
About
, + }) + + const router = createRouter({ + history, + routeTree: rootRoute.addChildren([indexRoute, aboutRoute]), + }) + + router.ssr = { + manifest: { + routes: { + [rootRoute.id]: { + assets: [ + { + tag: 'link', + attrs: { + rel: 'stylesheet', + href: '/main.css', + }, + }, + ], + }, + }, + }, + } as any + + await router.load() + + render() + + const getStylesheetLink = () => + Array.from(document.querySelectorAll('link[rel="stylesheet"]')).find( + (link) => link.getAttribute('href') === '/main.css', + ) + + await waitFor(() => { + expect(getStylesheetLink()).toBeInstanceOf(HTMLLinkElement) + }) + + const initialLink = getStylesheetLink() + expect(initialLink).toBeInstanceOf(HTMLLinkElement) + + await fireEvent.click( + screen.getByRole('link', { name: 'Go to about page' }), + ) + + await waitFor(() => { + expect(router.state.location.pathname).toBe('/about') + }) + + expect(getStylesheetLink()).toBe(initialLink) + expect( + Array.from(document.querySelectorAll('link[rel="stylesheet"]')).filter( + (link) => link.getAttribute('href') === '/main.css', + ), + ).toHaveLength(1) + } finally { + history.destroy() + } + }) }) From 370eb615321bcf20e440657920ea5a76b415675a Mon Sep 17 00:00:00 2001 From: Sheraff Date: Fri, 20 Mar 2026 21:05:42 +0100 Subject: [PATCH 4/7] test(router): type head asset regression fixtures --- packages/react-router/tests/Scripts.test.tsx | 60 ++++++++------------ packages/solid-router/tests/Scripts.test.tsx | 60 ++++++++------------ packages/vue-router/tests/Scripts.test.tsx | 60 ++++++++------------ 3 files changed, 75 insertions(+), 105 deletions(-) diff --git a/packages/react-router/tests/Scripts.test.tsx b/packages/react-router/tests/Scripts.test.tsx index 285137996a4..356fdd224ed 100644 --- a/packages/react-router/tests/Scripts.test.tsx +++ b/packages/react-router/tests/Scripts.test.tsx @@ -22,6 +22,24 @@ import { createRouter, } from '../src' import { Scripts } from '../src/Scripts' +import type { Manifest } from '@tanstack/router-core' + +const createTestManifest = (routeId: string) => + ({ + routes: { + [routeId]: { + assets: [ + { + tag: 'link', + attrs: { + rel: 'stylesheet', + href: '/main.css', + }, + }, + ], + }, + }, + }) satisfies Manifest afterEach(() => { window.history.replaceState(null, 'root', '/') @@ -381,22 +399,8 @@ describe('ssr HeadContent', () => { }) router.ssr = { - manifest: { - routes: { - [rootRoute.id]: { - assets: [ - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/main.css', - }, - }, - ], - }, - }, - }, - } as any + manifest: createTestManifest(rootRoute.id), + } await router.load() @@ -417,9 +421,9 @@ describe('ssr HeadContent', () => { fireEvent.click(screen.getByRole('button', { name: 'Replace state' })) await waitFor(() => { - expect( - (router.state.location.state as { slideId?: string }).slideId, - ).toBe('slide-2') + expect(router.state.location.state).toMatchObject({ + slideId: 'slide-2', + }) }) expect(getStylesheetLink()).toBe(initialLink) @@ -466,22 +470,8 @@ describe('ssr HeadContent', () => { }) router.ssr = { - manifest: { - routes: { - [rootRoute.id]: { - assets: [ - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/main.css', - }, - }, - ], - }, - }, - }, - } as any + manifest: createTestManifest(rootRoute.id), + } await router.load() diff --git a/packages/solid-router/tests/Scripts.test.tsx b/packages/solid-router/tests/Scripts.test.tsx index ee7b6289430..a2f19ae8785 100644 --- a/packages/solid-router/tests/Scripts.test.tsx +++ b/packages/solid-router/tests/Scripts.test.tsx @@ -19,6 +19,24 @@ import { createRouter, } from '../src' import { Scripts } from '../src/Scripts' +import type { Manifest } from '@tanstack/router-core' + +const createTestManifest = (routeId: string) => + ({ + routes: { + [routeId]: { + assets: [ + { + tag: 'link', + attrs: { + rel: 'stylesheet', + href: '/main.css', + }, + }, + ], + }, + }, + }) satisfies Manifest afterEach(() => { window.history.replaceState(null, 'root', '/') @@ -151,22 +169,8 @@ describe('ssr scripts', () => { }) router.ssr = { - manifest: { - routes: { - [rootRoute.id]: { - assets: [ - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/main.css', - }, - }, - ], - }, - }, - }, - } as any + manifest: createTestManifest(rootRoute.id), + } await router.load() @@ -337,22 +341,8 @@ describe('ssr HeadContent', () => { }) router.ssr = { - manifest: { - routes: { - [rootRoute.id]: { - assets: [ - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/main.css', - }, - }, - ], - }, - }, - }, - } as any + manifest: createTestManifest(rootRoute.id), + } await router.load() @@ -373,9 +363,9 @@ describe('ssr HeadContent', () => { fireEvent.click(screen.getByRole('button', { name: 'Replace state' })) await waitFor(() => { - expect( - (router.state.location.state as { slideId?: string }).slideId, - ).toBe('slide-2') + expect(router.state.location.state).toMatchObject({ + slideId: 'slide-2', + }) }) expect(getStylesheetLink()).toBe(initialLink) diff --git a/packages/vue-router/tests/Scripts.test.tsx b/packages/vue-router/tests/Scripts.test.tsx index 755e6053a33..1cc7f822caa 100644 --- a/packages/vue-router/tests/Scripts.test.tsx +++ b/packages/vue-router/tests/Scripts.test.tsx @@ -20,6 +20,24 @@ import { createRouter, } from '../src' import { Scripts } from '../src/Scripts' +import type { Manifest } from '@tanstack/router-core' + +const createTestManifest = (routeId: string) => + ({ + routes: { + [routeId]: { + assets: [ + { + tag: 'link', + attrs: { + rel: 'stylesheet', + href: '/main.css', + }, + }, + ], + }, + }, + }) satisfies Manifest afterEach(() => { window.history.replaceState(null, 'root', '/') @@ -254,22 +272,8 @@ describe('ssr HeadContent', () => { }) router.ssr = { - manifest: { - routes: { - [rootRoute.id]: { - assets: [ - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/main.css', - }, - }, - ], - }, - }, - }, - } as any + manifest: createTestManifest(rootRoute.id), + } await router.load() @@ -292,9 +296,9 @@ describe('ssr HeadContent', () => { ) await waitFor(() => { - expect( - (router.state.location.state as { slideId?: string }).slideId, - ).toBe('slide-2') + expect(router.state.location.state).toMatchObject({ + slideId: 'slide-2', + }) }) expect(getStylesheetLink()).toBe(initialLink) @@ -343,22 +347,8 @@ describe('ssr HeadContent', () => { }) router.ssr = { - manifest: { - routes: { - [rootRoute.id]: { - assets: [ - { - tag: 'link', - attrs: { - rel: 'stylesheet', - href: '/main.css', - }, - }, - ], - }, - }, - }, - } as any + manifest: createTestManifest(rootRoute.id), + } await router.load() From 2b51687538e81ff501bf20661c56729edba66b7f Mon Sep 17 00:00:00 2001 From: Sheraff Date: Fri, 20 Mar 2026 21:33:17 +0100 Subject: [PATCH 5/7] test(solid-router): harden Link head asset regression --- packages/solid-router/tests/Scripts.test.tsx | 42 +++++++++++++++++--- 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/packages/solid-router/tests/Scripts.test.tsx b/packages/solid-router/tests/Scripts.test.tsx index a2f19ae8785..2aaf15dfc2f 100644 --- a/packages/solid-router/tests/Scripts.test.tsx +++ b/packages/solid-router/tests/Scripts.test.tsx @@ -136,8 +136,9 @@ describe('ssr scripts', () => { ) }) - test('keeps manifest stylesheet links mounted when navigating with Link', async () => { + test('keeps manifest stylesheet links mounted across repeated Link navigations', async () => { const history = createBrowserHistory() + let observer: MutationObserver | undefined try { const rootRoute = createRootRoute({ @@ -160,7 +161,7 @@ describe('ssr scripts', () => { const aboutRoute = createRoute({ path: '/about', getParentRoute: () => rootRoute, - component: () =>
About
, + component: () => Back to home, }) const router = createRouter({ @@ -188,19 +189,48 @@ describe('ssr scripts', () => { const initialLink = getStylesheetLink() expect(initialLink).toBeInstanceOf(HTMLLinkElement) - fireEvent.click(screen.getByRole('link', { name: 'Go to about page' })) - - await waitFor(() => { - expect(router.state.location.pathname).toBe('/about') + const removedStylesheetLinks: Array = [] + observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + mutation.removedNodes.forEach((node) => { + if ( + node instanceof HTMLLinkElement && + node.getAttribute('href') === '/main.css' + ) { + removedStylesheetLinks.push(node) + } + }) + }) }) + observer.observe(document.head, { childList: true }) + + for (let i = 0; i < 5; i++) { + fireEvent.click(screen.getByRole('link', { name: 'Go to about page' })) + + await waitFor(() => { + expect(router.state.location.pathname).toBe('/about') + }) + + await screen.findByRole('link', { name: 'Back to home' }) + + fireEvent.click(screen.getByRole('link', { name: 'Back to home' })) + + await waitFor(() => { + expect(router.state.location.pathname).toBe('/') + }) + + await screen.findByRole('link', { name: 'Go to about page' }) + } expect(getStylesheetLink()).toBe(initialLink) + expect(removedStylesheetLinks).toHaveLength(0) expect( Array.from( document.head.querySelectorAll('link[rel="stylesheet"]'), ).filter((link) => link.getAttribute('href') === '/main.css'), ).toHaveLength(1) } finally { + observer?.disconnect() history.destroy() document.head .querySelectorAll('link[rel="stylesheet"]') From fc16cf39e9945ee5557b47cf79ce8c0fffec3cb2 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Fri, 20 Mar 2026 21:40:15 +0100 Subject: [PATCH 6/7] test(solid-router): simplify Link regression assertions --- packages/solid-router/tests/Scripts.test.tsx | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/packages/solid-router/tests/Scripts.test.tsx b/packages/solid-router/tests/Scripts.test.tsx index 2aaf15dfc2f..b4db5f55c04 100644 --- a/packages/solid-router/tests/Scripts.test.tsx +++ b/packages/solid-router/tests/Scripts.test.tsx @@ -138,8 +138,6 @@ describe('ssr scripts', () => { test('keeps manifest stylesheet links mounted across repeated Link navigations', async () => { const history = createBrowserHistory() - let observer: MutationObserver | undefined - try { const rootRoute = createRootRoute({ component: () => { @@ -189,21 +187,6 @@ describe('ssr scripts', () => { const initialLink = getStylesheetLink() expect(initialLink).toBeInstanceOf(HTMLLinkElement) - const removedStylesheetLinks: Array = [] - observer = new MutationObserver((mutations) => { - mutations.forEach((mutation) => { - mutation.removedNodes.forEach((node) => { - if ( - node instanceof HTMLLinkElement && - node.getAttribute('href') === '/main.css' - ) { - removedStylesheetLinks.push(node) - } - }) - }) - }) - observer.observe(document.head, { childList: true }) - for (let i = 0; i < 5; i++) { fireEvent.click(screen.getByRole('link', { name: 'Go to about page' })) @@ -223,14 +206,12 @@ describe('ssr scripts', () => { } expect(getStylesheetLink()).toBe(initialLink) - expect(removedStylesheetLinks).toHaveLength(0) expect( Array.from( document.head.querySelectorAll('link[rel="stylesheet"]'), ).filter((link) => link.getAttribute('href') === '/main.css'), ).toHaveLength(1) } finally { - observer?.disconnect() history.destroy() document.head .querySelectorAll('link[rel="stylesheet"]') From 0e262ffc0f6a70c7507ecb9038d9d8fc332e8bbe Mon Sep 17 00:00:00 2001 From: Sheraff Date: Fri, 20 Mar 2026 22:14:57 +0100 Subject: [PATCH 7/7] test(router): centralize browser history cleanup --- packages/react-router/tests/Scripts.test.tsx | 237 ++++++++-------- packages/solid-router/tests/Scripts.test.tsx | 270 +++++++++---------- packages/vue-router/tests/Scripts.test.tsx | 247 +++++++++-------- 3 files changed, 383 insertions(+), 371 deletions(-) diff --git a/packages/react-router/tests/Scripts.test.tsx b/packages/react-router/tests/Scripts.test.tsx index 356fdd224ed..7db908644bc 100644 --- a/packages/react-router/tests/Scripts.test.tsx +++ b/packages/react-router/tests/Scripts.test.tsx @@ -41,9 +41,18 @@ const createTestManifest = (routeId: string) => }, }) satisfies Manifest +const browserHistories: Array> = [] + +const createTestBrowserHistory = () => { + const history = createBrowserHistory() + browserHistories.push(history) + return history +} + afterEach(() => { - window.history.replaceState(null, 'root', '/') cleanup() + browserHistories.splice(0).forEach((history) => history.destroy()) + window.history.replaceState(null, 'root', '/') }) describe('ssr scripts', () => { @@ -362,148 +371,152 @@ describe('ssr HeadContent', () => { }) test('keeps manifest stylesheet links mounted when history state changes', async () => { - const history = createBrowserHistory() - - try { - const rootRoute = createRootRoute({ - component: () => { - return ( - <> - {createPortal(, document.head)} - - - - ) - }, - }) + const history = createTestBrowserHistory() - const indexRoute = createRoute({ - path: '/', - getParentRoute: () => rootRoute, - component: () =>
Index
, - }) + const rootRoute = createRootRoute({ + component: () => { + return ( + <> + {createPortal(, document.head)} + + + + ) + }, + }) - const router = createRouter({ - history, - routeTree: rootRoute.addChildren([indexRoute]), - }) + const indexRoute = createRoute({ + path: '/', + getParentRoute: () => rootRoute, + component: () =>
Index
, + }) - router.ssr = { - manifest: createTestManifest(rootRoute.id), - } + const router = createRouter({ + history, + routeTree: rootRoute.addChildren([indexRoute]), + }) - await router.load() + router.ssr = { + manifest: createTestManifest(rootRoute.id), + } - await act(() => render()) + await router.load() - const getStylesheetLink = () => - Array.from(document.querySelectorAll('link[rel="stylesheet"]')).find( - (link) => link.getAttribute('href') === '/main.css', - ) + await act(() => render()) - await waitFor(() => { - expect(getStylesheetLink()).toBeInstanceOf(HTMLLinkElement) - }) + const getStylesheetLink = () => + Array.from(document.querySelectorAll('link[rel="stylesheet"]')).find( + (link) => link.getAttribute('href') === '/main.css', + ) - const initialLink = getStylesheetLink() - expect(initialLink).toBeInstanceOf(HTMLLinkElement) + await waitFor(() => { + expect(getStylesheetLink()).toBeInstanceOf(HTMLLinkElement) + }) - fireEvent.click(screen.getByRole('button', { name: 'Replace state' })) + const initialLink = getStylesheetLink() + expect(initialLink).toBeInstanceOf(HTMLLinkElement) - await waitFor(() => { - expect(router.state.location.state).toMatchObject({ - slideId: 'slide-2', - }) + fireEvent.click(screen.getByRole('button', { name: 'Replace state' })) + + await waitFor(() => { + expect(router.state.location.state).toMatchObject({ + slideId: 'slide-2', }) + }) - expect(getStylesheetLink()).toBe(initialLink) - expect( - Array.from(document.querySelectorAll('link[rel="stylesheet"]')).filter( - (link) => link.getAttribute('href') === '/main.css', - ), - ).toHaveLength(1) - } finally { - history.destroy() - } + expect(getStylesheetLink()).toBe(initialLink) + expect( + Array.from(document.querySelectorAll('link[rel="stylesheet"]')).filter( + (link) => link.getAttribute('href') === '/main.css', + ), + ).toHaveLength(1) }) - test('keeps manifest stylesheet links mounted when navigating with Link', async () => { - const history = createBrowserHistory() - - try { - const rootRoute = createRootRoute({ - component: () => { - return ( - <> - {createPortal(, document.head)} - - - ) - }, - }) + test('keeps manifest stylesheet links mounted across repeated Link navigations', async () => { + const history = createTestBrowserHistory() - const indexRoute = createRoute({ - path: '/', - getParentRoute: () => rootRoute, - component: () => Go to about page, - }) + const rootRoute = createRootRoute({ + component: () => { + return ( + <> + {createPortal(, document.head)} + + + ) + }, + }) - const aboutRoute = createRoute({ - path: '/about', - getParentRoute: () => rootRoute, - component: () =>
About
, - }) + const indexRoute = createRoute({ + path: '/', + getParentRoute: () => rootRoute, + component: () => Go to about page, + }) - const router = createRouter({ - history, - routeTree: rootRoute.addChildren([indexRoute, aboutRoute]), - }) + const aboutRoute = createRoute({ + path: '/about', + getParentRoute: () => rootRoute, + component: () => Back to home, + }) - router.ssr = { - manifest: createTestManifest(rootRoute.id), - } + const router = createRouter({ + history, + routeTree: rootRoute.addChildren([indexRoute, aboutRoute]), + }) - await router.load() + router.ssr = { + manifest: createTestManifest(rootRoute.id), + } - await act(() => render()) + await router.load() - const getStylesheetLink = () => - Array.from(document.querySelectorAll('link[rel="stylesheet"]')).find( - (link) => link.getAttribute('href') === '/main.css', - ) + await act(() => render()) - await waitFor(() => { - expect(getStylesheetLink()).toBeInstanceOf(HTMLLinkElement) - }) + const getStylesheetLink = () => + Array.from(document.querySelectorAll('link[rel="stylesheet"]')).find( + (link) => link.getAttribute('href') === '/main.css', + ) - const initialLink = getStylesheetLink() - expect(initialLink).toBeInstanceOf(HTMLLinkElement) + await waitFor(() => { + expect(getStylesheetLink()).toBeInstanceOf(HTMLLinkElement) + }) + + const initialLink = getStylesheetLink() + expect(initialLink).toBeInstanceOf(HTMLLinkElement) + for (let i = 0; i < 5; i++) { fireEvent.click(screen.getByRole('link', { name: 'Go to about page' })) await waitFor(() => { expect(router.state.location.pathname).toBe('/about') }) - expect(getStylesheetLink()).toBe(initialLink) - expect( - Array.from(document.querySelectorAll('link[rel="stylesheet"]')).filter( - (link) => link.getAttribute('href') === '/main.css', - ), - ).toHaveLength(1) - } finally { - history.destroy() + await screen.findByRole('link', { name: 'Back to home' }) + + fireEvent.click(screen.getByRole('link', { name: 'Back to home' })) + + await waitFor(() => { + expect(router.state.location.pathname).toBe('/') + }) + + await screen.findByRole('link', { name: 'Go to about page' }) } + + expect(getStylesheetLink()).toBe(initialLink) + expect( + Array.from(document.querySelectorAll('link[rel="stylesheet"]')).filter( + (link) => link.getAttribute('href') === '/main.css', + ), + ).toHaveLength(1) }) }) diff --git a/packages/solid-router/tests/Scripts.test.tsx b/packages/solid-router/tests/Scripts.test.tsx index b4db5f55c04..d45137a8a3d 100644 --- a/packages/solid-router/tests/Scripts.test.tsx +++ b/packages/solid-router/tests/Scripts.test.tsx @@ -38,9 +38,18 @@ const createTestManifest = (routeId: string) => }, }) satisfies Manifest +const browserHistories: Array> = [] + +const createTestBrowserHistory = () => { + const history = createBrowserHistory() + browserHistories.push(history) + return history +} + afterEach(() => { - window.history.replaceState(null, 'root', '/') cleanup() + browserHistories.splice(0).forEach((history) => history.destroy()) + window.history.replaceState(null, 'root', '/') }) describe('ssr scripts', () => { @@ -137,90 +146,80 @@ describe('ssr scripts', () => { }) test('keeps manifest stylesheet links mounted across repeated Link navigations', async () => { - const history = createBrowserHistory() - try { - const rootRoute = createRootRoute({ - component: () => { - return ( - <> - - - - ) - }, - }) + const history = createTestBrowserHistory() - const indexRoute = createRoute({ - path: '/', - getParentRoute: () => rootRoute, - component: () => Go to about page, - }) + const rootRoute = createRootRoute({ + component: () => { + return ( + <> + + + + ) + }, + }) - const aboutRoute = createRoute({ - path: '/about', - getParentRoute: () => rootRoute, - component: () => Back to home, - }) + const indexRoute = createRoute({ + path: '/', + getParentRoute: () => rootRoute, + component: () => Go to about page, + }) - const router = createRouter({ - history, - routeTree: rootRoute.addChildren([indexRoute, aboutRoute]), - }) + const aboutRoute = createRoute({ + path: '/about', + getParentRoute: () => rootRoute, + component: () => Back to home, + }) + + const router = createRouter({ + history, + routeTree: rootRoute.addChildren([indexRoute, aboutRoute]), + }) + + router.ssr = { + manifest: createTestManifest(rootRoute.id), + } + + await router.load() + + render(() => ) + + const getStylesheetLink = () => + Array.from(document.head.querySelectorAll('link[rel="stylesheet"]')).find( + (link) => link.getAttribute('href') === '/main.css', + ) + + await waitFor(() => { + expect(getStylesheetLink()).toBeInstanceOf(HTMLLinkElement) + }) + + const initialLink = getStylesheetLink() + expect(initialLink).toBeInstanceOf(HTMLLinkElement) - router.ssr = { - manifest: createTestManifest(rootRoute.id), - } + for (let i = 0; i < 5; i++) { + fireEvent.click(screen.getByRole('link', { name: 'Go to about page' })) - await router.load() + await waitFor(() => { + expect(router.state.location.pathname).toBe('/about') + }) - render(() => ) + await screen.findByRole('link', { name: 'Back to home' }) - const getStylesheetLink = () => - Array.from( - document.head.querySelectorAll('link[rel="stylesheet"]'), - ).find((link) => link.getAttribute('href') === '/main.css') + fireEvent.click(screen.getByRole('link', { name: 'Back to home' })) await waitFor(() => { - expect(getStylesheetLink()).toBeInstanceOf(HTMLLinkElement) + expect(router.state.location.pathname).toBe('/') }) - const initialLink = getStylesheetLink() - expect(initialLink).toBeInstanceOf(HTMLLinkElement) - - for (let i = 0; i < 5; i++) { - fireEvent.click(screen.getByRole('link', { name: 'Go to about page' })) - - await waitFor(() => { - expect(router.state.location.pathname).toBe('/about') - }) - - await screen.findByRole('link', { name: 'Back to home' }) - - fireEvent.click(screen.getByRole('link', { name: 'Back to home' })) - - await waitFor(() => { - expect(router.state.location.pathname).toBe('/') - }) - - await screen.findByRole('link', { name: 'Go to about page' }) - } - - expect(getStylesheetLink()).toBe(initialLink) - expect( - Array.from( - document.head.querySelectorAll('link[rel="stylesheet"]'), - ).filter((link) => link.getAttribute('href') === '/main.css'), - ).toHaveLength(1) - } finally { - history.destroy() - document.head - .querySelectorAll('link[rel="stylesheet"]') - .forEach((link) => { - if (link.getAttribute('href') === '/main.css') { - link.remove() - } - }) + await screen.findByRole('link', { name: 'Go to about page' }) } + + expect(getStylesheetLink()).toBe(initialLink) + expect( + Array.from( + document.head.querySelectorAll('link[rel="stylesheet"]'), + ).filter((link) => link.getAttribute('href') === '/main.css'), + ).toHaveLength(1) }) }) @@ -315,85 +314,74 @@ describe('ssr HeadContent', () => { }) test('keeps manifest stylesheet links mounted when history state changes', async () => { - const history = createBrowserHistory() - - try { - const rootRoute = createRootRoute({ - component: () => { - return ( - <> - - - - - ) - }, - }) + const history = createTestBrowserHistory() - const indexRoute = createRoute({ - path: '/', - getParentRoute: () => rootRoute, - component: () =>
Index
, - }) + const rootRoute = createRootRoute({ + component: () => { + return ( + <> + + + + + ) + }, + }) - const router = createRouter({ - history, - routeTree: rootRoute.addChildren([indexRoute]), - }) + const indexRoute = createRoute({ + path: '/', + getParentRoute: () => rootRoute, + component: () =>
Index
, + }) - router.ssr = { - manifest: createTestManifest(rootRoute.id), - } + const router = createRouter({ + history, + routeTree: rootRoute.addChildren([indexRoute]), + }) + + router.ssr = { + manifest: createTestManifest(rootRoute.id), + } - await router.load() + await router.load() - render(() => ) + render(() => ) - const getStylesheetLink = () => - Array.from( - document.head.querySelectorAll('link[rel="stylesheet"]'), - ).find((link) => link.getAttribute('href') === '/main.css') + const getStylesheetLink = () => + Array.from(document.head.querySelectorAll('link[rel="stylesheet"]')).find( + (link) => link.getAttribute('href') === '/main.css', + ) - await waitFor(() => { - expect(getStylesheetLink()).toBeInstanceOf(HTMLLinkElement) - }) + await waitFor(() => { + expect(getStylesheetLink()).toBeInstanceOf(HTMLLinkElement) + }) - const initialLink = getStylesheetLink() - expect(initialLink).toBeInstanceOf(HTMLLinkElement) + const initialLink = getStylesheetLink() + expect(initialLink).toBeInstanceOf(HTMLLinkElement) - fireEvent.click(screen.getByRole('button', { name: 'Replace state' })) + fireEvent.click(screen.getByRole('button', { name: 'Replace state' })) - await waitFor(() => { - expect(router.state.location.state).toMatchObject({ - slideId: 'slide-2', - }) + await waitFor(() => { + expect(router.state.location.state).toMatchObject({ + slideId: 'slide-2', }) + }) - expect(getStylesheetLink()).toBe(initialLink) - expect( - Array.from( - document.head.querySelectorAll('link[rel="stylesheet"]'), - ).filter((link) => link.getAttribute('href') === '/main.css'), - ).toHaveLength(1) - } finally { - history.destroy() - document.head - .querySelectorAll('link[rel="stylesheet"]') - .forEach((link) => { - if (link.getAttribute('href') === '/main.css') { - link.remove() - } - }) - } + expect(getStylesheetLink()).toBe(initialLink) + expect( + Array.from( + document.head.querySelectorAll('link[rel="stylesheet"]'), + ).filter((link) => link.getAttribute('href') === '/main.css'), + ).toHaveLength(1) }) }) diff --git a/packages/vue-router/tests/Scripts.test.tsx b/packages/vue-router/tests/Scripts.test.tsx index 1cc7f822caa..445804d527e 100644 --- a/packages/vue-router/tests/Scripts.test.tsx +++ b/packages/vue-router/tests/Scripts.test.tsx @@ -39,9 +39,18 @@ const createTestManifest = (routeId: string) => }, }) satisfies Manifest +const browserHistories: Array> = [] + +const createTestBrowserHistory = () => { + const history = createBrowserHistory() + browserHistories.push(history) + return history +} + afterEach(() => { - window.history.replaceState(null, 'root', '/') cleanup() + browserHistories.splice(0).forEach((history) => history.destroy()) + window.history.replaceState(null, 'root', '/') }) describe('ssr scripts', () => { @@ -233,139 +242,133 @@ describe('ssr HeadContent', () => { }) test('keeps manifest stylesheet links mounted when history state changes', async () => { - const history = createBrowserHistory() - - try { - const rootRoute = createRootRoute({ - component: () => { - return ( - <> - - - - - - - ) - }, - }) + const history = createTestBrowserHistory() - const indexRoute = createRoute({ - path: '/', - getParentRoute: () => rootRoute, - component: () =>
Index
, - }) + const rootRoute = createRootRoute({ + component: () => { + return ( + <> + + + + + + + ) + }, + }) - const router = createRouter({ - history, - routeTree: rootRoute.addChildren([indexRoute]), - }) + const indexRoute = createRoute({ + path: '/', + getParentRoute: () => rootRoute, + component: () =>
Index
, + }) - router.ssr = { - manifest: createTestManifest(rootRoute.id), - } + const router = createRouter({ + history, + routeTree: rootRoute.addChildren([indexRoute]), + }) - await router.load() + router.ssr = { + manifest: createTestManifest(rootRoute.id), + } - render() + await router.load() - const getStylesheetLink = () => - Array.from(document.querySelectorAll('link[rel="stylesheet"]')).find( - (link) => link.getAttribute('href') === '/main.css', - ) + render() - await waitFor(() => { - expect(getStylesheetLink()).toBeInstanceOf(HTMLLinkElement) - }) + const getStylesheetLink = () => + Array.from(document.querySelectorAll('link[rel="stylesheet"]')).find( + (link) => link.getAttribute('href') === '/main.css', + ) - const initialLink = getStylesheetLink() - expect(initialLink).toBeInstanceOf(HTMLLinkElement) + await waitFor(() => { + expect(getStylesheetLink()).toBeInstanceOf(HTMLLinkElement) + }) - await fireEvent.click( - screen.getByRole('button', { name: 'Replace state' }), - ) + const initialLink = getStylesheetLink() + expect(initialLink).toBeInstanceOf(HTMLLinkElement) - await waitFor(() => { - expect(router.state.location.state).toMatchObject({ - slideId: 'slide-2', - }) + await fireEvent.click(screen.getByRole('button', { name: 'Replace state' })) + + await waitFor(() => { + expect(router.state.location.state).toMatchObject({ + slideId: 'slide-2', }) + }) - expect(getStylesheetLink()).toBe(initialLink) - expect( - Array.from(document.querySelectorAll('link[rel="stylesheet"]')).filter( - (link) => link.getAttribute('href') === '/main.css', - ), - ).toHaveLength(1) - } finally { - history.destroy() - } + expect(getStylesheetLink()).toBe(initialLink) + expect( + Array.from(document.querySelectorAll('link[rel="stylesheet"]')).filter( + (link) => link.getAttribute('href') === '/main.css', + ), + ).toHaveLength(1) }) - test('keeps manifest stylesheet links mounted when navigating with Link', async () => { - const history = createBrowserHistory() - - try { - const rootRoute = createRootRoute({ - component: () => { - return ( - <> - - - - - - ) - }, - }) + test('keeps manifest stylesheet links mounted across repeated Link navigations', async () => { + const history = createTestBrowserHistory() - const indexRoute = createRoute({ - path: '/', - getParentRoute: () => rootRoute, - component: () => Go to about page, - }) + const rootRoute = createRootRoute({ + component: () => { + return ( + <> + + + + + + ) + }, + }) - const aboutRoute = createRoute({ - path: '/about', - getParentRoute: () => rootRoute, - component: () =>
About
, - }) + const indexRoute = createRoute({ + path: '/', + getParentRoute: () => rootRoute, + component: () => Go to about page, + }) - const router = createRouter({ - history, - routeTree: rootRoute.addChildren([indexRoute, aboutRoute]), - }) + const aboutRoute = createRoute({ + path: '/about', + getParentRoute: () => rootRoute, + component: () => Back to home, + }) - router.ssr = { - manifest: createTestManifest(rootRoute.id), - } + const router = createRouter({ + history, + routeTree: rootRoute.addChildren([indexRoute, aboutRoute]), + }) - await router.load() + router.ssr = { + manifest: createTestManifest(rootRoute.id), + } - render() + await router.load() - const getStylesheetLink = () => - Array.from(document.querySelectorAll('link[rel="stylesheet"]')).find( - (link) => link.getAttribute('href') === '/main.css', - ) + render() - await waitFor(() => { - expect(getStylesheetLink()).toBeInstanceOf(HTMLLinkElement) - }) + const getStylesheetLink = () => + Array.from(document.querySelectorAll('link[rel="stylesheet"]')).find( + (link) => link.getAttribute('href') === '/main.css', + ) - const initialLink = getStylesheetLink() - expect(initialLink).toBeInstanceOf(HTMLLinkElement) + await waitFor(() => { + expect(getStylesheetLink()).toBeInstanceOf(HTMLLinkElement) + }) + const initialLink = getStylesheetLink() + expect(initialLink).toBeInstanceOf(HTMLLinkElement) + + for (let i = 0; i < 5; i++) { await fireEvent.click( screen.getByRole('link', { name: 'Go to about page' }), ) @@ -374,14 +377,22 @@ describe('ssr HeadContent', () => { expect(router.state.location.pathname).toBe('/about') }) - expect(getStylesheetLink()).toBe(initialLink) - expect( - Array.from(document.querySelectorAll('link[rel="stylesheet"]')).filter( - (link) => link.getAttribute('href') === '/main.css', - ), - ).toHaveLength(1) - } finally { - history.destroy() + await screen.findByRole('link', { name: 'Back to home' }) + + await fireEvent.click(screen.getByRole('link', { name: 'Back to home' })) + + await waitFor(() => { + expect(router.state.location.pathname).toBe('/') + }) + + await screen.findByRole('link', { name: 'Go to about page' }) } + + expect(getStylesheetLink()).toBe(initialLink) + expect( + Array.from(document.querySelectorAll('link[rel="stylesheet"]')).filter( + (link) => link.getAttribute('href') === '/main.css', + ), + ).toHaveLength(1) }) })