diff --git a/packages/react-router/tests/Scripts.test.tsx b/packages/react-router/tests/Scripts.test.tsx index a3f31a3a8af..7db908644bc 100644 --- a/packages/react-router/tests/Scripts.test.tsx +++ b/packages/react-router/tests/Scripts.test.tsx @@ -1,17 +1,59 @@ -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, 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 + +const browserHistories: Array> = [] + +const createTestBrowserHistory = () => { + const history = createBrowserHistory() + browserHistories.push(history) + return history +} + +afterEach(() => { + cleanup() + browserHistories.splice(0).forEach((history) => history.destroy()) + window.history.replaceState(null, 'root', '/') +}) describe('ssr scripts', () => { test('it works', async () => { @@ -327,6 +369,155 @@ describe('ssr HeadContent', () => { `Index`, ) }) + + test('keeps manifest stylesheet links mounted when history state changes', async () => { + const history = createTestBrowserHistory() + + 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: createTestManifest(rootRoute.id), + } + + 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).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) + }) + + test('keeps manifest stylesheet links mounted across repeated Link navigations', async () => { + const history = createTestBrowserHistory() + + 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: () => Back to home, + }) + + const router = createRouter({ + history, + routeTree: rootRoute.addChildren([indexRoute, aboutRoute]), + }) + + router.ssr = { + manifest: createTestManifest(rootRoute.id), + } + + 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) + + 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.querySelectorAll('link[rel="stylesheet"]')).filter( + (link) => link.getAttribute('href') === '/main.css', + ), + ).toHaveLength(1) + }) }) describe('data script rendering', () => { 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..d45137a8a3d 100644 --- a/packages/solid-router/tests/Scripts.test.tsx +++ b/packages/solid-router/tests/Scripts.test.tsx @@ -1,15 +1,56 @@ -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, + Link, + Outlet, RouterProvider, + createBrowserHistory, createMemoryHistory, createRootRoute, createRoute, 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 + +const browserHistories: Array> = [] + +const createTestBrowserHistory = () => { + const history = createBrowserHistory() + browserHistories.push(history) + return history +} + +afterEach(() => { + cleanup() + browserHistories.splice(0).forEach((history) => history.destroy()) + window.history.replaceState(null, 'root', '/') +}) describe('ssr scripts', () => { test('it works', async () => { @@ -103,6 +144,83 @@ describe('ssr scripts', () => { '', ) }) + + test('keeps manifest stylesheet links mounted across repeated Link navigations', async () => { + const history = createTestBrowserHistory() + + const rootRoute = createRootRoute({ + component: () => { + return ( + <> + + + + ) + }, + }) + + const indexRoute = createRoute({ + path: '/', + getParentRoute: () => rootRoute, + component: () => Go to about page, + }) + + 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) + + 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) + }) }) describe('ssr HeadContent', () => { @@ -194,4 +312,76 @@ describe('ssr HeadContent', () => { { property: 'og:image', content: 'index-image.jpg' }, ]) }) + + test('keeps manifest stylesheet links mounted when history state changes', async () => { + const history = createTestBrowserHistory() + + const rootRoute = createRootRoute({ + component: () => { + return ( + <> + + + + + ) + }, + }) + + const indexRoute = createRoute({ + path: '/', + getParentRoute: () => rootRoute, + component: () =>
Index
, + }) + + const router = createRouter({ + history, + routeTree: rootRoute.addChildren([indexRoute]), + }) + + 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) + + 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.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 577795a9db7..445804d527e 100644 --- a/packages/vue-router/tests/Scripts.test.tsx +++ b/packages/vue-router/tests/Scripts.test.tsx @@ -1,15 +1,57 @@ -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, 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 + +const browserHistories: Array> = [] + +const createTestBrowserHistory = () => { + const history = createBrowserHistory() + browserHistories.push(history) + return history +} + +afterEach(() => { + cleanup() + browserHistories.splice(0).forEach((history) => history.destroy()) + window.history.replaceState(null, 'root', '/') +}) describe('ssr scripts', () => { test('it works', async () => { @@ -198,4 +240,159 @@ describe('ssr HeadContent', () => { { property: 'og:image', content: 'index-image.jpg' }, ]) }) + + test('keeps manifest stylesheet links mounted when history state changes', async () => { + const history = createTestBrowserHistory() + + const rootRoute = createRootRoute({ + component: () => { + return ( + <> + + + + + + + ) + }, + }) + + const indexRoute = createRoute({ + path: '/', + getParentRoute: () => rootRoute, + component: () =>
Index
, + }) + + const router = createRouter({ + history, + routeTree: rootRoute.addChildren([indexRoute]), + }) + + router.ssr = { + manifest: createTestManifest(rootRoute.id), + } + + 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).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) + }) + + test('keeps manifest stylesheet links mounted across repeated Link navigations', async () => { + const history = createTestBrowserHistory() + + const rootRoute = createRootRoute({ + component: () => { + return ( + <> + + + + + + ) + }, + }) + + const indexRoute = createRoute({ + path: '/', + getParentRoute: () => rootRoute, + component: () => Go to about page, + }) + + 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.querySelectorAll('link[rel="stylesheet"]')).find( + (link) => link.getAttribute('href') === '/main.css', + ) + + 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' }), + ) + + await waitFor(() => { + expect(router.state.location.pathname).toBe('/about') + }) + + 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) + }) })