Skip to content
Open
Show file tree
Hide file tree
Changes from all 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/warn-page-partials-stripped-assets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"astro": patch
---

Warn when page partials contain component assets that will be stripped from the partial HTML output.
16 changes: 16 additions & 0 deletions packages/astro/src/core/pages/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const EMPTY_SLOTS: Record<string, never> = Object.freeze({});
*/
export class PagesHandler {
#pipeline: Pipeline;
#warnedPartialAssets = new Set<string>();

constructor(pipeline: Pipeline) {
this.#pipeline = pipeline;
Expand Down Expand Up @@ -73,6 +74,21 @@ export class PagesHandler {
throw e;
}

// Partial pages strip `<head>` content (scoped styles, hoisted scripts,
// and other propagated head parts) from the response. Warn once per
// route component when the rendered tree contained such assets so the
// loss isn't silent.
if (result.partial && result._metadata.propagators.size > 0) {
const componentKey = state.routeData!.component;
if (!this.#warnedPartialAssets.has(componentKey)) {
this.#warnedPartialAssets.add(componentKey);
logger.warn(
null,
`The page ${componentKey} is a partial but its component tree includes Astro component scripts or scoped styles. These will be stripped from the HTML output. See https://docs.astro.build/en/basics/astro-pages/#page-partials for more information.`,
);
}
}

// Signal to the i18n middleware to maybe act on this response
response.headers.set(ROUTE_TYPE_HEADER, 'page');
// Signal to the error-page-rerouting infra to let this response pass through to avoid loops
Expand Down
142 changes: 142 additions & 0 deletions packages/astro/test/units/render/partials-warnings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import * as assert from 'node:assert/strict';
import { before, describe, it } from 'node:test';
import { FetchState } from '../../../dist/core/fetch/fetch-state.js';
import {
createComponent,
createHeadAndContent,
render,
renderComponent,
renderUniqueStylesheet,
unescapeHTML,
} from '../../../dist/runtime/server/index.js';
import type { AstroComponentFactory } from '../../../dist/runtime/server/render/index.js';
import type { Pipeline } from '../../../dist/core/render/index.js';
import { AstroMiddleware } from '../../../dist/core/middleware/astro-middleware.js';
import { ActionHandler } from '../../../dist/actions/handler.js';
import { PagesHandler } from '../../../dist/core/pages/handler.js';
import { createBasicPipeline, SpyLogger } from '../test-utils.ts';

const createAstroModule = (AstroComponent: AstroComponentFactory, partial = false) => ({
default: AstroComponent,
partial,
});

async function renderPartialPage(
pipeline: Pipeline,
pagesHandler: PagesHandler,
Component: AstroComponentFactory,
{
component = 'src/pages/partial.astro',
partial = true,
}: { component?: string; partial?: boolean } = {},
) {
const request = new Request(`http://example.com/partial`);
const routeData = {
type: 'page',
pathname: '/partial',
component,
params: {},
};
const state = new FetchState(pipeline, request);
state.routeData = routeData as any;
state.pathname = '/partial';
state.componentInstance = createAstroModule(Component, partial) as any;
state.slots = {};
const middleware = new AstroMiddleware(pipeline);
const actionHandler = new ActionHandler();
return middleware.handle(state, (s, ctx) => {
if (!s.skipMiddleware) {
const actionResult = actionHandler.handle(ctx, s);
if (actionResult) {
return actionResult.then((r) => r ?? pagesHandler.handle(s, ctx));
}
}
return pagesHandler.handle(s, ctx);
});
}

describe('Partials warn about stripped component assets', () => {
let logger: SpyLogger;
let pipeline: Pipeline;
let pagesHandler: PagesHandler;

function freshPipeline() {
logger = new SpyLogger({ level: 'warn' });
pipeline = createBasicPipeline({ logger });
pipeline.headElements = () => ({
links: new Set(),
scripts: new Set(),
styles: new Set(),
});
pagesHandler = new PagesHandler(pipeline);
return pipeline;
}

before(() => {
freshPipeline();
});

function makePropagatingPartial(): AstroComponentFactory {
const HeadEntry = createComponent({
factory(result: any, _props: any, _slots: any) {
const link = renderUniqueStylesheet(result, {
type: 'external',
src: '/some/fake/styles.css',
});
return createHeadAndContent(
unescapeHTML(link) as unknown as string,
render`<div id="other">Other</div>`,
);
},
propagation: 'self',
});

return createComponent(
(result: any) => render`<li>${renderComponent(result, 'HeadEntry', HeadEntry, {}, {})}</li>`,
);
}

it('warns when a partial page contains components with scoped styles or scripts', async () => {
freshPipeline();
const Partial = makePropagatingPartial();
await renderPartialPage(pipeline, pagesHandler, Partial);

const warnings = logger.logs.filter((entry) => entry.level === 'warn');
assert.equal(warnings.length, 1, 'expected exactly one warning');
assert.match(warnings[0].message, /partial/);
assert.match(warnings[0].message, /scoped styles/);
assert.match(warnings[0].message, /src\/pages\/partial\.astro/);
});

it('does not warn when a partial page has no stripped assets', async () => {
freshPipeline();
const Partial = createComponent(() => render`<li>Plain partial</li>`);
await renderPartialPage(pipeline, pagesHandler, Partial);

const warnings = logger.logs.filter((entry) => entry.level === 'warn');
assert.equal(warnings.length, 0);
});

it('does not warn when the page is not a partial', async () => {
freshPipeline();
const Page = makePropagatingPartial();
await renderPartialPage(pipeline, pagesHandler, Page, {
partial: false,
component: 'src/pages/not-partial.astro',
});

const warnings = logger.logs.filter((entry) => entry.level === 'warn');
assert.equal(warnings.length, 0);
});

it('warns only once per route component across repeated renders', async () => {
freshPipeline();
const Partial = makePropagatingPartial();
await renderPartialPage(pipeline, pagesHandler, Partial);
await renderPartialPage(pipeline, pagesHandler, Partial);
await renderPartialPage(pipeline, pagesHandler, Partial);

const warnings = logger.logs.filter((entry) => entry.level === 'warn');
assert.equal(warnings.length, 1, 'expected dedupe across repeated renders');
});
});
Loading