diff --git a/.changeset/four-cobras-appear.md b/.changeset/four-cobras-appear.md
new file mode 100644
index 0000000000..3ad5b7e17f
--- /dev/null
+++ b/.changeset/four-cobras-appear.md
@@ -0,0 +1,6 @@
+---
+'@tanstack/solid-router': patch
+'@tanstack/vue-router': patch
+---
+
+compact DOM script handling
diff --git a/packages/solid-router/src/Asset.tsx b/packages/solid-router/src/Asset.tsx
index bc6bbe58c3..4bc01c1041 100644
--- a/packages/solid-router/src/Asset.tsx
+++ b/packages/solid-router/src/Asset.tsx
@@ -62,6 +62,20 @@ interface ScriptAttrs {
src?: string
}
+function setScriptAttrs(
+ script: HTMLScriptElement,
+ attrs: ScriptAttrs | undefined,
+) {
+ if (!attrs) {
+ return
+ }
+ for (const [key, value] of Object.entries(attrs)) {
+ if (value !== undefined && value !== false) {
+ script.setAttribute(key, typeof value === 'boolean' ? '' : String(value))
+ }
+ }
+}
+
function Script({
attrs,
children,
@@ -88,32 +102,18 @@ function Script({
return attrs.src
}
})()
- const existingScript = Array.from(
- document.querySelectorAll('script[src]'),
- ).find((el) => (el as HTMLScriptElement).src === normSrc)
-
- if (existingScript) {
- return
+ for (const el of document.querySelectorAll('script[src]')) {
+ if ((el as HTMLScriptElement).src === normSrc) {
+ 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),
- )
- }
- }
+ setScriptAttrs(script, attrs)
document.head.appendChild(script)
- onCleanup(() => {
- if (script.parentNode) {
- script.parentNode.removeChild(script)
- }
- })
+ onCleanup(() => script.remove())
}
if (typeof children === 'string') {
@@ -121,44 +121,28 @@ function Script({
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
+ for (const el of document.querySelectorAll('script:not([src])')) {
+ if (!(el instanceof HTMLScriptElement)) {
+ continue
+ }
const sType = el.getAttribute('type') ?? 'text/javascript'
const sNonce = el.getAttribute('nonce') ?? undefined
- return (
+ if (
el.textContent === children &&
sType === typeAttr &&
sNonce === nonceAttr
- )
- })
-
- if (existingScript) {
- return
+ ) {
+ return
+ }
}
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),
- )
- }
- }
- }
+ setScriptAttrs(script, attrs)
document.head.appendChild(script)
- onCleanup(() => {
- if (script.parentNode) {
- script.parentNode.removeChild(script)
- }
- })
+ onCleanup(() => script.remove())
}
})
diff --git a/packages/solid-router/tests/Scripts.test.tsx b/packages/solid-router/tests/Scripts.test.tsx
index 29f6553d56..dda7ddaf17 100644
--- a/packages/solid-router/tests/Scripts.test.tsx
+++ b/packages/solid-router/tests/Scripts.test.tsx
@@ -200,6 +200,77 @@ describe('ssr scripts', () => {
)
})
+ test('injects client script attributes and removes the script on cleanup', async () => {
+ const externalScript = {
+ src: 'solid-client-script.js',
+ async: true,
+ defer: false,
+ crossOrigin: 'anonymous' as const,
+ }
+ const inlineScript = {
+ id: 'solid-client-inline-script',
+ type: 'module',
+ children: 'window.__solidClientScript = true',
+ }
+ const rootRoute = createRootRoute({
+ scripts: () => [
+ externalScript,
+ externalScript,
+ inlineScript,
+ inlineScript,
+ ],
+ component: () => (
+ <>
+
+
+ >
+ ),
+ })
+ const indexRoute = createRoute({
+ path: '/',
+ getParentRoute: () => rootRoute,
+ })
+ const router = createRouter({
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ routeTree: rootRoute.addChildren([indexRoute]),
+ isServer: false,
+ })
+
+ await router.load()
+ const result = render(() => )
+ expect(
+ await screen.findByTestId('solid-client-script-root'),
+ ).toBeInTheDocument()
+
+ const getScript = () =>
+ document.head.querySelector(
+ 'script[src="solid-client-script.js"]',
+ )
+ await waitFor(() => expect(getScript()).not.toBeNull())
+ expect(getScript()?.hasAttribute('async')).toBe(true)
+ expect(getScript()?.hasAttribute('defer')).toBe(false)
+ expect(getScript()?.getAttribute('crossorigin')).toBe('anonymous')
+ expect(
+ document.head.querySelectorAll('script[src="solid-client-script.js"]'),
+ ).toHaveLength(1)
+ const getInlineScript = () =>
+ document.head.querySelector(
+ 'script#solid-client-inline-script',
+ )
+ await waitFor(() => expect(getInlineScript()).not.toBeNull())
+ expect(getInlineScript()?.textContent).toBe(
+ 'window.__solidClientScript = true',
+ )
+ expect(
+ document.head.querySelectorAll('script#solid-client-inline-script'),
+ ).toHaveLength(1)
+
+ getScript()?.remove()
+ expect(() => result.unmount()).not.toThrow()
+ expect(getScript()).toBeNull()
+ expect(getInlineScript()).toBeNull()
+ })
+
test('keeps manifest stylesheet links mounted across repeated Link navigations', async () => {
const history = createTestBrowserHistory()
diff --git a/packages/vue-router/src/Asset.tsx b/packages/vue-router/src/Asset.tsx
index fdbf140c79..610cbee6b0 100644
--- a/packages/vue-router/src/Asset.tsx
+++ b/packages/vue-router/src/Asset.tsx
@@ -10,6 +10,20 @@ interface ScriptAttrs {
src?: string
}
+function setScriptAttrs(
+ script: HTMLScriptElement,
+ attrs: ScriptAttrs | undefined,
+) {
+ if (!attrs) {
+ return
+ }
+ for (const [key, value] of Object.entries(attrs)) {
+ if (value !== undefined && value !== false) {
+ script.setAttribute(key, typeof value === 'boolean' ? '' : String(value))
+ }
+ }
+}
+
const Title = Vue.defineComponent({
name: 'Title',
props: {
@@ -78,24 +92,14 @@ const Script = Vue.defineComponent({
return attrs.src
}
})()
- const existingScript = Array.from(
- document.querySelectorAll('script[src]'),
- ).find((el) => (el as HTMLScriptElement).src === normSrc)
-
- if (existingScript) {
- return
+ for (const el of document.querySelectorAll('script[src]')) {
+ if ((el as HTMLScriptElement).src === normSrc) {
+ 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),
- )
- }
- }
+ setScriptAttrs(script, attrs)
document.head.appendChild(script)
} else if (typeof children === 'string') {
@@ -103,36 +107,24 @@ const Script = Vue.defineComponent({
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
+ for (const el of document.querySelectorAll('script:not([src])')) {
+ if (!(el instanceof HTMLScriptElement)) {
+ continue
+ }
const sType = el.getAttribute('type') ?? 'text/javascript'
const sNonce = el.getAttribute('nonce') ?? undefined
- return (
+ if (
el.textContent === children &&
sType === typeAttr &&
sNonce === nonceAttr
- )
- })
-
- if (existingScript) {
- return
+ ) {
+ return
+ }
}
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),
- )
- }
- }
- }
+ setScriptAttrs(script, attrs)
document.head.appendChild(script)
}
diff --git a/packages/vue-router/tests/Scripts.test.tsx b/packages/vue-router/tests/Scripts.test.tsx
index 8d6724076b..0f8e031a09 100644
--- a/packages/vue-router/tests/Scripts.test.tsx
+++ b/packages/vue-router/tests/Scripts.test.tsx
@@ -51,6 +51,11 @@ afterEach(() => {
cleanup()
browserHistories.splice(0).forEach((history) => history.destroy())
window.history.replaceState(null, 'root', '/')
+ document.head
+ .querySelectorAll(
+ 'script[src="vue-client-script.js"], script#vue-client-inline-script',
+ )
+ .forEach((script) => script.remove())
delete window.$_TSR
})
@@ -149,6 +154,75 @@ describe('ssr scripts', () => {
expect(scripts[0]!.getAttribute('src')).toBe('script.js')
expect(scripts[1]!.getAttribute('src')).toBe('script3.js')
})
+
+ test('injects client script attributes into the document head', async () => {
+ const externalScript = {
+ src: 'vue-client-script.js',
+ async: true,
+ defer: false,
+ crossOrigin: 'anonymous' as const,
+ }
+ const inlineScriptOptions = {
+ id: 'vue-client-inline-script',
+ type: 'module',
+ children: 'window.__vueClientScript = true',
+ }
+ const rootRoute = createRootRoute({
+ scripts: () => [
+ externalScript,
+ externalScript,
+ inlineScriptOptions,
+ inlineScriptOptions,
+ ],
+ component: () => (
+ <>
+
+
+ >
+ ),
+ })
+ const indexRoute = createRoute({
+ path: '/',
+ getParentRoute: () => rootRoute,
+ })
+ const router = createRouter({
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ routeTree: rootRoute.addChildren([indexRoute]),
+ isServer: false,
+ })
+
+ await router.load()
+ render()
+ expect(
+ await screen.findByTestId('vue-client-script-root'),
+ ).toBeInTheDocument()
+
+ const getScript = () =>
+ document.head.querySelector(
+ 'script[src="vue-client-script.js"]',
+ )
+ await waitFor(() => expect(getScript()).not.toBeNull())
+ expect(getScript()?.hasAttribute('async')).toBe(true)
+ expect(getScript()?.hasAttribute('defer')).toBe(false)
+ expect(getScript()?.getAttribute('crossorigin')).toBe('anonymous')
+ expect(
+ document.head.querySelectorAll('script[src="vue-client-script.js"]'),
+ ).toHaveLength(1)
+ const getInlineScript = () =>
+ document.head.querySelector(
+ 'script#vue-client-inline-script',
+ )
+ await waitFor(() => expect(getInlineScript()).not.toBeNull())
+ expect(getInlineScript()?.textContent).toBe(
+ 'window.__vueClientScript = true',
+ )
+ expect(
+ document.head.querySelectorAll('script#vue-client-inline-script'),
+ ).toHaveLength(1)
+
+ getScript()?.remove()
+ getInlineScript()?.remove()
+ })
})
describe('ssr HeadContent', () => {