Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion packages/solid-router/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,6 @@
"dependencies": {
"@solid-devtools/logger": "^0.9.4",
"@solid-primitives/refs": "^1.0.8",
"@solidjs/meta": "^0.29.4",
"@tanstack/history": "workspace:*",
"@tanstack/router-core": "workspace:*",
"@tanstack/solid-store": "^0.8.0",
Expand Down
154 changes: 149 additions & 5 deletions packages/solid-router/src/Asset.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { Link, Meta, Style, Title } from '@solidjs/meta'
import { onCleanup, onMount } from 'solid-js'
import { useRouter } from './useRouter'
import type { RouterManagedTag } from '@tanstack/router-core'
Expand All @@ -9,15 +8,43 @@ export function Asset({
attrs,
children,
}: RouterManagedTag): JSX.Element | null {
const router = useRouter()

if (router.isServer) {
switch (tag) {
case 'title':
return <title {...attrs}>{children}</title>
case 'meta':
return <meta {...attrs} />
case 'link':
return <link {...attrs} />
case 'style':
if (typeof children === 'string') {
return <style {...attrs} innerHTML={children} />
}
return <style {...attrs} />
case 'script':
if (attrs?.src && typeof attrs.src === 'string') {
return <script {...attrs} />
}
if (typeof children === 'string') {
return <script {...attrs} innerHTML={children} />
}
return <script {...attrs} />
default:
return null
}
}

switch (tag) {
case 'title':
return <Title {...attrs}>{children}</Title>
return <HeadTag tag="title" attrs={attrs} children={children} />
case 'meta':
return <Meta {...attrs} />
return <HeadTag tag="meta" attrs={attrs} />
case 'link':
return <Link {...attrs} />
return <HeadTag tag="link" attrs={attrs} />
case 'style':
return <Style {...attrs} innerHTML={children} />
return <HeadTag tag="style" attrs={attrs} children={children} />
case 'script':
return <Script attrs={attrs}>{children}</Script>
default:
Expand Down Expand Up @@ -138,3 +165,120 @@ function Script({

return null
}

function HeadTag({
tag,
attrs,
children,
}: {
tag: 'title' | 'meta' | 'link' | 'style'
attrs?: Record<string, any>
children?: string
}): null {
onMount(() => {
if (typeof document === 'undefined') {
return
}

const element = findOrCreateHeadElement(tag, attrs, children)

if (!element) {
return
}

onCleanup(() => {
if (element.parentNode) {
element.parentNode.removeChild(element)
}
})
})

return null
}

function findOrCreateHeadElement(
tag: 'title' | 'meta' | 'link' | 'style',
attrs?: Record<string, any>,
children?: string,
) {
const existing = findExistingHeadElement(tag, attrs, children)
if (existing) {
return existing
}

const element = document.createElement(tag)
setAttributes(element, attrs)

if (typeof children === 'string' && (tag === 'title' || tag === 'style')) {
element.textContent = children
}

document.head.appendChild(element)

return element
}

function findExistingHeadElement(
tag: 'title' | 'meta' | 'link' | 'style',
attrs?: Record<string, any>,
children?: string,
) {
const candidates = document.head.querySelectorAll(tag)
for (const candidate of candidates) {
if (!matchesAttributes(candidate, attrs)) {
continue
}

if (typeof children === 'string' && (tag === 'title' || tag === 'style')) {
if (candidate.textContent !== children) {
continue
}
}

return candidate as HTMLElement
}

return undefined
}

function matchesAttributes(
element: Element,
attrs?: Record<string, any>,
): boolean {
if (!attrs) {
return true
}

for (const [key, value] of Object.entries(attrs)) {
if (value === undefined || value === false) {
continue
}

if (value === true) {
if (!element.hasAttribute(key)) {
return false
}
continue
}

if (element.getAttribute(key) !== String(value)) {
return false
}
}

return true
}

function setAttributes(element: Element, attrs?: Record<string, any>) {
if (!attrs) {
return
}

for (const [key, value] of Object.entries(attrs)) {
if (value === undefined || value === false) {
continue
}

element.setAttribute(key, value === true ? '' : String(value))
}
}
5 changes: 1 addition & 4 deletions packages/solid-router/src/HeadContent.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import * as Solid from 'solid-js'
import { MetaProvider } from '@solidjs/meta'
import { For } from 'solid-js'
import { Asset } from './Asset'
import { useRouter } from './useRouter'
Expand Down Expand Up @@ -191,9 +190,7 @@ export function HeadContent() {
const tags = useTags()

return (
<MetaProvider>
<For each={tags()}>{(tag) => <Asset {...tag} />}</For>
</MetaProvider>
<For each={tags()}>{(tag) => <Asset {...tag} />}</For>
)
}

Expand Down
13 changes: 5 additions & 8 deletions packages/solid-router/src/ssr/RouterServer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
ssr,
useAssets,
} from 'solid-js/web'
import { MetaProvider } from '@solidjs/meta'
import { Asset } from '../Asset'
import { useTags } from '../HeadContent'
import { RouterProvider } from '../RouterProvider'
Expand All @@ -16,11 +15,11 @@ export function ServerHeadContent() {
const tags = useTags()
useAssets(() => {
return (
<MetaProvider>
<>
{tags().map((tag) => (
<Asset {...tag} />
))}
</MetaProvider>
</>
)
})
return null
Expand All @@ -44,11 +43,9 @@ export function RouterServer<TRouter extends AnyRouter>(props: {
router={props.router}
InnerWrap={(props) => (
<NoHydration>
<MetaProvider>
<ServerHeadContent />
<Hydration>{props.children}</Hydration>
<Scripts />
</MetaProvider>
<ServerHeadContent />
<Hydration>{props.children}</Hydration>
<Scripts />
</NoHydration>
)}
/>
Expand Down
85 changes: 83 additions & 2 deletions packages/solid-router/tests/Scripts.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from 'vitest'
import { render } from '@solidjs/testing-library'
import { afterEach, describe, expect, test } from 'vitest'
import { cleanup, render } from '@solidjs/testing-library'

import {
HeadContent,
Expand All @@ -11,6 +11,10 @@ import {
} from '../src'
import { Scripts } from '../src/Scripts'

afterEach(() => {
cleanup()
})

describe('ssr scripts', () => {
test('it works', async () => {
const rootRoute = createRootRoute({
Expand Down Expand Up @@ -195,3 +199,80 @@ describe('ssr HeadContent', () => {
])
})
})

describe('client HeadContent', () => {
test('renders and cleans up head tags', async () => {
const rootRoute = createRootRoute({
head: () => ({
meta: [
{ title: 'Root' },
{ name: 'description', content: 'Root description' },
],
links: [{ rel: 'stylesheet', href: '/root.css' }],
styles: [{ children: '.root{color:red}' }],
}),
component: () => {
return <HeadContent />
},
})

const indexRoute = createRoute({
path: '/',
getParentRoute: () => rootRoute,
head: () => ({
meta: [
{ title: 'Index' },
{ name: 'description', content: 'Index description' },
],
links: [{ rel: 'stylesheet', href: '/index.css' }],
styles: [{ children: '.index{color:red}' }],
}),
})

const router = createRouter({
history: createMemoryHistory({
initialEntries: ['/'],
}),
routeTree: rootRoute.addChildren([indexRoute]),
})

await router.load()

const { unmount } = render(() => <RouterProvider router={router} />)

const meta = document.head.querySelector('meta[name="description"]')
const link = document.head.querySelector(
'link[rel="stylesheet"][href="/index.css"]',
)
const style = Array.from(document.head.querySelectorAll('style')).find(
(el) => el.textContent === '.index{color:red}',
)
const title = Array.from(document.head.querySelectorAll('title')).find(
(el) => el.textContent === 'Index',
)

expect(meta?.getAttribute('content')).toBe('Index description')
expect(link).not.toBeNull()
expect(style).not.toBeUndefined()
expect(title).not.toBeUndefined()

unmount()

expect(
document.head.querySelector('meta[name="description"]'),
).toBeNull()
expect(
document.head.querySelector('link[rel="stylesheet"][href="/index.css"]'),
).toBeNull()
expect(
Array.from(document.head.querySelectorAll('style')).find(
(el) => el.textContent === '.index{color:red}',
),
).toBeUndefined()
expect(
Array.from(document.head.querySelectorAll('title')).find(
(el) => el.textContent === 'Index',
),
).toBeUndefined()
})
})
3 changes: 0 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading