Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/short-snails-admire.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Prevents script deduplication state from being consumed while rendering inert `<template>` contexts.
20 changes: 20 additions & 0 deletions packages/astro/src/runtime/server/render/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,20 @@ function stringifyChunk(
switch (instruction.type) {
case 'directive': {
const { hydration } = instruction;
// Script tags inside <template> are inert and won't execute, so
// encountering hydration instructions here must not consume global
// dedup state needed by executable contexts later in the document.
if (result._metadata.templateDepth > 0) {
Comment thread
enjoyandlove marked this conversation as resolved.
Outdated
const needsHydrationScript = !result._metadata.hasHydrationScript;
const needsDirectiveScript = !result._metadata.hasDirectives.has(hydration.directive);
if (needsHydrationScript) {
return getPrescripts(result, 'both', hydration.directive);
}
if (needsDirectiveScript) {
return getPrescripts(result, 'directive', hydration.directive);
}
return '';
}
let needsHydrationScript = hydration && determineIfNeedsHydrationScript(result);
let needsDirectiveScript =
hydration && determinesIfNeedsDirectiveScript(result, hydration.directive);
Expand Down Expand Up @@ -90,6 +104,9 @@ function stringifyChunk(
case 'renderer-hydration-script': {
const { rendererSpecificHydrationScripts } = result._metadata;
const { rendererName } = instruction;
if (result._metadata.templateDepth > 0) {
return instruction.render();
}

if (!rendererSpecificHydrationScripts.has(rendererName)) {
rendererSpecificHydrationScripts.add(rendererName);
Expand All @@ -98,6 +115,9 @@ function stringifyChunk(
return '';
}
case 'server-island-runtime': {
if (result._metadata.templateDepth > 0) {
return renderServerIslandRuntime();
}
if (result._metadata.hasRenderedServerIslandRuntime) {
return '';
}
Expand Down
120 changes: 120 additions & 0 deletions packages/astro/test/units/app/inert-script-dedup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { App } from '../../../dist/core/app/app.js';
import { createRenderInstruction } from '../../../dist/runtime/server/render/instruction.js';
import { createComponent, render, templateEnter, templateExit } from '../../../dist/runtime/server/index.js';
import { createManifest, createRouteInfo } from './test-helpers.ts';

const hydrationRouteData = {
route: '/inert-hydration',
component: 'src/pages/inert-hydration.astro',
params: [],
pathname: '/inert-hydration',
distURL: [],
pattern: /^\/inert-hydration\/?$/,
segments: [[{ content: 'inert-hydration', dynamic: false, spread: false }]],
type: 'page' as const,
prerender: false,
fallbackRoutes: [],
isIndex: false,
origin: 'project' as const,
};

const serverIslandRouteData = {
route: '/inert-server-island-runtime',
component: 'src/pages/inert-server-island-runtime.astro',
params: [],
pathname: '/inert-server-island-runtime',
distURL: [],
pattern: /^\/inert-server-island-runtime\/?$/,
segments: [[{ content: 'inert-server-island-runtime', dynamic: false, spread: false }]],
type: 'page' as const,
prerender: false,
fallbackRoutes: [],
isIndex: false,
origin: 'project' as const,
};

const hydrationInstruction = createRenderInstruction({
type: 'directive',
hydration: {
directive: 'load',
value: '',
componentUrl: '',
componentExport: { value: '' },
},
});

const serverIslandInstruction = createRenderInstruction({ type: 'server-island-runtime' });

const hydrationPage = createComponent((result: any) => {
return render`
<template id="inert-hydration-template">
${templateEnter(result)}
${hydrationInstruction}
${templateExit(result)}
</template>
<div id="hydration-runtime">${hydrationInstruction}</div>
`;
});

const serverIslandPage = createComponent((result: any) => {
return render`
<template id="inert-server-island-template">
${templateEnter(result)}
${serverIslandInstruction}
${templateExit(result)}
</template>
<div id="server-island-runtime">${serverIslandInstruction}</div>
`;
});

const pageMap = new Map([
[
hydrationRouteData.component,
async () => ({
page: async () => ({
default: hydrationPage,
}),
}),
],
[
serverIslandRouteData.component,
async () => ({
page: async () => ({
default: serverIslandPage,
}),
}),
],
]);

const app = new App(
createManifest({
routes: [
createRouteInfo(hydrationRouteData),
createRouteInfo(serverIslandRouteData),
],
clientDirectives: new Map([['load', 'console.log("directive")']]),
pageMap: pageMap as any,
}) as any,
);

describe('Inert template script deduplication', () => {
it('keeps hydration prescripts available after template content', async () => {
const response = await app.render(new Request('http://example.com/inert-hydration'));
const html = await response.text();

assert.equal(countOccurrences(html, 'console.log("directive")'), 2);
});

it('does not consume server-island runtime dedup inside template content', async () => {
const response = await app.render(new Request('http://example.com/inert-server-island-runtime'));
const html = await response.text();

assert.equal(countOccurrences(html, 'replaceServerIsland('), 2);
});
});

function countOccurrences(content: string, needle: string) {
return content.split(needle).length - 1;
}
106 changes: 106 additions & 0 deletions packages/astro/test/units/render/inert-script-dedup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { chunkToString } from '../../../dist/runtime/server/render/common.js';
import { createRenderInstruction } from '../../../dist/runtime/server/render/instruction.js';

function createStubResult() {
return {
clientDirectives: new Map([['load', 'console.log("directive")']]),
_metadata: {
hasHydrationScript: false,
rendererSpecificHydrationScripts: new Set<string>(),
hasRenderedHead: false,
renderedScripts: new Set<string>(),
hasDirectives: new Set<string>(),
hasRenderedServerIslandRuntime: false,
headInTree: false,
extraHead: [],
extraStyleHashes: [],
extraScriptHashes: [],
propagators: new Set(),
templateDepth: 0,
},
};
}

describe('inert context dedup behavior', () => {
it('does not consume directive or hydration dedup inside templates', () => {
const result = createStubResult();
result._metadata.templateDepth = 1;

const instruction = createRenderInstruction({
type: 'directive',
hydration: {
directive: 'load',
value: '',
componentUrl: '',
componentExport: { value: '' },
},
});

const inertOutput = chunkToString(result as any, instruction).toString();
assert.match(inertOutput, /<script>/);
assert.equal(result._metadata.hasHydrationScript, false);
assert.equal(result._metadata.hasDirectives.has('load'), false);

result._metadata.templateDepth = 0;
const executableOutput = chunkToString(result as any, instruction).toString();
assert.match(executableOutput, /<script>/);
assert.equal(result._metadata.hasHydrationScript, true);
assert.equal(result._metadata.hasDirectives.has('load'), true);
});

it('does not emit inert directive scripts when already deduplicated', () => {
const result = createStubResult();
const instruction = createRenderInstruction({
type: 'directive',
hydration: {
directive: 'load',
value: '',
componentUrl: '',
componentExport: { value: '' },
},
});

result._metadata.hasHydrationScript = true;
result._metadata.hasDirectives.add('load');
result._metadata.templateDepth = 1;

const inertOutput = chunkToString(result as any, instruction);
assert.equal(inertOutput, '');
});

it('does not consume renderer hydration dedup inside templates', () => {
const result = createStubResult();
const rendererInstruction = createRenderInstruction({
type: 'renderer-hydration-script',
rendererName: 'react',
render: () => '<script>window.__react = true;</script>',
});

result._metadata.templateDepth = 1;
const inertOutput = chunkToString(result as any, rendererInstruction).toString();
assert.match(inertOutput, /__react/);
assert.equal(result._metadata.rendererSpecificHydrationScripts.has('react'), false);

result._metadata.templateDepth = 0;
const executableOutput = chunkToString(result as any, rendererInstruction).toString();
assert.match(executableOutput, /__react/);
assert.equal(result._metadata.rendererSpecificHydrationScripts.has('react'), true);
});

it('does not consume server-island runtime dedup inside templates', () => {
const result = createStubResult();
const instruction = createRenderInstruction({ type: 'server-island-runtime' });

result._metadata.templateDepth = 1;
const inertOutput = chunkToString(result as any, instruction).toString();
assert.match(inertOutput, /replaceServerIsland/);
assert.equal(result._metadata.hasRenderedServerIslandRuntime, false);

result._metadata.templateDepth = 0;
const executableOutput = chunkToString(result as any, instruction).toString();
assert.match(executableOutput, /replaceServerIsland/);
assert.equal(result._metadata.hasRenderedServerIslandRuntime, true);
});
});
Loading