UNPKG

@tanstack/solid-router

Version:

Modern and scalable routing for Solid applications

200 lines 8.53 kB
import { isServer } from '@tanstack/router-core/isServer'; import { createEffect, onCleanup, onSettled } from 'solid-js'; import { useRouter } from './useRouter'; const INLINE_CSS_HYDRATION_ATTR = 'data-tsr-inline-css'; export function Asset(asset) { const { tag, attrs, children } = asset; switch (tag) { case 'title': return <Title attrs={attrs} children={children}/>; case 'meta': return <HeadElement tag="meta" attrs={attrs}/>; case 'link': return <HeadElement tag="link" attrs={attrs}/>; case 'style': if (asset.inlineCss && (process.env.TSS_INLINE_CSS_ENABLED === 'true' || (process.env.TSS_INLINE_CSS_ENABLED === undefined && isServer))) { return <InlineCssStyle attrs={attrs}>{children}</InlineCssStyle>; } return <HeadElement tag="style" attrs={attrs} children={children}/>; case 'script': return <Script attrs={attrs} children={children}/>; default: return null; } } // On the client, relocate a rendered head element into document.head so head // tags end up in the right place even when <HeadContent /> is rendered in // <body>. The *same* node that Solid rendered/hydrated is moved — never // recreated — so the server-rendered node stays claimed (its hydration id is // preserved) and stylesheets/scripts are not refetched or re-executed. // // When <HeadContent /> is placed in <head> (the SSR/hydration case), the node // is already in document.head, so this is a no-op and the element is left // exactly where Solid hydrated it. function useRelocateToHead(getEl) { onSettled(() => { const el = getEl(); if (el && el.parentNode !== document.head) { document.head.appendChild(el); } }); onCleanup(() => { const el = getEl(); if (el?.parentNode) { el.parentNode.removeChild(el); } }); } function InlineCssStyle({ attrs, children, }) { const isInlineCssPlaceholder = children === undefined; const html = isInlineCssPlaceholder ? typeof document === 'undefined' ? '' : (document.querySelector(`style[${INLINE_CSS_HYDRATION_ATTR}]`)?.textContent ?? '') : (children ?? ''); return (<HeadElement tag="style" attrs={{ ...attrs, [INLINE_CSS_HYDRATION_ATTR]: '' }} children={html}/>); } function HeadElement(props) { // Each branch must live in its own `case` block so the Solid compiler emits // exactly ONE getNextElement() per invocation. Sequential `if ... return` // statements compile to multiple getNextElement() calls that all execute, // desyncing the hydration key counter (causing "expected <style>" mismatches // and unclaimed nodes). // // We capture the element via the JSX return value (a real DOM node in Solid) // rather than a `ref` attribute: a `ref` compiles to a _$ref() call that // interferes with attribute spreading on hydration-claimed nodes (wiping the // SSR attributes). let element; switch (props.tag) { case 'style': { const attrs = { ...props.attrs, innerHTML: typeof props.children === 'string' ? props.children : undefined, }; element = (<style {...attrs}/>); break; } case 'meta': element = (<meta {...props.attrs}/>); break; default: element = (<link {...props.attrs}/>); } // Move the rendered/hydrated element into <head> when <HeadContent /> is // placed in <body>. No-op when already in <head>. Called unconditionally so // server and client register the same primitives (see Title). useRelocateToHead(() => element); return element; } function Title(props) { let el; // IMPORTANT: call these hooks UNCONDITIONALLY (do not guard with isServer). // Solid's hydration relies on the component body registering the same // reactive primitives in the same order on the server and the client. An // `if (!isServer)` guard would skip them on the server but run them on the // client, desyncing the hydration owner tree and causing the server <title> // to be left unclaimed (and a duplicate appended). These hooks are no-ops // during SSR anyway. // Move the rendered/hydrated <title> into <head> when <HeadContent /> is // placed in <body>. No-op when already in <head>. useRelocateToHead(() => el); // Keep document.title in sync during client-side navigation. createEffect(() => props.children, (titleText) => { document.title = typeof titleText === 'string' ? titleText : ''; }); return (<title ref={(e) => (el = e)} {...props.attrs}> {props.children} </title>); } function Script(props) { const router = useRouter(); const attrs = props.attrs; const children = props.children; const dataScript = typeof attrs?.type === 'string' && attrs.type !== '' && attrs.type !== 'text/javascript' && attrs.type !== 'module'; // --- Server rendering --- if (isServer ?? router.isServer) { if (attrs?.src) { return <script {...attrs}/>; } if (typeof children === 'string') { return <script {...attrs} innerHTML={children}/>; } return null; } // --- Client rendering --- // Data scripts (e.g. application/ld+json) are rendered in the tree; // they don't need to execute. if (dataScript && typeof children === 'string') { return <script {...attrs} innerHTML={children}/>; } // For executable scripts, use imperative DOM injection so the browser // actually executes them during client-side navigation. The injection is // idempotent (it checks for an already-present matching script), so the // server-rendered script node is reused rather than duplicated. createEffect(() => ({ attrs, children, dataScript }), ({ attrs, children, dataScript }) => { if (dataScript) return; let script; 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.src === normSrc); if (existingScript) { return; } 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); } else 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; } 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)); } } } document.head.appendChild(script); } return () => { if (script?.parentNode) { script.parentNode.removeChild(script); } }; }); return null; } //# sourceMappingURL=Asset.jsx.map