diff --git a/.changeset/good-items-rest.md b/.changeset/good-items-rest.md new file mode 100644 index 000000000000..7768f7aa331e --- /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 c1c5f585ab64..7fab1556fe90 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -1440,7 +1440,7 @@ export interface SSRResult { links: Set; propagation: Map; propagators: Map; - extraHead: Array; + extraHead: Array; 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 57f05425d391..fbadcb6f175a 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 9563959d262b..c9d17b64cbe1 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 c02e973f17fa..83c2c173b3da 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 component // This accommodates the fact that using a 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 52a1d59a259a..cd9225be42dc 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 a9e1afe6527a..3744037a08c4 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 000000000000..30ce69781861 --- /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` + + ${renderSlot(result, slots['head'])} + ${renderHead(result)} + + ${maybeRenderHead(result)} + + ${renderSlot(result, slots['default'])} + + `; + }) + + const PageLayout = createComponent((result, _props, slots) => { + return render`${renderComponent(result, 'Layout', BaseLayout, {}, { + 'default': () => render` + ${maybeRenderHead(result)} +
+ ${renderSlot(result, slots['default'])} +
+ `, + '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)}
hello world
`, + 'head': () => render` + ${renderComponent(result, 'Fragment', Fragment, {slot: 'head'}, { + 'default': () => render`` + })} + ` + })}`; + }); + + 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` + ${renderSlot(result, slots['head'])} + ${maybeRenderHead(result)} + + ${renderSlot(result, slots['default'])} + + `; + }) + + const PageLayout = createComponent((result, _props, slots) => { + return render`${renderComponent(result, 'Layout', BaseLayout, {}, { + 'default': () => render` + ${maybeRenderHead(result)} +
+ ${renderSlot(result, slots['default'])} +
+ `, + '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)}
hello world
`, + 'head': () => render` + ${renderComponent(result, 'Fragment', Fragment, {slot: 'head'}, { + 'default': () => render`` + })} + ` + })}`; + }); + + 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)}
hello world
`, + })}`; + }); + + 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 000000000000..a6492c3ba17b --- /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 000000000000..ee8084b4600b --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/css-head-mdx/src/components/HelloWorld.astro @@ -0,0 +1,11 @@ +--- +--- + +

Hello world!!

+ + + + + \ 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 000000000000..b9916e106488 --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/css-head-mdx/src/layouts/One.astro @@ -0,0 +1,15 @@ +--- +--- + + + + + + + + Astro + + + + + \ 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 000000000000..3f0fdfa723ce --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/css-head-mdx/src/layouts/Three.astro @@ -0,0 +1,6 @@ +--- +import Two from './Two.astro' +--- + + + \ 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 000000000000..51f0ca18cbb5 --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/css-head-mdx/src/layouts/Two.astro @@ -0,0 +1,6 @@ +--- +import One from './One.astro' +--- + + + \ 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 000000000000..f24bf4f3c288 --- /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' +--- + + +

Astro

+ +
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 000000000000..99be9677c06a --- /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' +--- + + +

Astro

+ +
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 000000000000..af07af926053 --- /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' +--- + + +

Astro

+ +
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 000000000000..6874b499f438 --- /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 + + + +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 000000000000..b0e55eed26f2 --- /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 + + + +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 000000000000..9a80ed5f0c22 --- /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 + + + +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 000000000000..c8ecc4daa844 --- /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 + + + +456