diff options
20 files changed, 411 insertions, 54 deletions
diff --git a/.changeset/good-items-rest.md b/.changeset/good-items-rest.md new file mode 100644 index 000000000..7768f7aa3 --- /dev/null +++ b/.changeset/good-items-rest.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Ensure CSS injections properly when using multiple layouts diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index c1c5f585a..7fab1556f 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -1440,7 +1440,7 @@ export interface SSRResult { links: Set<SSRElement>; propagation: Map<string, PropagationHint>; propagators: Map<AstroComponentFactory, AstroComponentInstance>; - extraHead: Array<any>; + extraHead: Array<string>; cookies: AstroCookies | undefined; createAstro( Astro: AstroGlobalPartial, diff --git a/packages/astro/src/runtime/server/render/astro/head-and-content.ts b/packages/astro/src/runtime/server/render/astro/head-and-content.ts index 57f05425d..fbadcb6f1 100644 --- a/packages/astro/src/runtime/server/render/astro/head-and-content.ts +++ b/packages/astro/src/runtime/server/render/astro/head-and-content.ts @@ -4,7 +4,7 @@ const headAndContentSym = Symbol.for('astro.headAndContent'); export type HeadAndContent = { [headAndContentSym]: true; - head: string | RenderTemplateResult; + head: string; content: RenderTemplateResult; }; @@ -13,7 +13,7 @@ export function isHeadAndContent(obj: unknown): obj is HeadAndContent { } export function createHeadAndContent( - head: string | RenderTemplateResult, + head: string, content: RenderTemplateResult ): HeadAndContent { return { diff --git a/packages/astro/src/runtime/server/render/common.ts b/packages/astro/src/runtime/server/render/common.ts index 9563959d2..c9d17b64c 100644 --- a/packages/astro/src/runtime/server/render/common.ts +++ b/packages/astro/src/runtime/server/render/common.ts @@ -2,6 +2,7 @@ import type { SSRResult } from '../../../@types/astro'; import type { RenderInstruction } from './types.js'; import { HTMLBytes, markHTMLString } from '../escape.js'; +import { renderAllHeadContent } from './head.js'; import { determineIfNeedsHydrationScript, determinesIfNeedsDirectiveScript, @@ -20,40 +21,48 @@ export const decoder = new TextDecoder(); // These directive instructions bubble all the way up to renderPage so that we // can ensure they are added only once, and as soon as possible. export function stringifyChunk(result: SSRResult, chunk: string | SlotString | RenderInstruction) { - switch ((chunk as any).type) { - case 'directive': { - const { hydration } = chunk as RenderInstruction; - let needsHydrationScript = hydration && determineIfNeedsHydrationScript(result); - let needsDirectiveScript = - hydration && determinesIfNeedsDirectiveScript(result, hydration.directive); - - let prescriptType: PrescriptType = needsHydrationScript - ? 'both' - : needsDirectiveScript - ? 'directive' - : null; - if (prescriptType) { - let prescripts = getPrescripts(prescriptType, hydration.directive); - return markHTMLString(prescripts); - } else { - return ''; + if(typeof (chunk as any).type === 'string') { + const instruction = chunk as RenderInstruction; + switch(instruction.type) { + case 'directive': { + const { hydration } = instruction; + let needsHydrationScript = hydration && determineIfNeedsHydrationScript(result); + let needsDirectiveScript = + hydration && determinesIfNeedsDirectiveScript(result, hydration.directive); + + let prescriptType: PrescriptType = needsHydrationScript + ? 'both' + : needsDirectiveScript + ? 'directive' + : null; + if (prescriptType) { + let prescripts = getPrescripts(prescriptType, hydration.directive); + return markHTMLString(prescripts); + } else { + return ''; + } + } + case 'head': { + if(result._metadata.hasRenderedHead) { + return ''; + } + return renderAllHeadContent(result); } } - default: { - if (isSlotString(chunk as string)) { - let out = ''; - const c = chunk as SlotString; - if (c.instructions) { - for (const instr of c.instructions) { - out += stringifyChunk(result, instr); - } + } else { + if (isSlotString(chunk as string)) { + let out = ''; + const c = chunk as SlotString; + if (c.instructions) { + for (const instr of c.instructions) { + out += stringifyChunk(result, instr); } - out += chunk.toString(); - return out; } - - return chunk.toString(); + out += chunk.toString(); + return out; } + + return chunk.toString(); } } diff --git a/packages/astro/src/runtime/server/render/head.ts b/packages/astro/src/runtime/server/render/head.ts index c02e973f1..83c2c173b 100644 --- a/packages/astro/src/runtime/server/render/head.ts +++ b/packages/astro/src/runtime/server/render/head.ts @@ -1,7 +1,6 @@ import type { SSRResult } from '../../../@types/astro'; import { markHTMLString } from '../escape.js'; -import { renderChild } from './any.js'; import { renderElement } from './util.js'; // Filter out duplicate elements in our set @@ -13,14 +12,8 @@ const uniqueElements = (item: any, index: number, all: any[]) => { ); }; -async function* renderExtraHead(result: SSRResult, base: string) { - yield base; - for (const part of result.extraHead) { - yield* renderChild(part); - } -} - -function renderAllHeadContent(result: SSRResult) { +export function renderAllHeadContent(result: SSRResult) { + result._metadata.hasRenderedHead = true; const styles = Array.from(result.styles) .filter(uniqueElements) .map((style) => renderElement('style', style)); @@ -35,29 +28,31 @@ function renderAllHeadContent(result: SSRResult) { .filter(uniqueElements) .map((link) => renderElement('link', link, false)); - const baseHeadContent = markHTMLString(links.join('\n') + styles.join('\n') + scripts.join('\n')); + let content = links.join('\n') + styles.join('\n') + scripts.join('\n'); if (result.extraHead.length > 0) { - return renderExtraHead(result, baseHeadContent); - } else { - return baseHeadContent; + for (const part of result.extraHead) { + content += part; + } } -} -export function createRenderHead(result: SSRResult) { - result._metadata.hasRenderedHead = true; - return renderAllHeadContent.bind(null, result); + return markHTMLString(content); } -export const renderHead = createRenderHead; +export function * renderHead(result: SSRResult) { + yield { type: 'head', result } as const; +} // This function is called by Astro components that do not contain a <head> component // This accommodates the fact that using a <head> is optional in Astro, so this // is called before a component's first non-head HTML element. If the head was // already injected it is a noop. -export async function* maybeRenderHead(result: SSRResult) { +export function* maybeRenderHead(result: SSRResult) { if (result._metadata.hasRenderedHead) { return; } - yield createRenderHead(result)(); + + // This is an instruction informing the page rendering that head might need rendering. + // This allows the page to deduplicate head injections. + yield { type: 'head', result } as const; } diff --git a/packages/astro/src/runtime/server/render/slot.ts b/packages/astro/src/runtime/server/render/slot.ts index 52a1d59a2..cd9225be4 100644 --- a/packages/astro/src/runtime/server/render/slot.ts +++ b/packages/astro/src/runtime/server/render/slot.ts @@ -26,7 +26,7 @@ export async function renderSlot(_result: any, slotted: string, fallback?: any): let content = ''; let instructions: null | RenderInstruction[] = null; for await (const chunk of iterator) { - if ((chunk as any).type === 'directive') { + if (typeof (chunk as any).type === 'string') { if (instructions === null) { instructions = []; } diff --git a/packages/astro/src/runtime/server/render/types.ts b/packages/astro/src/runtime/server/render/types.ts index a9e1afe65..3744037a0 100644 --- a/packages/astro/src/runtime/server/render/types.ts +++ b/packages/astro/src/runtime/server/render/types.ts @@ -1,8 +1,15 @@ import type { SSRResult } from '../../../@types/astro'; import type { HydrationMetadata } from '../hydration.js'; -export interface RenderInstruction { +export type RenderDirectiveInstruction = { type: 'directive'; result: SSRResult; hydration: HydrationMetadata; +}; + +export type RenderHeadInstruction = { + type: 'head'; + result: SSRResult; } + +export type RenderInstruction = RenderDirectiveInstruction | RenderHeadInstruction; diff --git a/packages/astro/test/units/render/head.test.js b/packages/astro/test/units/render/head.test.js new file mode 100644 index 000000000..30ce69781 --- /dev/null +++ b/packages/astro/test/units/render/head.test.js @@ -0,0 +1,181 @@ +import { expect } from 'chai'; + +import { + createComponent, + render, + renderComponent, + renderSlot, + maybeRenderHead, + renderHead, + Fragment +} from '../../../dist/runtime/server/index.js'; +import { + createBasicEnvironment, + createRenderContext, + renderPage, +} from '../../../dist/core/render/index.js'; +import { defaultLogging as logging } from '../../test-utils.js'; +import * as cheerio from 'cheerio'; + +const createAstroModule = (AstroComponent) => ({ default: AstroComponent }); + +describe('core/render', () => { + describe('Injected head contents', () => { + let env; + before(async () => { + env = createBasicEnvironment({ + logging, + renderers: [], + }); + }); + + it('Multi-level layouts and head injection, with explicit head', async () => { + const BaseLayout = createComponent((result, _props, slots) => { + return render`<html> + <head> + ${renderSlot(result, slots['head'])} + ${renderHead(result)} + </head> + ${maybeRenderHead(result)} + <body> + ${renderSlot(result, slots['default'])} + </body> + </html>`; + }) + + const PageLayout = createComponent((result, _props, slots) => { + return render`${renderComponent(result, 'Layout', BaseLayout, {}, { + 'default': () => render` + ${maybeRenderHead(result)} + <main> + ${renderSlot(result, slots['default'])} + </main> + `, + 'head': () => render` + ${renderComponent(result, 'Fragment', Fragment, { slot: 'head' }, { + 'default': () => render`${renderSlot(result, slots['head'])}` + })} + ` + })} + `; + }); + + const Page = createComponent((result, _props) => { + return render`${renderComponent(result, 'PageLayout', PageLayout, {}, { + 'default': () => render`${maybeRenderHead(result)}<div>hello world</div>`, + 'head': () => render` + ${renderComponent(result, 'Fragment', Fragment, {slot: 'head'}, { + 'default': () => render`<meta charset="utf-8">` + })} + ` + })}`; + }); + + const ctx = createRenderContext({ + request: new Request('http://example.com/'), + links: [ + { name: 'link', props: {rel:'stylesheet', href:'/main.css'}, children: '' } + ] + }); + const PageModule = createAstroModule(Page); + + const response = await renderPage(PageModule, ctx, env); + + const html = await response.text(); + const $ = cheerio.load(html); + + expect($('head link')).to.have.a.lengthOf(1); + expect($('body link')).to.have.a.lengthOf(0); + }); + + it('Multi-level layouts and head injection, without explicit head', async () => { + const BaseLayout = createComponent((result, _props, slots) => { + return render`<html> + ${renderSlot(result, slots['head'])} + ${maybeRenderHead(result)} + <body> + ${renderSlot(result, slots['default'])} + </body> + </html>`; + }) + + const PageLayout = createComponent((result, _props, slots) => { + return render`${renderComponent(result, 'Layout', BaseLayout, {}, { + 'default': () => render` + ${maybeRenderHead(result)} + <main> + ${renderSlot(result, slots['default'])} + </main> + `, + 'head': () => render` + ${renderComponent(result, 'Fragment', Fragment, { slot: 'head' }, { + 'default': () => render`${renderSlot(result, slots['head'])}` + })} + ` + })} + `; + }); + + const Page = createComponent((result, _props) => { + return render`${renderComponent(result, 'PageLayout', PageLayout, {}, { + 'default': () => render`${maybeRenderHead(result)}<div>hello world</div>`, + 'head': () => render` + ${renderComponent(result, 'Fragment', Fragment, {slot: 'head'}, { + 'default': () => render`<meta charset="utf-8">` + })} + ` + })}`; + }); + + const ctx = createRenderContext({ + request: new Request('http://example.com/'), + links: [ + { name: 'link', props: {rel:'stylesheet', href:'/main.css'}, children: '' } + ] + }); + const PageModule = createAstroModule(Page); + + const response = await renderPage(PageModule, ctx, env); + + const html = await response.text(); + const $ = cheerio.load(html); + + expect($('head link')).to.have.a.lengthOf(1); + expect($('body link')).to.have.a.lengthOf(0); + }); + + it('Multi-level layouts and head injection, without any content in layouts', async () => { + const BaseLayout = createComponent((result, _props, slots) => { + return render`${renderSlot(result, slots['default'])}`; + }) + + const PageLayout = createComponent((result, _props, slots) => { + return render`${renderComponent(result, 'Layout', BaseLayout, {}, { + 'default': () => render`${renderSlot(result, slots['default'])} `, + })} + `; + }); + + const Page = createComponent((result, _props) => { + return render`${renderComponent(result, 'PageLayout', PageLayout, {}, { + 'default': () => render`${maybeRenderHead(result)}<div>hello world</div>`, + })}`; + }); + + const ctx = createRenderContext({ + request: new Request('http://example.com/'), + links: [ + { name: 'link', props: {rel:'stylesheet', href:'/main.css'}, children: '' } + ] + }); + const PageModule = createAstroModule(Page); + + const response = await renderPage(PageModule, ctx, env); + + const html = await response.text(); + const $ = cheerio.load(html); + + expect($('link')).to.have.a.lengthOf(1); + }); + }); +}); diff --git a/packages/integrations/mdx/test/css-head-mdx.test.js b/packages/integrations/mdx/test/css-head-mdx.test.js new file mode 100644 index 000000000..a6492c3ba --- /dev/null +++ b/packages/integrations/mdx/test/css-head-mdx.test.js @@ -0,0 +1,33 @@ +import mdx from '@astrojs/mdx'; + +import { expect } from 'chai'; +import { parseHTML } from 'linkedom'; +import { loadFixture } from '../../../astro/test/test-utils.js'; + +describe('Head injection w/ MDX', () => { + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: new URL('./fixtures/css-head-mdx/', import.meta.url), + integrations: [mdx()], + }); + }); + + describe('build', () => { + before(async () => { + await fixture.build(); + }); + + it('only injects contents into head', async () => { + const html = await fixture.readFile('/indexThree/index.html'); + const { document } = parseHTML(html); + + const links = document.querySelectorAll('link[rel=stylesheet]'); + expect(links).to.have.a.lengthOf(1); + + const scripts = document.querySelectorAll('script[type=module]'); + expect(scripts).to.have.a.lengthOf(1); + }); + }); +}); diff --git a/packages/integrations/mdx/test/fixtures/css-head-mdx/src/components/HelloWorld.astro b/packages/integrations/mdx/test/fixtures/css-head-mdx/src/components/HelloWorld.astro new file mode 100644 index 000000000..ee8084b46 --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/css-head-mdx/src/components/HelloWorld.astro @@ -0,0 +1,11 @@ +---
+---
+
+<h3>Hello world!!</h3>
+<slot />
+
+<style>h3 { color: red }</style>
+
+<script>
+console.log('hellooooo')
+</script>
\ No newline at end of file diff --git a/packages/integrations/mdx/test/fixtures/css-head-mdx/src/layouts/One.astro b/packages/integrations/mdx/test/fixtures/css-head-mdx/src/layouts/One.astro new file mode 100644 index 000000000..b9916e106 --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/css-head-mdx/src/layouts/One.astro @@ -0,0 +1,15 @@ +---
+---
+
+<html lang="en">
+ <head>
+ <meta charset="utf-8" />
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
+ <meta name="viewport" content="width=device-width" />
+ <meta name="generator" content={Astro.generator} />
+ <title>Astro</title>
+ </head>
+ <body>
+ <slot />
+ </body>
+</html>
\ No newline at end of file diff --git a/packages/integrations/mdx/test/fixtures/css-head-mdx/src/layouts/Three.astro b/packages/integrations/mdx/test/fixtures/css-head-mdx/src/layouts/Three.astro new file mode 100644 index 000000000..3f0fdfa72 --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/css-head-mdx/src/layouts/Three.astro @@ -0,0 +1,6 @@ +---
+import Two from './Two.astro'
+---
+<Two>
+<slot />
+</Two>
\ No newline at end of file diff --git a/packages/integrations/mdx/test/fixtures/css-head-mdx/src/layouts/Two.astro b/packages/integrations/mdx/test/fixtures/css-head-mdx/src/layouts/Two.astro new file mode 100644 index 000000000..51f0ca18c --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/css-head-mdx/src/layouts/Two.astro @@ -0,0 +1,6 @@ +---
+import One from './One.astro'
+---
+<One>
+<slot />
+</One>
\ No newline at end of file diff --git a/packages/integrations/mdx/test/fixtures/css-head-mdx/src/pages/indexOne.astro b/packages/integrations/mdx/test/fixtures/css-head-mdx/src/pages/indexOne.astro new file mode 100644 index 000000000..f24bf4f3c --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/css-head-mdx/src/pages/indexOne.astro @@ -0,0 +1,10 @@ +--- +import One from '../layouts/One.astro' + +import { Content } from '../test.mdx' +--- + +<One> + <h1>Astro</h1> + <Content /> +</One> diff --git a/packages/integrations/mdx/test/fixtures/css-head-mdx/src/pages/indexThree.astro b/packages/integrations/mdx/test/fixtures/css-head-mdx/src/pages/indexThree.astro new file mode 100644 index 000000000..99be9677c --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/css-head-mdx/src/pages/indexThree.astro @@ -0,0 +1,10 @@ +---
+import Three from '../layouts/Three.astro'
+
+import { Content } from '../test.mdx'
+---
+
+<Three>
+ <h1>Astro</h1>
+ <Content />
+</Three>
diff --git a/packages/integrations/mdx/test/fixtures/css-head-mdx/src/pages/indexTwo.astro b/packages/integrations/mdx/test/fixtures/css-head-mdx/src/pages/indexTwo.astro new file mode 100644 index 000000000..af07af926 --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/css-head-mdx/src/pages/indexTwo.astro @@ -0,0 +1,10 @@ +---
+import Two from '../layouts/Two.astro'
+
+import { Content } from '../test.mdx'
+---
+
+<Two>
+ <h1>Astro</h1>
+ <Content />
+</Two>
diff --git a/packages/integrations/mdx/test/fixtures/css-head-mdx/src/pages/testOne.mdx b/packages/integrations/mdx/test/fixtures/css-head-mdx/src/pages/testOne.mdx new file mode 100644 index 000000000..6874b499f --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/css-head-mdx/src/pages/testOne.mdx @@ -0,0 +1,15 @@ +---
+layout: '../layouts/One.astro'
+title: "hello world"
+publishDate: "2023-01-01"
+---
+
+import HelloWorld from '../components/HelloWorld.astro';
+
+# Test
+
+123
+
+<HelloWorld />
+
+456
diff --git a/packages/integrations/mdx/test/fixtures/css-head-mdx/src/pages/testThree.mdx b/packages/integrations/mdx/test/fixtures/css-head-mdx/src/pages/testThree.mdx new file mode 100644 index 000000000..b0e55eed2 --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/css-head-mdx/src/pages/testThree.mdx @@ -0,0 +1,15 @@ +---
+layout: '../layouts/Three.astro'
+title: "hello world"
+publishDate: "2023-01-01"
+---
+
+import HelloWorld from '../components/HelloWorld.astro';
+
+# Test
+
+123
+
+<HelloWorld />
+
+456
diff --git a/packages/integrations/mdx/test/fixtures/css-head-mdx/src/pages/testTwo.mdx b/packages/integrations/mdx/test/fixtures/css-head-mdx/src/pages/testTwo.mdx new file mode 100644 index 000000000..9a80ed5f0 --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/css-head-mdx/src/pages/testTwo.mdx @@ -0,0 +1,15 @@ +---
+layout: '../layouts/Two.astro'
+title: "hello world"
+publishDate: "2023-01-01"
+---
+
+import HelloWorld from '../components/HelloWorld.astro';
+
+# Test
+
+123
+
+<HelloWorld />
+
+456
diff --git a/packages/integrations/mdx/test/fixtures/css-head-mdx/src/test.mdx b/packages/integrations/mdx/test/fixtures/css-head-mdx/src/test.mdx new file mode 100644 index 000000000..c8ecc4daa --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/css-head-mdx/src/test.mdx @@ -0,0 +1,14 @@ +---
+title: "hello world"
+publishDate: "2023-01-01"
+---
+
+import HelloWorld from './components/HelloWorld.astro';
+
+# Test
+
+123
+
+<HelloWorld />
+
+456
|