Skip to content

Commit 071e1de

Browse files
authored
Simplified head injection (#6034)
* Simplified head injection * Make renderHead also yield an instruction * Add changeset * Add mdx test
1 parent cf60412 commit 071e1de

File tree

20 files changed

+411
-54
lines changed

20 files changed

+411
-54
lines changed

.changeset/good-items-rest.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'astro': patch
3+
---
4+
5+
Ensure CSS injections properly when using multiple layouts

packages/astro/src/@types/astro.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1440,7 +1440,7 @@ export interface SSRResult {
14401440
links: Set<SSRElement>;
14411441
propagation: Map<string, PropagationHint>;
14421442
propagators: Map<AstroComponentFactory, AstroComponentInstance>;
1443-
extraHead: Array<any>;
1443+
extraHead: Array<string>;
14441444
cookies: AstroCookies | undefined;
14451445
createAstro(
14461446
Astro: AstroGlobalPartial,

packages/astro/src/runtime/server/render/astro/head-and-content.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ const headAndContentSym = Symbol.for('astro.headAndContent');
44

55
export type HeadAndContent = {
66
[headAndContentSym]: true;
7-
head: string | RenderTemplateResult;
7+
head: string;
88
content: RenderTemplateResult;
99
};
1010

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

1515
export function createHeadAndContent(
16-
head: string | RenderTemplateResult,
16+
head: string,
1717
content: RenderTemplateResult
1818
): HeadAndContent {
1919
return {

packages/astro/src/runtime/server/render/common.ts

Lines changed: 38 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { SSRResult } from '../../../@types/astro';
22
import type { RenderInstruction } from './types.js';
33

44
import { HTMLBytes, markHTMLString } from '../escape.js';
5+
import { renderAllHeadContent } from './head.js';
56
import {
67
determineIfNeedsHydrationScript,
78
determinesIfNeedsDirectiveScript,
@@ -20,40 +21,48 @@ export const decoder = new TextDecoder();
2021
// These directive instructions bubble all the way up to renderPage so that we
2122
// can ensure they are added only once, and as soon as possible.
2223
export function stringifyChunk(result: SSRResult, chunk: string | SlotString | RenderInstruction) {
23-
switch ((chunk as any).type) {
24-
case 'directive': {
25-
const { hydration } = chunk as RenderInstruction;
26-
let needsHydrationScript = hydration && determineIfNeedsHydrationScript(result);
27-
let needsDirectiveScript =
28-
hydration && determinesIfNeedsDirectiveScript(result, hydration.directive);
29-
30-
let prescriptType: PrescriptType = needsHydrationScript
31-
? 'both'
32-
: needsDirectiveScript
33-
? 'directive'
34-
: null;
35-
if (prescriptType) {
36-
let prescripts = getPrescripts(prescriptType, hydration.directive);
37-
return markHTMLString(prescripts);
38-
} else {
39-
return '';
24+
if(typeof (chunk as any).type === 'string') {
25+
const instruction = chunk as RenderInstruction;
26+
switch(instruction.type) {
27+
case 'directive': {
28+
const { hydration } = instruction;
29+
let needsHydrationScript = hydration && determineIfNeedsHydrationScript(result);
30+
let needsDirectiveScript =
31+
hydration && determinesIfNeedsDirectiveScript(result, hydration.directive);
32+
33+
let prescriptType: PrescriptType = needsHydrationScript
34+
? 'both'
35+
: needsDirectiveScript
36+
? 'directive'
37+
: null;
38+
if (prescriptType) {
39+
let prescripts = getPrescripts(prescriptType, hydration.directive);
40+
return markHTMLString(prescripts);
41+
} else {
42+
return '';
43+
}
44+
}
45+
case 'head': {
46+
if(result._metadata.hasRenderedHead) {
47+
return '';
48+
}
49+
return renderAllHeadContent(result);
4050
}
4151
}
42-
default: {
43-
if (isSlotString(chunk as string)) {
44-
let out = '';
45-
const c = chunk as SlotString;
46-
if (c.instructions) {
47-
for (const instr of c.instructions) {
48-
out += stringifyChunk(result, instr);
49-
}
52+
} else {
53+
if (isSlotString(chunk as string)) {
54+
let out = '';
55+
const c = chunk as SlotString;
56+
if (c.instructions) {
57+
for (const instr of c.instructions) {
58+
out += stringifyChunk(result, instr);
5059
}
51-
out += chunk.toString();
52-
return out;
5360
}
54-
55-
return chunk.toString();
61+
out += chunk.toString();
62+
return out;
5663
}
64+
65+
return chunk.toString();
5766
}
5867
}
5968

Lines changed: 15 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import type { SSRResult } from '../../../@types/astro';
22

33
import { markHTMLString } from '../escape.js';
4-
import { renderChild } from './any.js';
54
import { renderElement } from './util.js';
65

76
// Filter out duplicate elements in our set
@@ -13,14 +12,8 @@ const uniqueElements = (item: any, index: number, all: any[]) => {
1312
);
1413
};
1514

16-
async function* renderExtraHead(result: SSRResult, base: string) {
17-
yield base;
18-
for (const part of result.extraHead) {
19-
yield* renderChild(part);
20-
}
21-
}
22-
23-
function renderAllHeadContent(result: SSRResult) {
15+
export function renderAllHeadContent(result: SSRResult) {
16+
result._metadata.hasRenderedHead = true;
2417
const styles = Array.from(result.styles)
2518
.filter(uniqueElements)
2619
.map((style) => renderElement('style', style));
@@ -35,29 +28,31 @@ function renderAllHeadContent(result: SSRResult) {
3528
.filter(uniqueElements)
3629
.map((link) => renderElement('link', link, false));
3730

38-
const baseHeadContent = markHTMLString(links.join('\n') + styles.join('\n') + scripts.join('\n'));
31+
let content = links.join('\n') + styles.join('\n') + scripts.join('\n');
3932

4033
if (result.extraHead.length > 0) {
41-
return renderExtraHead(result, baseHeadContent);
42-
} else {
43-
return baseHeadContent;
34+
for (const part of result.extraHead) {
35+
content += part;
36+
}
4437
}
45-
}
4638

47-
export function createRenderHead(result: SSRResult) {
48-
result._metadata.hasRenderedHead = true;
49-
return renderAllHeadContent.bind(null, result);
39+
return markHTMLString(content);
5040
}
5141

52-
export const renderHead = createRenderHead;
42+
export function * renderHead(result: SSRResult) {
43+
yield { type: 'head', result } as const;
44+
}
5345

5446
// This function is called by Astro components that do not contain a <head> component
5547
// This accommodates the fact that using a <head> is optional in Astro, so this
5648
// is called before a component's first non-head HTML element. If the head was
5749
// already injected it is a noop.
58-
export async function* maybeRenderHead(result: SSRResult) {
50+
export function* maybeRenderHead(result: SSRResult) {
5951
if (result._metadata.hasRenderedHead) {
6052
return;
6153
}
62-
yield createRenderHead(result)();
54+
55+
// This is an instruction informing the page rendering that head might need rendering.
56+
// This allows the page to deduplicate head injections.
57+
yield { type: 'head', result } as const;
6358
}

packages/astro/src/runtime/server/render/slot.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export async function renderSlot(_result: any, slotted: string, fallback?: any):
2626
let content = '';
2727
let instructions: null | RenderInstruction[] = null;
2828
for await (const chunk of iterator) {
29-
if ((chunk as any).type === 'directive') {
29+
if (typeof (chunk as any).type === 'string') {
3030
if (instructions === null) {
3131
instructions = [];
3232
}
Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
import type { SSRResult } from '../../../@types/astro';
22
import type { HydrationMetadata } from '../hydration.js';
33

4-
export interface RenderInstruction {
4+
export type RenderDirectiveInstruction = {
55
type: 'directive';
66
result: SSRResult;
77
hydration: HydrationMetadata;
8+
};
9+
10+
export type RenderHeadInstruction = {
11+
type: 'head';
12+
result: SSRResult;
813
}
14+
15+
export type RenderInstruction = RenderDirectiveInstruction | RenderHeadInstruction;
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import { expect } from 'chai';
2+
3+
import {
4+
createComponent,
5+
render,
6+
renderComponent,
7+
renderSlot,
8+
maybeRenderHead,
9+
renderHead,
10+
Fragment
11+
} from '../../../dist/runtime/server/index.js';
12+
import {
13+
createBasicEnvironment,
14+
createRenderContext,
15+
renderPage,
16+
} from '../../../dist/core/render/index.js';
17+
import { defaultLogging as logging } from '../../test-utils.js';
18+
import * as cheerio from 'cheerio';
19+
20+
const createAstroModule = (AstroComponent) => ({ default: AstroComponent });
21+
22+
describe('core/render', () => {
23+
describe('Injected head contents', () => {
24+
let env;
25+
before(async () => {
26+
env = createBasicEnvironment({
27+
logging,
28+
renderers: [],
29+
});
30+
});
31+
32+
it('Multi-level layouts and head injection, with explicit head', async () => {
33+
const BaseLayout = createComponent((result, _props, slots) => {
34+
return render`<html>
35+
<head>
36+
${renderSlot(result, slots['head'])}
37+
${renderHead(result)}
38+
</head>
39+
${maybeRenderHead(result)}
40+
<body>
41+
${renderSlot(result, slots['default'])}
42+
</body>
43+
</html>`;
44+
})
45+
46+
const PageLayout = createComponent((result, _props, slots) => {
47+
return render`${renderComponent(result, 'Layout', BaseLayout, {}, {
48+
'default': () => render`
49+
${maybeRenderHead(result)}
50+
<main>
51+
${renderSlot(result, slots['default'])}
52+
</main>
53+
`,
54+
'head': () => render`
55+
${renderComponent(result, 'Fragment', Fragment, { slot: 'head' }, {
56+
'default': () => render`${renderSlot(result, slots['head'])}`
57+
})}
58+
`
59+
})}
60+
`;
61+
});
62+
63+
const Page = createComponent((result, _props) => {
64+
return render`${renderComponent(result, 'PageLayout', PageLayout, {}, {
65+
'default': () => render`${maybeRenderHead(result)}<div>hello world</div>`,
66+
'head': () => render`
67+
${renderComponent(result, 'Fragment', Fragment, {slot: 'head'}, {
68+
'default': () => render`<meta charset="utf-8">`
69+
})}
70+
`
71+
})}`;
72+
});
73+
74+
const ctx = createRenderContext({
75+
request: new Request('http://example.com/'),
76+
links: [
77+
{ name: 'link', props: {rel:'stylesheet', href:'/main.css'}, children: '' }
78+
]
79+
});
80+
const PageModule = createAstroModule(Page);
81+
82+
const response = await renderPage(PageModule, ctx, env);
83+
84+
const html = await response.text();
85+
const $ = cheerio.load(html);
86+
87+
expect($('head link')).to.have.a.lengthOf(1);
88+
expect($('body link')).to.have.a.lengthOf(0);
89+
});
90+
91+
it('Multi-level layouts and head injection, without explicit head', async () => {
92+
const BaseLayout = createComponent((result, _props, slots) => {
93+
return render`<html>
94+
${renderSlot(result, slots['head'])}
95+
${maybeRenderHead(result)}
96+
<body>
97+
${renderSlot(result, slots['default'])}
98+
</body>
99+
</html>`;
100+
})
101+
102+
const PageLayout = createComponent((result, _props, slots) => {
103+
return render`${renderComponent(result, 'Layout', BaseLayout, {}, {
104+
'default': () => render`
105+
${maybeRenderHead(result)}
106+
<main>
107+
${renderSlot(result, slots['default'])}
108+
</main>
109+
`,
110+
'head': () => render`
111+
${renderComponent(result, 'Fragment', Fragment, { slot: 'head' }, {
112+
'default': () => render`${renderSlot(result, slots['head'])}`
113+
})}
114+
`
115+
})}
116+
`;
117+
});
118+
119+
const Page = createComponent((result, _props) => {
120+
return render`${renderComponent(result, 'PageLayout', PageLayout, {}, {
121+
'default': () => render`${maybeRenderHead(result)}<div>hello world</div>`,
122+
'head': () => render`
123+
${renderComponent(result, 'Fragment', Fragment, {slot: 'head'}, {
124+
'default': () => render`<meta charset="utf-8">`
125+
})}
126+
`
127+
})}`;
128+
});
129+
130+
const ctx = createRenderContext({
131+
request: new Request('http://example.com/'),
132+
links: [
133+
{ name: 'link', props: {rel:'stylesheet', href:'/main.css'}, children: '' }
134+
]
135+
});
136+
const PageModule = createAstroModule(Page);
137+
138+
const response = await renderPage(PageModule, ctx, env);
139+
140+
const html = await response.text();
141+
const $ = cheerio.load(html);
142+
143+
expect($('head link')).to.have.a.lengthOf(1);
144+
expect($('body link')).to.have.a.lengthOf(0);
145+
});
146+
147+
it('Multi-level layouts and head injection, without any content in layouts', async () => {
148+
const BaseLayout = createComponent((result, _props, slots) => {
149+
return render`${renderSlot(result, slots['default'])}`;
150+
})
151+
152+
const PageLayout = createComponent((result, _props, slots) => {
153+
return render`${renderComponent(result, 'Layout', BaseLayout, {}, {
154+
'default': () => render`${renderSlot(result, slots['default'])} `,
155+
})}
156+
`;
157+
});
158+
159+
const Page = createComponent((result, _props) => {
160+
return render`${renderComponent(result, 'PageLayout', PageLayout, {}, {
161+
'default': () => render`${maybeRenderHead(result)}<div>hello world</div>`,
162+
})}`;
163+
});
164+
165+
const ctx = createRenderContext({
166+
request: new Request('http://example.com/'),
167+
links: [
168+
{ name: 'link', props: {rel:'stylesheet', href:'/main.css'}, children: '' }
169+
]
170+
});
171+
const PageModule = createAstroModule(Page);
172+
173+
const response = await renderPage(PageModule, ctx, env);
174+
175+
const html = await response.text();
176+
const $ = cheerio.load(html);
177+
178+
expect($('link')).to.have.a.lengthOf(1);
179+
});
180+
});
181+
});

0 commit comments

Comments
 (0)