Skip to content

Commit

Permalink
Simplified head injection (#6034)
Browse files Browse the repository at this point in the history
* Simplified head injection

* Make renderHead also yield an instruction

* Add changeset

* Add mdx test
  • Loading branch information
matthewp authored Jan 30, 2023
1 parent cf60412 commit 071e1de
Show file tree
Hide file tree
Showing 20 changed files with 411 additions and 54 deletions.
5 changes: 5 additions & 0 deletions .changeset/good-items-rest.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Ensure CSS injections properly when using multiple layouts
2 changes: 1 addition & 1 deletion packages/astro/src/@types/astro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ const headAndContentSym = Symbol.for('astro.headAndContent');

export type HeadAndContent = {
[headAndContentSym]: true;
head: string | RenderTemplateResult;
head: string;
content: RenderTemplateResult;
};

Expand All @@ -13,7 +13,7 @@ export function isHeadAndContent(obj: unknown): obj is HeadAndContent {
}

export function createHeadAndContent(
head: string | RenderTemplateResult,
head: string,
content: RenderTemplateResult
): HeadAndContent {
return {
Expand Down
67 changes: 38 additions & 29 deletions packages/astro/src/runtime/server/render/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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();
}
}

Expand Down
35 changes: 15 additions & 20 deletions packages/astro/src/runtime/server/render/head.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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));
Expand All @@ -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;
}
2 changes: 1 addition & 1 deletion packages/astro/src/runtime/server/render/slot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
}
Expand Down
9 changes: 8 additions & 1 deletion packages/astro/src/runtime/server/render/types.ts
Original file line number Diff line number Diff line change
@@ -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;
181 changes: 181 additions & 0 deletions packages/astro/test/units/render/head.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Loading

0 comments on commit 071e1de

Please sign in to comment.