Skip to content

Commit 0d11d5e

Browse files
authored
fix(solid-router): avoid HeadContent remounts on history.replaceState (#6998)
1 parent e710407 commit 0d11d5e

4 files changed

Lines changed: 592 additions & 9 deletions

File tree

packages/react-router/tests/Scripts.test.tsx

Lines changed: 193 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,59 @@
1-
import { describe, expect, test } from 'vitest'
2-
import { act, render, screen } from '@testing-library/react'
1+
import { afterEach, describe, expect, test } from 'vitest'
2+
import {
3+
act,
4+
cleanup,
5+
fireEvent,
6+
render,
7+
screen,
8+
waitFor,
9+
} from '@testing-library/react'
10+
import { createPortal } from 'react-dom'
311
import ReactDOMServer from 'react-dom/server'
412

513
import {
614
HeadContent,
15+
Link,
716
Outlet,
817
RouterProvider,
18+
createBrowserHistory,
919
createMemoryHistory,
1020
createRootRoute,
1121
createRoute,
1222
createRouter,
1323
} from '../src'
1424
import { Scripts } from '../src/Scripts'
25+
import type { Manifest } from '@tanstack/router-core'
26+
27+
const createTestManifest = (routeId: string) =>
28+
({
29+
routes: {
30+
[routeId]: {
31+
assets: [
32+
{
33+
tag: 'link',
34+
attrs: {
35+
rel: 'stylesheet',
36+
href: '/main.css',
37+
},
38+
},
39+
],
40+
},
41+
},
42+
}) satisfies Manifest
43+
44+
const browserHistories: Array<ReturnType<typeof createBrowserHistory>> = []
45+
46+
const createTestBrowserHistory = () => {
47+
const history = createBrowserHistory()
48+
browserHistories.push(history)
49+
return history
50+
}
51+
52+
afterEach(() => {
53+
cleanup()
54+
browserHistories.splice(0).forEach((history) => history.destroy())
55+
window.history.replaceState(null, 'root', '/')
56+
})
1557

1658
describe('ssr scripts', () => {
1759
test('it works', async () => {
@@ -327,6 +369,155 @@ describe('ssr HeadContent', () => {
327369
`<title>Index</title><meta name="image" content="image.jpg"/><meta property="og:description" content="Root description"/><meta name="description" content="Index"/><meta name="last-modified" content="2021-10-10"/><meta property="og:image" content="index-image.jpg"/>`,
328370
)
329371
})
372+
373+
test('keeps manifest stylesheet links mounted when history state changes', async () => {
374+
const history = createTestBrowserHistory()
375+
376+
const rootRoute = createRootRoute({
377+
component: () => {
378+
return (
379+
<>
380+
{createPortal(<HeadContent />, document.head)}
381+
<button
382+
onClick={() => {
383+
window.history.replaceState(
384+
{ slideId: 'slide-2' },
385+
'',
386+
window.location.href,
387+
)
388+
}}
389+
>
390+
Replace state
391+
</button>
392+
<Outlet />
393+
</>
394+
)
395+
},
396+
})
397+
398+
const indexRoute = createRoute({
399+
path: '/',
400+
getParentRoute: () => rootRoute,
401+
component: () => <div>Index</div>,
402+
})
403+
404+
const router = createRouter({
405+
history,
406+
routeTree: rootRoute.addChildren([indexRoute]),
407+
})
408+
409+
router.ssr = {
410+
manifest: createTestManifest(rootRoute.id),
411+
}
412+
413+
await router.load()
414+
415+
await act(() => render(<RouterProvider router={router} />))
416+
417+
const getStylesheetLink = () =>
418+
Array.from(document.querySelectorAll('link[rel="stylesheet"]')).find(
419+
(link) => link.getAttribute('href') === '/main.css',
420+
)
421+
422+
await waitFor(() => {
423+
expect(getStylesheetLink()).toBeInstanceOf(HTMLLinkElement)
424+
})
425+
426+
const initialLink = getStylesheetLink()
427+
expect(initialLink).toBeInstanceOf(HTMLLinkElement)
428+
429+
fireEvent.click(screen.getByRole('button', { name: 'Replace state' }))
430+
431+
await waitFor(() => {
432+
expect(router.state.location.state).toMatchObject({
433+
slideId: 'slide-2',
434+
})
435+
})
436+
437+
expect(getStylesheetLink()).toBe(initialLink)
438+
expect(
439+
Array.from(document.querySelectorAll('link[rel="stylesheet"]')).filter(
440+
(link) => link.getAttribute('href') === '/main.css',
441+
),
442+
).toHaveLength(1)
443+
})
444+
445+
test('keeps manifest stylesheet links mounted across repeated Link navigations', async () => {
446+
const history = createTestBrowserHistory()
447+
448+
const rootRoute = createRootRoute({
449+
component: () => {
450+
return (
451+
<>
452+
{createPortal(<HeadContent />, document.head)}
453+
<Outlet />
454+
</>
455+
)
456+
},
457+
})
458+
459+
const indexRoute = createRoute({
460+
path: '/',
461+
getParentRoute: () => rootRoute,
462+
component: () => <Link to="/about">Go to about page</Link>,
463+
})
464+
465+
const aboutRoute = createRoute({
466+
path: '/about',
467+
getParentRoute: () => rootRoute,
468+
component: () => <Link to="/">Back to home</Link>,
469+
})
470+
471+
const router = createRouter({
472+
history,
473+
routeTree: rootRoute.addChildren([indexRoute, aboutRoute]),
474+
})
475+
476+
router.ssr = {
477+
manifest: createTestManifest(rootRoute.id),
478+
}
479+
480+
await router.load()
481+
482+
await act(() => render(<RouterProvider router={router} />))
483+
484+
const getStylesheetLink = () =>
485+
Array.from(document.querySelectorAll('link[rel="stylesheet"]')).find(
486+
(link) => link.getAttribute('href') === '/main.css',
487+
)
488+
489+
await waitFor(() => {
490+
expect(getStylesheetLink()).toBeInstanceOf(HTMLLinkElement)
491+
})
492+
493+
const initialLink = getStylesheetLink()
494+
expect(initialLink).toBeInstanceOf(HTMLLinkElement)
495+
496+
for (let i = 0; i < 5; i++) {
497+
fireEvent.click(screen.getByRole('link', { name: 'Go to about page' }))
498+
499+
await waitFor(() => {
500+
expect(router.state.location.pathname).toBe('/about')
501+
})
502+
503+
await screen.findByRole('link', { name: 'Back to home' })
504+
505+
fireEvent.click(screen.getByRole('link', { name: 'Back to home' }))
506+
507+
await waitFor(() => {
508+
expect(router.state.location.pathname).toBe('/')
509+
})
510+
511+
await screen.findByRole('link', { name: 'Go to about page' })
512+
}
513+
514+
expect(getStylesheetLink()).toBe(initialLink)
515+
expect(
516+
Array.from(document.querySelectorAll('link[rel="stylesheet"]')).filter(
517+
(link) => link.getAttribute('href') === '/main.css',
518+
),
519+
).toHaveLength(1)
520+
})
330521
})
331522

332523
describe('data script rendering', () => {

packages/solid-router/src/headContentUtils.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as Solid from 'solid-js'
2-
import { escapeHtml } from '@tanstack/router-core'
2+
import { escapeHtml, replaceEqualDeep } from '@tanstack/router-core'
33
import { useRouter } from './useRouter'
44
import type { RouterManagedTag } from '@tanstack/router-core'
55

@@ -179,8 +179,8 @@ export const useTags = () => {
179179
})),
180180
)
181181

182-
return () =>
183-
uniqBy(
182+
return Solid.createMemo((prev: Array<RouterManagedTag> | undefined) => {
183+
const next = uniqBy(
184184
[
185185
...meta(),
186186
...preloadLinks(),
@@ -192,6 +192,11 @@ export const useTags = () => {
192192
return JSON.stringify(d)
193193
},
194194
)
195+
if (prev === undefined) {
196+
return next
197+
}
198+
return replaceEqualDeep(prev, next)
199+
})
195200
}
196201

197202
export function uniqBy<T>(arr: Array<T>, fn: (item: T) => string) {

0 commit comments

Comments
 (0)