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', () => {