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
5 changes: 3 additions & 2 deletions packages/solid-router/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,13 +100,14 @@
"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",
"@unhead/solid-js": "3.0.0-beta.5",
"isbot": "^5.1.22",
"tiny-invariant": "^1.3.3",
"tiny-warning": "^1.0.3"
"tiny-warning": "^1.0.3",
"unhead": "3.0.0-beta.5"
},
"devDependencies": {
"@solidjs/testing-library": "^0.8.10",
Expand Down
168 changes: 42 additions & 126 deletions packages/solid-router/src/Asset.tsx
Original file line number Diff line number Diff line change
@@ -1,140 +1,56 @@
import { Link, Meta, Style, Title } from '@solidjs/meta'
import { onCleanup, onMount } from 'solid-js'
import { useRouter } from './useRouter'
import { useHead } from '@unhead/solid-js'
import type { RouterManagedTag } from '@tanstack/router-core'
import type { JSX } from 'solid-js'
import type { UseHeadInput, ValidTagPositions } from 'unhead/types'

export function Asset(
props: RouterManagedTag & {
tagPosition?: ValidTagPositions
},
): JSX.Element | null {
useHead(toHeadInput(props))
return null
}

export function Asset({
function toHeadInput({
tag,
attrs,
children,
}: RouterManagedTag): JSX.Element | null {
switch (tag) {
case 'title':
return <Title {...attrs}>{children}</Title>
case 'meta':
return <Meta {...attrs} />
case 'link':
return <Link {...attrs} />
case 'style':
return <Style {...attrs} innerHTML={children} />
case 'script':
return <Script attrs={attrs}>{children}</Script>
default:
return null
tagPosition,
}: RouterManagedTag & {
tagPosition?: ValidTagPositions
}): UseHeadInput {
const withPosition = (input: Record<string, any>) => {
if (tagPosition) {
input.tagPosition = tagPosition
}
return input
}
}

interface ScriptAttrs {
[key: string]: string | boolean | undefined
src?: string
}

function Script({
attrs,
children,
}: {
attrs?: ScriptAttrs
children?: string
}): JSX.Element | null {
const router = useRouter()

onMount(() => {
if (attrs?.src) {
const normSrc = (() => {
try {
const base = document.baseURI || window.location.href
return new URL(attrs.src, base).href
} catch {
return attrs.src
}
})()
const existingScript = Array.from(
document.querySelectorAll('script[src]'),
).find((el) => (el as HTMLScriptElement).src === normSrc)

if (existingScript) {
return
}

const script = document.createElement('script')

for (const [key, value] of Object.entries(attrs)) {
if (value !== undefined && value !== false) {
script.setAttribute(
key,
typeof value === 'boolean' ? '' : String(value),
)
}
}

document.head.appendChild(script)

onCleanup(() => {
if (script.parentNode) {
script.parentNode.removeChild(script)
}
})
switch (tag) {
case 'title':
return { title: children }
case 'meta': {
return { meta: [withPosition({ ...(attrs ?? {}) })] }
}

if (typeof children === 'string') {
const typeAttr =
typeof attrs?.type === 'string' ? attrs.type : 'text/javascript'
const nonceAttr =
typeof attrs?.nonce === 'string' ? attrs.nonce : undefined
const existingScript = Array.from(
document.querySelectorAll('script:not([src])'),
).find((el) => {
if (!(el instanceof HTMLScriptElement)) return false
const sType = el.getAttribute('type') ?? 'text/javascript'
const sNonce = el.getAttribute('nonce') ?? undefined
return (
el.textContent === children &&
sType === typeAttr &&
sNonce === nonceAttr
)
})

if (existingScript) {
return
case 'link': {
return { link: [withPosition({ ...(attrs ?? {}) })] }
}
case 'style': {
const style = withPosition({ ...(attrs ?? {}) })
if (typeof children === 'string') {
style.textContent = children
}

const script = document.createElement('script')
script.textContent = children

if (attrs) {
for (const [key, value] of Object.entries(attrs)) {
if (value !== undefined && value !== false) {
script.setAttribute(
key,
typeof value === 'boolean' ? '' : String(value),
)
}
}
return { style: [style] }
}
case 'script': {
const script = withPosition({ ...(attrs ?? {}) })
if (typeof children === 'string') {
script.textContent = children
}

document.head.appendChild(script)

onCleanup(() => {
if (script.parentNode) {
script.parentNode.removeChild(script)
}
})
return { script: [script] }
}
})

if (!router.isServer) {
// render an empty script on the client just to avoid hydration errors
return null
}

if (attrs?.src && typeof attrs.src === 'string') {
return <script {...attrs} />
}

if (typeof children === 'string') {
return <script {...attrs} innerHTML={children} />
default:
return {}
}

return null
}
9 changes: 2 additions & 7 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 @@ -185,16 +184,12 @@ export const useTags = () => {
* @description The `HeadContent` component is used to render meta tags, links, and scripts for the current route.
* When using full document hydration (hydrating from `<html>`), this component should be rendered in the `<body>`
* to ensure it's part of the reactive tree and updates correctly during client-side navigation.
* The component uses portals internally to render content into the `<head>` element.
* The component registers tags with Unhead so the provider can render them into the `<head>` element.
*/
export function HeadContent() {
const tags = useTags()

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

function uniqBy<T>(arr: Array<T>, fn: (item: T) => string) {
Expand Down
28 changes: 25 additions & 3 deletions packages/solid-router/src/RouterProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { UnheadContext, createHead } from '@unhead/solid-js/client'
import { useContext } from 'solid-js'
import { isServer } from 'solid-js/web'
import { getRouterContext } from './routerContext'
import { SafeFragment } from './SafeFragment'
import { Matches } from './Matches'
Expand All @@ -8,6 +11,21 @@ import type {
} from '@tanstack/router-core'
import type * as Solid from 'solid-js'

let clientHead: ReturnType<typeof createHead> | undefined

function HeadProvider(props: { children: () => Solid.JSX.Element }) {
const existing = useContext(UnheadContext)
if (existing || isServer) {
return props.children()
}
clientHead ||= createHead()
return (
<UnheadContext.Provider value={clientHead}>
{props.children()}
</UnheadContext.Provider>
)
}

export function RouterContextProvider<
TRouter extends AnyRouter = RegisteredRouter,
TDehydrated extends Record<string, any> = Record<string, any>,
Expand All @@ -34,9 +52,13 @@ export function RouterContextProvider<

return (
<OptionalWrapper>
<routerContext.Provider value={router as AnyRouter}>
{children()}
</routerContext.Provider>
<HeadProvider>
{() => (
<routerContext.Provider value={router as AnyRouter}>
{children()}
</routerContext.Provider>
)}
</HeadProvider>
</OptionalWrapper>
)
}
Expand Down
2 changes: 1 addition & 1 deletion packages/solid-router/src/Scripts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export const Scripts = () => {
return (
<>
{allScripts.map((asset, i) => (
<Asset {...asset} />
<Asset {...asset} tagPosition="bodyClose" />
))}
</>
)
Expand Down
52 changes: 35 additions & 17 deletions packages/solid-router/src/ssr/RouterClient.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,31 @@
import { hydrate } from '@tanstack/router-core/ssr/client'
import {
UnheadContext,
createHead as createClientHead,
} from '@unhead/solid-js/client'
import { createStreamableHead as createStreamableClientHead } from '@unhead/solid-js/stream/client'
import { Await } from '../awaited'
import { HeadContent } from '../HeadContent'
import { RouterProvider } from '../RouterProvider'
import type { AnyRouter } from '@tanstack/router-core'
import type { JSXElement } from 'solid-js'

let hydrationPromise: Promise<void | Array<Array<void>>> | undefined
let headInstance: ReturnType<typeof createClientHead> | undefined

const Dummy = (props: { children?: JSXElement }) => <>{props.children}</>

const getHeadInstance = () => {
if (!headInstance) {
headInstance =
(createStreamableClientHead()) ?? createClientHead()
}
return headInstance
}

export function RouterClient(props: { router: AnyRouter }) {
const head = getHeadInstance()

if (!hydrationPromise) {
if (!props.router.state.matches.length) {
hydrationPromise = hydrate(props.router)
Expand All @@ -18,26 +34,28 @@ export function RouterClient(props: { router: AnyRouter }) {
}
}
return (
<Await
promise={hydrationPromise}
children={() => (
<Dummy>
<UnheadContext.Provider value={head}>
<Await
promise={hydrationPromise}
children={() => (
<Dummy>
<RouterProvider
router={props.router}
InnerWrap={(props) => (
<Dummy>
<Dummy>
<RouterProvider
router={props.router}
InnerWrap={(props) => (
<Dummy>
<HeadContent />
{props.children}
<Dummy>
<HeadContent />
{props.children}
</Dummy>
<Dummy />
</Dummy>
<Dummy />
</Dummy>
)}
/>
)}
/>
</Dummy>
</Dummy>
</Dummy>
)}
/>
)}
/>
</UnheadContext.Provider>
)
}
Loading
Loading