summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGravatar Matthew Phillips <matthew@skypack.dev> 2022-12-06 16:26:15 -0500
committerGravatar GitHub <noreply@github.com> 2022-12-06 16:26:15 -0500
commit05915fec01a51f27ab5051644f01e6112ecf06bc (patch)
treeddc4cc625cb4402e0d3598bd5c1cfaebca69853a
parentb1376576994146b315c12240fe7dac80893fc101 (diff)
downloadastro-05915fec01a51f27ab5051644f01e6112ecf06bc.tar.gz
astro-05915fec01a51f27ab5051644f01e6112ecf06bc.tar.zst
astro-05915fec01a51f27ab5051644f01e6112ecf06bc.zip
Head propagation (#5511)
* Head propagation * Adding a changeset * Fix broken build * Self review stuff * Use compiler prerelease exact version * new compiler version * Update packages/astro/src/vite-plugin-head-propagation/index.ts Co-authored-by: Bjorn Lu <bjornlu.dev@gmail.com> * Use getAstroMetadata * add .js * make relative lookup work on win * Use compiler@0.30.0 * PR review comments * Make renderHead an alias for a better named function Co-authored-by: Bjorn Lu <bjornlu.dev@gmail.com>
Diffstat (limited to '')
-rw-r--r--.changeset/cool-jobs-draw.md7
-rw-r--r--packages/astro/package.json6
-rw-r--r--packages/astro/src/@types/astro.ts17
-rw-r--r--packages/astro/src/core/compile/compile.ts3
-rw-r--r--packages/astro/src/core/create-vite.ts2
-rw-r--r--packages/astro/src/core/render/context.ts3
-rw-r--r--packages/astro/src/core/render/core.ts1
-rw-r--r--packages/astro/src/core/render/dev/head.ts34
-rw-r--r--packages/astro/src/core/render/dev/index.ts34
-rw-r--r--packages/astro/src/core/render/result.ts4
-rw-r--r--packages/astro/src/jsx/babel.ts1
-rw-r--r--packages/astro/src/runtime/server/astro-component.ts29
-rw-r--r--packages/astro/src/runtime/server/astro-global.ts1
-rw-r--r--packages/astro/src/runtime/server/index.ts18
-rw-r--r--packages/astro/src/runtime/server/jsx.ts6
-rw-r--r--packages/astro/src/runtime/server/render/any.ts14
-rw-r--r--packages/astro/src/runtime/server/render/astro.ts146
-rw-r--r--packages/astro/src/runtime/server/render/astro/factory.ts53
-rw-r--r--packages/astro/src/runtime/server/render/astro/head-and-content.ts24
-rw-r--r--packages/astro/src/runtime/server/render/astro/index.ts25
-rw-r--r--packages/astro/src/runtime/server/render/astro/instance.ts82
-rw-r--r--packages/astro/src/runtime/server/render/astro/render-template.ts83
-rw-r--r--packages/astro/src/runtime/server/render/component.ts134
-rw-r--r--packages/astro/src/runtime/server/render/head.ts31
-rw-r--r--packages/astro/src/runtime/server/render/index.ts15
-rw-r--r--packages/astro/src/runtime/server/render/page.ts41
-rw-r--r--packages/astro/src/runtime/server/render/stylesheet.ts25
-rw-r--r--packages/astro/src/vite-plugin-astro/index.ts6
-rw-r--r--packages/astro/src/vite-plugin-astro/metadata.ts9
-rw-r--r--packages/astro/src/vite-plugin-astro/types.ts2
-rw-r--r--packages/astro/src/vite-plugin-head-propagation/index.ts54
-rw-r--r--packages/astro/src/vite-plugin-load-fallback/index.ts2
-rw-r--r--packages/astro/src/vite-plugin-markdown-legacy/index.ts2
-rw-r--r--packages/astro/src/vite-plugin-markdown/index.ts1
-rw-r--r--packages/astro/test/units/dev/head-injection.test.js160
-rw-r--r--pnpm-lock.yaml8
36 files changed, 804 insertions, 279 deletions
diff --git a/.changeset/cool-jobs-draw.md b/.changeset/cool-jobs-draw.md
new file mode 100644
index 000000000..1fee55e54
--- /dev/null
+++ b/.changeset/cool-jobs-draw.md
@@ -0,0 +1,7 @@
+---
+'astro': patch
+---
+
+Low-level head propagation
+
+This adds low-level head propagation ability within the Astro runtime. This is not really useable within an Astro app at the moment, but provides the APIs necessary for `renderEntry` to do head propagation.
diff --git a/packages/astro/package.json b/packages/astro/package.json
index 5fbc5bd34..f75ca1131 100644
--- a/packages/astro/package.json
+++ b/packages/astro/package.json
@@ -100,7 +100,7 @@
"test:e2e:match": "playwright test -g"
},
"dependencies": {
- "@astrojs/compiler": "^0.29.15",
+ "@astrojs/compiler": "^0.30.0",
"@astrojs/language-server": "^0.28.3",
"@astrojs/markdown-remark": "^1.1.3",
"@astrojs/telemetry": "^1.0.1",
@@ -111,11 +111,11 @@
"@babel/plugin-transform-react-jsx": "^7.17.12",
"@babel/traverse": "^7.18.2",
"@babel/types": "^7.18.4",
+ "@proload/core": "^0.3.3",
+ "@proload/plugin-tsm": "^0.2.1",
"@types/babel__core": "^7.1.19",
"@types/html-escaper": "^3.0.0",
"@types/yargs-parser": "^21.0.0",
- "@proload/core": "^0.3.3",
- "@proload/plugin-tsm": "^0.2.1",
"boxen": "^6.2.1",
"ci-info": "^3.3.1",
"common-ancestor-path": "^1.0.1",
diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts
index a6bafdad7..ce117f3ac 100644
--- a/packages/astro/src/@types/astro.ts
+++ b/packages/astro/src/@types/astro.ts
@@ -16,7 +16,7 @@ import type { SerializedSSRManifest } from '../core/app/types';
import type { PageBuildData } from '../core/build/types';
import type { AstroConfigSchema } from '../core/config';
import type { AstroCookies } from '../core/cookies';
-import type { AstroComponentFactory } from '../runtime/server';
+import type { AstroComponentFactory, AstroComponentInstance } from '../runtime/server';
import { SUPPORTED_MARKDOWN_FILE_EXTENSIONS } from './../core/constants.js';
export type {
MarkdownHeading,
@@ -1398,10 +1398,25 @@ export interface SSRMetadata {
hasRenderedHead: boolean;
}
+/**
+ * A hint on whether the Astro runtime needs to wait on a component to render head
+ * content. The meanings:
+ *
+ * - __none__ (default) The component does not propagation head content.
+ * - __self__ The component appends head content.
+ * - __in-tree__ Another component within this component's dependency tree appends head content.
+ *
+ * These are used within the runtime to know whether or not a component should be waited on.
+ */
+export type PropagationHint = 'none' | 'self' | 'in-tree';
+
export interface SSRResult {
styles: Set<SSRElement>;
scripts: Set<SSRElement>;
links: Set<SSRElement>;
+ propagation: Map<string, PropagationHint>;
+ propagators: Map<AstroComponentFactory, AstroComponentInstance>;
+ extraHead: Array<any>;
cookies: AstroCookies | undefined;
createAstro(
Astro: AstroGlobalPartial,
diff --git a/packages/astro/src/core/compile/compile.ts b/packages/astro/src/core/compile/compile.ts
index 9936fc5e3..e431f1bb3 100644
--- a/packages/astro/src/core/compile/compile.ts
+++ b/packages/astro/src/core/compile/compile.ts
@@ -12,6 +12,7 @@ export interface CompileProps {
astroConfig: AstroConfig;
viteConfig: ResolvedConfig;
filename: string;
+ id: string | undefined;
source: string;
}
@@ -24,6 +25,7 @@ export async function compile({
astroConfig,
viteConfig,
filename,
+ id: moduleId,
source,
}: CompileProps): Promise<CompileResult> {
const cssDeps = new Set<string>();
@@ -35,6 +37,7 @@ export async function compile({
// use `sourcemap: "both"` so that sourcemap is included in the code
// result passed to esbuild, but also available in the catch handler.
transformResult = await transform(source, {
+ moduleId,
pathname: filename,
projectRoot: astroConfig.root.toString(),
site: astroConfig.site?.toString(),
diff --git a/packages/astro/src/core/create-vite.ts b/packages/astro/src/core/create-vite.ts
index da774e9ac..576ef0469 100644
--- a/packages/astro/src/core/create-vite.ts
+++ b/packages/astro/src/core/create-vite.ts
@@ -18,6 +18,7 @@ import legacyMarkdownVitePlugin from '../vite-plugin-markdown-legacy/index.js';
import markdownVitePlugin from '../vite-plugin-markdown/index.js';
import astroScriptsPlugin from '../vite-plugin-scripts/index.js';
import astroScriptsPageSSRPlugin from '../vite-plugin-scripts/page-ssr.js';
+import astroHeadPropagationPlugin from '../vite-plugin-head-propagation/index.js';
import { createCustomViteLogger } from './errors/dev/index.js';
import { resolveDependency } from './util.js';
@@ -112,6 +113,7 @@ export async function createVite(
astroPostprocessVitePlugin({ settings }),
astroIntegrationsContainerPlugin({ settings, logging }),
astroScriptsPageSSRPlugin({ settings }),
+ astroHeadPropagationPlugin({ settings }),
],
publicDir: fileURLToPath(settings.config.publicDir),
root: fileURLToPath(settings.config.root),
diff --git a/packages/astro/src/core/render/context.ts b/packages/astro/src/core/render/context.ts
index ab7563862..6e453fea0 100644
--- a/packages/astro/src/core/render/context.ts
+++ b/packages/astro/src/core/render/context.ts
@@ -1,4 +1,4 @@
-import type { RouteData, SSRElement } from '../../@types/astro';
+import type { RouteData, SSRElement, SSRResult } from '../../@types/astro';
/**
* The RenderContext represents the parts of rendering that are specific to one request.
@@ -11,6 +11,7 @@ export interface RenderContext {
scripts?: Set<SSRElement>;
links?: Set<SSRElement>;
styles?: Set<SSRElement>;
+ propagation?: SSRResult['propagation'];
route?: RouteData;
status?: number;
}
diff --git a/packages/astro/src/core/render/core.ts b/packages/astro/src/core/render/core.ts
index 86aa7fb3f..0516a4d8f 100644
--- a/packages/astro/src/core/render/core.ts
+++ b/packages/astro/src/core/render/core.ts
@@ -98,6 +98,7 @@ export async function renderPage(mod: ComponentInstance, ctx: RenderContext, env
params,
props: pageProps,
pathname: ctx.pathname,
+ propagation: ctx.propagation,
resolve: env.resolve,
renderers: env.renderers,
request: ctx.request,
diff --git a/packages/astro/src/core/render/dev/head.ts b/packages/astro/src/core/render/dev/head.ts
new file mode 100644
index 000000000..9294192b3
--- /dev/null
+++ b/packages/astro/src/core/render/dev/head.ts
@@ -0,0 +1,34 @@
+import type { SSRResult } from '../../../@types/astro';
+
+import type { ModuleInfo, ModuleLoader } from '../../module-loader/index';
+
+import { viteID } from '../../util.js';
+import { getAstroMetadata } from '../../../vite-plugin-astro/index.js';
+import { crawlGraph } from './vite.js';
+
+export async function getPropagationMap(
+ filePath: URL,
+ loader: ModuleLoader
+): Promise<SSRResult['propagation']> {
+ const map: SSRResult['propagation'] = new Map();
+
+ const rootID = viteID(filePath);
+ addInjection(map, loader.getModuleInfo(rootID))
+ for await (const moduleNode of crawlGraph(loader, rootID, true)) {
+ const id = moduleNode.id;
+ if (id) {
+ addInjection(map, loader.getModuleInfo(id));
+ }
+ }
+
+ return map;
+}
+
+function addInjection(map: SSRResult['propagation'], modInfo: ModuleInfo | null) {
+ if(modInfo) {
+ const astro = getAstroMetadata(modInfo);
+ if(astro && astro.propagation) {
+ map.set(modInfo.id, astro.propagation)
+ }
+ }
+}
diff --git a/packages/astro/src/core/render/dev/index.ts b/packages/astro/src/core/render/dev/index.ts
index 805be1123..e35e152e8 100644
--- a/packages/astro/src/core/render/dev/index.ts
+++ b/packages/astro/src/core/render/dev/index.ts
@@ -3,48 +3,23 @@ import type {
AstroSettings,
ComponentInstance,
RouteData,
- RuntimeMode,
SSRElement,
SSRLoadedRenderer,
} from '../../../@types/astro';
import { PAGE_SCRIPT_ID } from '../../../vite-plugin-scripts/index.js';
import { enhanceViteSSRError } from '../../errors/dev/index.js';
import { AggregateError, CSSError, MarkdownError } from '../../errors/index.js';
-import { LogOptions } from '../../logger/core.js';
import type { ModuleLoader } from '../../module-loader/index';
import { isPage, resolveIdToUrl } from '../../util.js';
import { createRenderContext, renderPage as coreRenderPage } from '../index.js';
import { filterFoundRenderers, loadRenderer } from '../renderer.js';
-import { RouteCache } from '../route-cache.js';
import { getStylesForURL } from './css.js';
import type { DevelopmentEnvironment } from './environment';
import { getScriptsForURL } from './scripts.js';
+import { getPropagationMap } from './head.js';
export { createDevelopmentEnvironment } from './environment.js';
export type { DevelopmentEnvironment };
-export interface SSROptionsOld {
- /** an instance of the AstroSettings */
- settings: AstroSettings;
- /** location of file on disk */
- filePath: URL;
- /** logging options */
- logging: LogOptions;
- /** "development" or "production" */
- mode: RuntimeMode;
- /** production website */
- origin: string;
- /** the web request (needed for dynamic routes) */
- pathname: string;
- /** optional, in case we need to render something outside of a dev server */
- route?: RouteData;
- /** pass in route cache because SSR can’t manage cache-busting */
- routeCache: RouteCache;
- /** Module loader (Vite) */
- loader: ModuleLoader;
- /** Request */
- request: Request;
-}
-
export interface SSROptions {
/** The environment instance */
env: DevelopmentEnvironment;
@@ -163,7 +138,9 @@ async function getScriptsAndStyles({ env, filePath }: GetScriptsAndStylesParams)
});
});
- return { scripts, styles, links };
+ const propagationMap = await getPropagationMap(filePath, env.loader);
+
+ return { scripts, styles, links, propagationMap };
}
export async function renderPage(options: SSROptions): Promise<Response> {
@@ -173,7 +150,7 @@ export async function renderPage(options: SSROptions): Promise<Response> {
// The new instances are passed through.
options.env.renderers = renderers;
- const { scripts, links, styles } = await getScriptsAndStyles({
+ const { scripts, links, styles, propagationMap } = await getScriptsAndStyles({
env: options.env,
filePath: options.filePath,
});
@@ -185,6 +162,7 @@ export async function renderPage(options: SSROptions): Promise<Response> {
scripts,
links,
styles,
+ propagation: propagationMap,
route: options.route,
});
diff --git a/packages/astro/src/core/render/result.ts b/packages/astro/src/core/render/result.ts
index 342c7fbd8..20fb5d5a7 100644
--- a/packages/astro/src/core/render/result.ts
+++ b/packages/astro/src/core/render/result.ts
@@ -45,6 +45,7 @@ export interface CreateResultArgs {
links?: Set<SSRElement>;
scripts?: Set<SSRElement>;
styles?: Set<SSRElement>;
+ propagation?: SSRResult['propagation'];
request: Request;
status: number;
}
@@ -154,6 +155,9 @@ export function createResult(args: CreateResultArgs): SSRResult {
styles: args.styles ?? new Set<SSRElement>(),
scripts: args.scripts ?? new Set<SSRElement>(),
links: args.links ?? new Set<SSRElement>(),
+ propagation: args.propagation ?? new Map(),
+ propagators: new Map(),
+ extraHead: [],
cookies,
/** This function returns the `Astro` faux-global */
createAstro(
diff --git a/packages/astro/src/jsx/babel.ts b/packages/astro/src/jsx/babel.ts
index 541cad71c..8e8df454a 100644
--- a/packages/astro/src/jsx/babel.ts
+++ b/packages/astro/src/jsx/babel.ts
@@ -145,6 +145,7 @@ export default function astroJSX(): PluginObj {
clientOnlyComponents: [],
hydratedComponents: [],
scripts: [],
+ propagation: 'none',
};
}
path.node.body.splice(
diff --git a/packages/astro/src/runtime/server/astro-component.ts b/packages/astro/src/runtime/server/astro-component.ts
new file mode 100644
index 000000000..52b993f62
--- /dev/null
+++ b/packages/astro/src/runtime/server/astro-component.ts
@@ -0,0 +1,29 @@
+import type { PropagationHint } from '../../@types/astro';
+import type { AstroComponentFactory } from './render/index.js';
+
+function baseCreateComponent(cb: AstroComponentFactory, moduleId?: string) {
+ // Add a flag to this callback to mark it as an Astro component
+ cb.isAstroComponentFactory = true;
+ cb.moduleId = moduleId;
+ return cb;
+}
+
+interface CreateComponentOptions {
+ factory: AstroComponentFactory;
+ moduleId?: string;
+ propagation?: PropagationHint;
+}
+
+function createComponentWithOptions(opts: CreateComponentOptions) {
+ const cb = baseCreateComponent(opts.factory, opts.moduleId);
+ cb.propagation = opts.propagation;
+ return cb;
+}
+// Used in creating the component. aka the main export.
+export function createComponent(arg1: AstroComponentFactory, moduleId: string) {
+ if(typeof arg1 === 'function') {
+ return baseCreateComponent(arg1, moduleId);
+ } else {
+ return createComponentWithOptions(arg1);
+ }
+}
diff --git a/packages/astro/src/runtime/server/astro-global.ts b/packages/astro/src/runtime/server/astro-global.ts
index 101ec53ac..5dd17afce 100644
--- a/packages/astro/src/runtime/server/astro-global.ts
+++ b/packages/astro/src/runtime/server/astro-global.ts
@@ -39,6 +39,7 @@ export function createAstro(
fetchContent: createDeprecatedFetchContentFn(),
glob: createAstroGlobFn(),
// INVESTIGATE is there a use-case for multi args?
+ // TODO remove in 2.0
resolve(...segments: string[]) {
let resolved = segments.reduce((u, segment) => new URL(segment, u), referenceURL).pathname;
// When inside of project root, remove the leading path so you are
diff --git a/packages/astro/src/runtime/server/index.ts b/packages/astro/src/runtime/server/index.ts
index 6ae149917..519703b95 100644
--- a/packages/astro/src/runtime/server/index.ts
+++ b/packages/astro/src/runtime/server/index.ts
@@ -4,11 +4,13 @@ export { escapeHTML, HTMLBytes, HTMLString, markHTMLString, unescapeHTML } from
export { renderJSX } from './jsx.js';
export {
addAttribute,
+ createHeadAndContent,
defineScriptVars,
Fragment,
maybeRenderHead,
- renderAstroComponent,
+ renderAstroTemplateResult as renderAstroComponent,
renderComponent,
+ renderComponentToIterable,
Renderer as Renderer,
renderHead,
renderHTMLElement,
@@ -16,26 +18,18 @@ export {
renderSlot,
renderTemplate as render,
renderTemplate,
+ renderUniqueStylesheet,
renderToString,
stringifyChunk,
voidElementNames,
} from './render/index.js';
-export type { AstroComponentFactory, RenderInstruction } from './render/index.js';
-import type { AstroComponentFactory } from './render/index.js';
+export { createComponent } from './astro-component.js';
+export type { AstroComponentFactory, AstroComponentInstance, RenderInstruction } from './render/index.js';
import { markHTMLString } from './escape.js';
import { Renderer } from './render/index.js';
-
import { addAttribute } from './render/index.js';
-// Used in creating the component. aka the main export.
-export function createComponent(cb: AstroComponentFactory) {
- // Add a flag to this callback to mark it as an Astro component
- // INVESTIGATE does this need to cast
- (cb as any).isAstroComponentFactory = true;
- return cb;
-}
-
export function mergeSlots(...slotted: unknown[]) {
const slots: Record<string, () => any> = {};
for (const slot of slotted) {
diff --git a/packages/astro/src/runtime/server/jsx.ts b/packages/astro/src/runtime/server/jsx.ts
index 8b976b2e8..651ccc945 100644
--- a/packages/astro/src/runtime/server/jsx.ts
+++ b/packages/astro/src/runtime/server/jsx.ts
@@ -5,7 +5,7 @@ import {
escapeHTML,
HTMLString,
markHTMLString,
- renderComponent,
+ renderComponentToIterable,
renderToString,
spreadAttributes,
voidElementNames,
@@ -177,7 +177,7 @@ Did you forget to import the component or is it possible there is a typo?`);
props[Skip.symbol] = skip;
let output: ComponentIterable;
if (vnode.type === ClientOnlyPlaceholder && vnode.props['client:only']) {
- output = await renderComponent(
+ output = await renderComponentToIterable(
result,
vnode.props['client:display-name'] ?? '',
null,
@@ -185,7 +185,7 @@ Did you forget to import the component or is it possible there is a typo?`);
slots
);
} else {
- output = await renderComponent(
+ output = await renderComponentToIterable(
result,
typeof vnode.type === 'function' ? vnode.type.name : vnode.type,
vnode.type,
diff --git a/packages/astro/src/runtime/server/render/any.ts b/packages/astro/src/runtime/server/render/any.ts
index 1a19ef519..119dbc105 100644
--- a/packages/astro/src/runtime/server/render/any.ts
+++ b/packages/astro/src/runtime/server/render/any.ts
@@ -1,5 +1,6 @@
import { escapeHTML, isHTMLString, markHTMLString } from '../escape.js';
-import { AstroComponent, renderAstroComponent } from './astro.js';
+import { isRenderTemplateResult, renderAstroTemplateResult } from './astro/index.js';
+import { isAstroComponentInstance } from './astro/index.js';
import { SlotString } from './slot.js';
export async function* renderChild(child: any): AsyncIterable<any> {
@@ -25,13 +26,10 @@ export async function* renderChild(child: any): AsyncIterable<any> {
} else if (!child && child !== 0) {
// do nothing, safe to ignore falsey values.
}
- // Add a comment explaining why each of these are needed.
- // Maybe create clearly named function for what this is doing.
- else if (
- child instanceof AstroComponent ||
- Object.prototype.toString.call(child) === '[object AstroComponent]'
- ) {
- yield* renderAstroComponent(child);
+ else if(isRenderTemplateResult(child)) {
+ yield* renderAstroTemplateResult(child);
+ } else if(isAstroComponentInstance(child)) {
+ yield* child.render();
} else if (ArrayBuffer.isView(child)) {
yield child;
} else if (
diff --git a/packages/astro/src/runtime/server/render/astro.ts b/packages/astro/src/runtime/server/render/astro.ts
deleted file mode 100644
index 077bc6ca2..000000000
--- a/packages/astro/src/runtime/server/render/astro.ts
+++ /dev/null
@@ -1,146 +0,0 @@
-import type { SSRResult } from '../../../@types/astro';
-import type { AstroComponentFactory } from './index';
-import type { RenderInstruction } from './types';
-
-import { HTMLBytes, markHTMLString } from '../escape.js';
-import { HydrationDirectiveProps } from '../hydration.js';
-import { isPromise } from '../util.js';
-import { renderChild } from './any.js';
-import { HTMLParts } from './common.js';
-
-// Issue warnings for invalid props for Astro components
-function validateComponentProps(props: any, displayName: string) {
- if (props != null) {
- for (const prop of Object.keys(props)) {
- if (HydrationDirectiveProps.has(prop)) {
- // eslint-disable-next-line
- console.warn(
- `You are attempting to render <${displayName} ${prop} />, but ${displayName} is an Astro component. Astro components do not render in the client and should not have a hydration directive. Please use a framework component for client rendering.`
- );
- }
- }
- }
-}
-
-// The return value when rendering a component.
-// This is the result of calling render(), should this be named to RenderResult or...?
-export class AstroComponent {
- private htmlParts: TemplateStringsArray;
- private expressions: any[];
- private error: Error | undefined;
-
- constructor(htmlParts: TemplateStringsArray, expressions: any[]) {
- this.htmlParts = htmlParts;
- this.error = undefined;
- this.expressions = expressions.map((expression) => {
- // Wrap Promise expressions so we can catch errors
- // There can only be 1 error that we rethrow from an Astro component,
- // so this keeps track of whether or not we have already done so.
- if (isPromise(expression)) {
- return Promise.resolve(expression).catch((err) => {
- if (!this.error) {
- this.error = err;
- throw err;
- }
- });
- }
- return expression;
- });
- }
-
- get [Symbol.toStringTag]() {
- return 'AstroComponent';
- }
-
- async *[Symbol.asyncIterator]() {
- const { htmlParts, expressions } = this;
-
- for (let i = 0; i < htmlParts.length; i++) {
- const html = htmlParts[i];
- const expression = expressions[i];
-
- yield markHTMLString(html);
- yield* renderChild(expression);
- }
- }
-}
-
-// Determines if a component is an .astro component
-export function isAstroComponent(obj: any): obj is AstroComponent {
- return (
- typeof obj === 'object' && Object.prototype.toString.call(obj) === '[object AstroComponent]'
- );
-}
-
-export function isAstroComponentFactory(obj: any): obj is AstroComponentFactory {
- return obj == null ? false : obj.isAstroComponentFactory === true;
-}
-
-export async function* renderAstroComponent(
- component: InstanceType<typeof AstroComponent>
-): AsyncIterable<string | HTMLBytes | RenderInstruction> {
- for await (const value of component) {
- if (value || value === 0) {
- for await (const chunk of renderChild(value)) {
- switch (chunk.type) {
- case 'directive': {
- yield chunk;
- break;
- }
- default: {
- yield markHTMLString(chunk);
- break;
- }
- }
- }
- }
- }
-}
-
-// Calls a component and renders it into a string of HTML
-export async function renderToString(
- result: SSRResult,
- componentFactory: AstroComponentFactory,
- props: any,
- children: any
-): Promise<string> {
- const Component = await componentFactory(result, props, children);
-
- if (!isAstroComponent(Component)) {
- const response: Response = Component;
- throw response;
- }
-
- let parts = new HTMLParts();
- for await (const chunk of renderAstroComponent(Component)) {
- parts.append(chunk, result);
- }
- return parts.toString();
-}
-
-export async function renderToIterable(
- result: SSRResult,
- componentFactory: AstroComponentFactory,
- displayName: string,
- props: any,
- children: any
-): Promise<AsyncIterable<string | HTMLBytes | RenderInstruction>> {
- validateComponentProps(props, displayName);
- const Component = await componentFactory(result, props, children);
-
- if (!isAstroComponent(Component)) {
- // eslint-disable-next-line no-console
- console.warn(
- `Returning a Response is only supported inside of page components. Consider refactoring this logic into something like a function that can be used in the page.`
- );
-
- const response = Component;
- throw response;
- }
-
- return renderAstroComponent(Component);
-}
-
-export async function renderTemplate(htmlParts: TemplateStringsArray, ...expressions: any[]) {
- return new AstroComponent(htmlParts, expressions);
-}
diff --git a/packages/astro/src/runtime/server/render/astro/factory.ts b/packages/astro/src/runtime/server/render/astro/factory.ts
new file mode 100644
index 000000000..50cda589d
--- /dev/null
+++ b/packages/astro/src/runtime/server/render/astro/factory.ts
@@ -0,0 +1,53 @@
+import type { SSRResult, PropagationHint } from '../../../../@types/astro';
+import type { HeadAndContent } from './head-and-content';
+import type { RenderTemplateResult } from './render-template';
+
+import { renderAstroTemplateResult } from './render-template.js';
+import { isHeadAndContent } from './head-and-content.js';
+import { HTMLParts } from '../common.js';
+
+export type AstroFactoryReturnValue = RenderTemplateResult | Response | HeadAndContent;
+
+// The callback passed to to $$createComponent
+export interface AstroComponentFactory {
+ (result: any, props: any, slots: any): AstroFactoryReturnValue;
+ isAstroComponentFactory?: boolean;
+ moduleId: string | undefined;
+ propagation?: PropagationHint;
+}
+
+export function isAstroComponentFactory(obj: any): obj is AstroComponentFactory {
+ return obj == null ? false : obj.isAstroComponentFactory === true;
+}
+
+// Calls a component and renders it into a string of HTML
+export async function renderToString(
+ result: SSRResult,
+ componentFactory: AstroComponentFactory,
+ props: any,
+ children: any
+): Promise<string> {
+ const factoryResult = await componentFactory(result, props, children);
+
+ if (factoryResult instanceof Response) {
+ const response = factoryResult;
+ throw response;
+ }
+
+ let parts = new HTMLParts();
+ const templateResult = isHeadAndContent(factoryResult) ? factoryResult.content : factoryResult;
+ for await (const chunk of renderAstroTemplateResult(templateResult)) {
+ parts.append(chunk, result);
+ }
+
+
+ return parts.toString();
+}
+
+export function isAPropagatingComponent(result: SSRResult, factory: AstroComponentFactory): boolean {
+ let hint: PropagationHint = factory.propagation || 'none';
+ if(factory.moduleId && result.propagation.has(factory.moduleId) && hint === 'none') {
+ hint = result.propagation.get(factory.moduleId)!;
+ }
+ return hint === 'in-tree' || hint === 'self';
+}
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
new file mode 100644
index 000000000..e44911424
--- /dev/null
+++ b/packages/astro/src/runtime/server/render/astro/head-and-content.ts
@@ -0,0 +1,24 @@
+import type { RenderTemplateResult } from './render-template';
+
+const headAndContentSym = Symbol.for('astro.headAndContent');
+
+export type HeadAndContent = {
+ [headAndContentSym]: true;
+ head: string | RenderTemplateResult;
+ content: RenderTemplateResult;
+}
+
+export function isHeadAndContent(obj: unknown): obj is HeadAndContent {
+ return typeof obj === 'object' && !!((obj as any)[headAndContentSym]);
+}
+
+export function createHeadAndContent(
+ head: string | RenderTemplateResult,
+ content: RenderTemplateResult
+): HeadAndContent {
+ return {
+ [headAndContentSym]: true,
+ head,
+ content
+ }
+}
diff --git a/packages/astro/src/runtime/server/render/astro/index.ts b/packages/astro/src/runtime/server/render/astro/index.ts
new file mode 100644
index 000000000..0dc39805d
--- /dev/null
+++ b/packages/astro/src/runtime/server/render/astro/index.ts
@@ -0,0 +1,25 @@
+
+export {
+ createAstroComponentInstance,
+ isAstroComponentInstance
+} from './instance.js';
+export {
+ isAstroComponentFactory,
+ renderToString
+} from './factory.js';
+export {
+ isRenderTemplateResult,
+ renderAstroTemplateResult,
+ renderTemplate
+} from './render-template.js';
+export {
+ isHeadAndContent,
+ createHeadAndContent
+} from './head-and-content.js';
+
+export type {
+ AstroComponentFactory
+} from './factory';
+export type {
+ AstroComponentInstance
+} from './instance';
diff --git a/packages/astro/src/runtime/server/render/astro/instance.ts b/packages/astro/src/runtime/server/render/astro/instance.ts
new file mode 100644
index 000000000..db3916a49
--- /dev/null
+++ b/packages/astro/src/runtime/server/render/astro/instance.ts
@@ -0,0 +1,82 @@
+import type { SSRResult } from '../../../../@types/astro';
+import type { AstroComponentFactory, AstroFactoryReturnValue } from './factory.js';
+
+import { HydrationDirectiveProps } from '../../hydration.js';
+import { renderChild } from '../any.js';
+import { isHeadAndContent } from './head-and-content.js';
+import { isAPropagatingComponent } from './factory.js';
+import { isPromise } from '../../util.js';
+
+type ComponentProps = Record<string | number, any>;
+
+const astroComponentInstanceSym = Symbol.for('astro.componentInstance');
+
+export class AstroComponentInstance {
+ [astroComponentInstanceSym] = true;
+
+ private readonly result: SSRResult;
+ private readonly props: ComponentProps;
+ private readonly slots: any;
+ private readonly factory: AstroComponentFactory;
+ private returnValue: ReturnType<AstroComponentFactory> | undefined;
+ constructor(result: SSRResult, props: ComponentProps, slots: any, factory: AstroComponentFactory) {
+ this.result = result;
+ this.props = props;
+ this.slots = slots;
+ this.factory = factory;
+ }
+
+ async init() {
+ this.returnValue = this.factory(this.result, this.props, this.slots);
+ return this.returnValue;
+ }
+
+ async *render() {
+ if(this.returnValue === undefined) {
+ await this.init();
+ }
+
+ let value: AstroFactoryReturnValue | undefined = this.returnValue;
+ if(isPromise(value)) {
+ value = await value;
+ }
+ if(isHeadAndContent(value)) {
+ yield * value.content;
+ } else {
+ yield * renderChild(value);
+ }
+ }
+}
+
+// Issue warnings for invalid props for Astro components
+function validateComponentProps(props: any, displayName: string) {
+ if (props != null) {
+ for (const prop of Object.keys(props)) {
+ if (HydrationDirectiveProps.has(prop)) {
+ // eslint-disable-next-line
+ console.warn(
+ `You are attempting to render <${displayName} ${prop} />, but ${displayName} is an Astro component. Astro components do not render in the client and should not have a hydration directive. Please use a framework component for client rendering.`
+ );
+ }
+ }
+ }
+}
+
+export function createAstroComponentInstance(
+ result: SSRResult,
+ displayName: string,
+ factory: AstroComponentFactory,
+ props: ComponentProps,
+ slots: any = {}
+) {
+ validateComponentProps(props, displayName);
+ const instance = new AstroComponentInstance(result, props, slots, factory);
+ if(isAPropagatingComponent(result, factory) && !result.propagators.has(factory)) {
+ result.propagators.set(factory, instance);
+ }
+ return instance;
+}
+
+export function isAstroComponentInstance(obj: unknown): obj is AstroComponentInstance {
+ return typeof obj === 'object' && !!((obj as any)[astroComponentInstanceSym]);
+}
diff --git a/packages/astro/src/runtime/server/render/astro/render-template.ts b/packages/astro/src/runtime/server/render/astro/render-template.ts
new file mode 100644
index 000000000..2c637f3c8
--- /dev/null
+++ b/packages/astro/src/runtime/server/render/astro/render-template.ts
@@ -0,0 +1,83 @@
+import type { RenderInstruction } from '../types';
+
+import { HTMLBytes, markHTMLString } from '../../escape.js';
+import { isPromise } from '../../util.js';
+import { renderChild } from '../any.js';
+
+const renderTemplateResultSym = Symbol.for('astro.renderTemplateResult');
+
+// The return value when rendering a component.
+// This is the result of calling render(), should this be named to RenderResult or...?
+export class RenderTemplateResult {
+ public [renderTemplateResultSym] = true;
+ private htmlParts: TemplateStringsArray;
+ private expressions: any[];
+ private error: Error | undefined;
+ constructor(htmlParts: TemplateStringsArray, expressions: unknown[]) {
+ this.htmlParts = htmlParts;
+ this.error = undefined;
+ this.expressions = expressions.map((expression) => {
+ // Wrap Promise expressions so we can catch errors
+ // There can only be 1 error that we rethrow from an Astro component,
+ // so this keeps track of whether or not we have already done so.
+ if (isPromise(expression)) {
+ return Promise.resolve(expression).catch((err) => {
+ if (!this.error) {
+ this.error = err;
+ throw err;
+ }
+ });
+ }
+ return expression;
+ });
+ }
+
+ // TODO this is legacy and should be removed in 2.0
+ get [Symbol.toStringTag]() {
+ return 'AstroComponent';
+ }
+
+ async *[Symbol.asyncIterator]() {
+ const { htmlParts, expressions } = this;
+
+ for (let i = 0; i < htmlParts.length; i++) {
+ const html = htmlParts[i];
+ const expression = expressions[i];
+
+ yield markHTMLString(html);
+ yield* renderChild(expression);
+ }
+ }
+}
+
+// Determines if a component is an .astro component
+export function isRenderTemplateResult(obj: unknown): obj is RenderTemplateResult {
+ return (
+ typeof obj === 'object' && !!((obj as any)[renderTemplateResultSym])
+ );
+}
+
+export async function* renderAstroTemplateResult(
+ component: RenderTemplateResult
+): AsyncIterable<string | HTMLBytes | RenderInstruction> {
+ for await (const value of component) {
+ if (value || value === 0) {
+ for await (const chunk of renderChild(value)) {
+ switch (chunk.type) {
+ case 'directive': {
+ yield chunk;
+ break;
+ }
+ default: {
+ yield markHTMLString(chunk);
+ break;
+ }
+ }
+ }
+ }
+ }
+}
+
+export function renderTemplate(htmlParts: TemplateStringsArray, ...expressions: any[]) {
+ return new RenderTemplateResult(htmlParts, expressions);
+}
diff --git a/packages/astro/src/runtime/server/render/component.ts b/packages/astro/src/runtime/server/render/component.ts
index f461e4f12..0e25d7014 100644
--- a/packages/astro/src/runtime/server/render/component.ts
+++ b/packages/astro/src/runtime/server/render/component.ts
@@ -12,15 +12,18 @@ import { extractDirectives, generateHydrateScript } from '../hydration.js';
import { serializeProps } from '../serialize.js';
import { shorthash } from '../shorthash.js';
import {
+ createAstroComponentInstance,
isAstroComponentFactory,
- renderAstroComponent,
+ isAstroComponentInstance,
+ renderAstroTemplateResult,
renderTemplate,
- renderToIterable,
-} from './astro.js';
+ type AstroComponentInstance
+} from './astro/index.js';
import { Fragment, Renderer, stringifyChunk } from './common.js';
import { componentIsHTMLElement, renderHTMLElement } from './dom.js';
import { renderSlot, renderSlots } from './slot.js';
import { formatList, internalSpreadAttributes, renderElement, voidElementNames } from './util.js';
+import { isPromise } from '../util.js';
const rendererAliases = new Map([['solid', 'solid-js']]);
@@ -45,65 +48,25 @@ function guessRenderers(componentUrl?: string): string[] {
}
}
-type ComponentType = 'fragment' | 'html' | 'astro-factory' | 'unknown';
export type ComponentIterable = AsyncIterable<string | HTMLBytes | RenderInstruction>;
-function getComponentType(Component: unknown): ComponentType {
- if (Component === Fragment) {
- return 'fragment';
- }
- if (Component && typeof Component === 'object' && (Component as any)['astro:html']) {
- return 'html';
- }
- if (isAstroComponentFactory(Component)) {
- return 'astro-factory';
- }
- return 'unknown';
+function isFragmentComponent(Component: unknown) {
+ return Component === Fragment;
+}
+
+function isHTMLComponent(Component: unknown) {
+ return (
+ Component && typeof Component === 'object' && (Component as any)['astro:html']
+ );
}
-export async function renderComponent(
+async function renderFrameworkComponent(
result: SSRResult,
displayName: string,
Component: unknown,
_props: Record<string | number, any>,
slots: any = {},
- route?: RouteData | undefined
): Promise<ComponentIterable> {
- Component = (await Component) ?? Component;
-
- switch (getComponentType(Component)) {
- case 'fragment': {
- const children = await renderSlot(result, slots?.default);
- if (children == null) {
- return children;
- }
- return markHTMLString(children);
- }
-
- // .html components
- case 'html': {
- const { slotInstructions, children } = await renderSlots(result, slots);
- const html = (Component as any).render({ slots: children });
- const hydrationHtml = slotInstructions
- ? slotInstructions.map((instr) => stringifyChunk(result, instr)).join('')
- : '';
- return markHTMLString(hydrationHtml + html);
- }
-
- case 'astro-factory': {
- async function* renderAstroComponentInline(): AsyncGenerator<
- string | HTMLBytes | RenderInstruction,
- void,
- undefined
- > {
- let iterable = await renderToIterable(result, Component as any, displayName, _props, slots);
- yield* iterable;
- }
-
- return renderAstroComponentInline();
- }
- }
-
if (!Component && !_props['client:only']) {
throw new Error(
`Unable to render ${displayName} because it is ${Component}!\nDid you forget to import the component or is it possible there is a typo?`
@@ -284,7 +247,7 @@ If you're still stuck, please open an issue on GitHub or join us at https://astr
// as a string and the user is responsible for adding a script tag for the component definition.
if (!html && typeof Component === 'string') {
const childSlots = Object.values(children).join('');
- const iterable = renderAstroComponent(
+ const iterable = renderAstroTemplateResult(
await renderTemplate`<${Component}${internalSpreadAttributes(props)}${markHTMLString(
childSlots === '' && voidElementNames.test(Component)
? `/>`
@@ -365,3 +328,68 @@ If you're still stuck, please open an issue on GitHub or join us at https://astr
return renderAll();
}
+
+async function renderFragmentComponent(result: SSRResult, slots: any = {}) {
+ const children = await renderSlot(result, slots?.default);
+ if (children == null) {
+ return children;
+ }
+ return markHTMLString(children);
+}
+
+async function renderHTMLComponent(
+ result: SSRResult,
+ Component: unknown,
+ _props: Record<string | number, any>,
+ slots: any = {}
+) {
+ const { slotInstructions, children } = await renderSlots(result, slots);
+ const html = (Component as any).render({ slots: children });
+ const hydrationHtml = slotInstructions
+ ? slotInstructions.map((instr) => stringifyChunk(result, instr)).join('')
+ : '';
+ return markHTMLString(hydrationHtml + html);
+}
+
+export function renderComponent(
+ result: SSRResult,
+ displayName: string,
+ Component: unknown,
+ props: Record<string | number, any>,
+ slots: any = {}
+): Promise<ComponentIterable> | ComponentIterable | AstroComponentInstance {
+ if(isPromise(Component)) {
+ return Promise.resolve(Component).then(Unwrapped => {
+ return renderComponent(result, displayName, Unwrapped, props, slots) as any;
+ });
+ }
+
+ if(isFragmentComponent(Component)) {
+ return renderFragmentComponent(result, slots);
+ }
+
+ // .html components
+ if(isHTMLComponent(Component)) {
+ return renderHTMLComponent(result, Component, props, slots);
+ }
+
+ if(isAstroComponentFactory(Component)) {
+ return createAstroComponentInstance(result, displayName, Component, props, slots);
+ }
+
+ return renderFrameworkComponent(result, displayName, Component, props, slots);
+}
+
+export function renderComponentToIterable(
+ result: SSRResult,
+ displayName: string,
+ Component: unknown,
+ props: Record<string | number, any>,
+ slots: any = {}
+): Promise<ComponentIterable> | ComponentIterable {
+ const renderResult = renderComponent(result, displayName, Component, props, slots);
+ if(isAstroComponentInstance(renderResult)) {
+ return renderResult.render();
+ }
+ return renderResult;
+}
diff --git a/packages/astro/src/runtime/server/render/head.ts b/packages/astro/src/runtime/server/render/head.ts
index 9afed33fe..701432c2a 100644
--- a/packages/astro/src/runtime/server/render/head.ts
+++ b/packages/astro/src/runtime/server/render/head.ts
@@ -2,6 +2,7 @@ import type { SSRResult } from '../../../@types/astro';
import { markHTMLString } from '../escape.js';
import { renderElement } from './util.js';
+import { renderChild } from './any.js';
// Filter out duplicate elements in our set
const uniqueElements = (item: any, index: number, all: any[]) => {
@@ -12,8 +13,14 @@ const uniqueElements = (item: any, index: number, all: any[]) => {
);
};
-export function renderHead(result: SSRResult): Promise<string> {
- result._metadata.hasRenderedHead = true;
+async function * renderExtraHead(result: SSRResult, base: string) {
+ yield base;
+ for(const part of result.extraHead) {
+ yield * renderChild(part);
+ }
+}
+
+function renderAllHeadContent(result: SSRResult) {
const styles = Array.from(result.styles)
.filter(uniqueElements)
.map((style) => renderElement('style', style));
@@ -27,16 +34,30 @@ export function renderHead(result: SSRResult): Promise<string> {
const links = Array.from(result.links)
.filter(uniqueElements)
.map((link) => renderElement('link', link, false));
- return markHTMLString(links.join('\n') + styles.join('\n') + scripts.join('\n'));
+
+ const baseHeadContent = markHTMLString(links.join('\n') + styles.join('\n') + scripts.join('\n'))
+
+ if(result.extraHead.length > 0) {
+ return renderExtraHead(result, baseHeadContent);
+ } else {
+ return baseHeadContent;
+ }
}
+export function createRenderHead(result: SSRResult) {
+ result._metadata.hasRenderedHead = true;
+ return renderAllHeadContent.bind(null, result);
+}
+
+export const renderHead = createRenderHead;
+
// 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): AsyncIterable<string> {
+export async function* maybeRenderHead(result: SSRResult) {
if (result._metadata.hasRenderedHead) {
return;
}
- yield renderHead(result);
+ yield createRenderHead(result)();
}
diff --git a/packages/astro/src/runtime/server/render/index.ts b/packages/astro/src/runtime/server/render/index.ts
index 537482691..15a4d1977 100644
--- a/packages/astro/src/runtime/server/render/index.ts
+++ b/packages/astro/src/runtime/server/render/index.ts
@@ -1,17 +1,12 @@
-import { renderTemplate } from './astro.js';
+export type { RenderInstruction } from './types';
+export type { AstroComponentFactory, AstroComponentInstance } from './astro/index';
-export { renderAstroComponent, renderTemplate, renderToString } from './astro.js';
+export { createHeadAndContent, renderAstroTemplateResult, renderToString, renderTemplate } from './astro/index.js';
export { Fragment, Renderer, stringifyChunk } from './common.js';
-export { renderComponent } from './component.js';
+export { renderComponent, renderComponentToIterable } from './component.js';
export { renderHTMLElement } from './dom.js';
export { maybeRenderHead, renderHead } from './head.js';
export { renderPage } from './page.js';
export { renderSlot } from './slot.js';
-export type { RenderInstruction } from './types';
export { addAttribute, defineScriptVars, voidElementNames } from './util.js';
-
-// The callback passed to to $$createComponent
-export interface AstroComponentFactory {
- (result: any, props: any, slots: any): ReturnType<typeof renderTemplate> | Response;
- isAstroComponentFactory?: boolean;
-}
+export { renderUniqueStylesheet } from './stylesheet.js';
diff --git a/packages/astro/src/runtime/server/render/page.ts b/packages/astro/src/runtime/server/render/page.ts
index 7b9c1ac56..9f9c7ae40 100644
--- a/packages/astro/src/runtime/server/render/page.ts
+++ b/packages/astro/src/runtime/server/render/page.ts
@@ -5,7 +5,13 @@ import type { AstroComponentFactory } from './index';
import { AstroError, AstroErrorData } from '../../../core/errors/index.js';
import { isHTMLString } from '../escape.js';
import { createResponse } from '../response.js';
-import { isAstroComponent, isAstroComponentFactory, renderAstroComponent } from './astro.js';
+import {
+ isAstroComponentFactory,
+ isAstroComponentInstance,
+ isRenderTemplateResult,
+ isHeadAndContent,
+ renderAstroTemplateResult
+} from './astro/index.js';
import { chunkToByteArray, encoder, HTMLParts } from './common.js';
import { renderComponent } from './component.js';
import { maybeRenderHead } from './head.js';
@@ -45,6 +51,22 @@ async function iterableToHTMLBytes(
return parts.toArrayBuffer();
}
+// Recursively calls component instances that might have head content
+// to be propagated up.
+async function bufferHeadContent(result: SSRResult) {
+ const iterator = result.propagators.values();
+ while(true) {
+ const { value, done } = iterator.next();
+ if(done) {
+ break;
+ }
+ const returnValue = await value.init();
+ if(isHeadAndContent(returnValue)) {
+ result.extraHead.push(returnValue.head);
+ }
+ }
+}
+
export async function renderPage(
result: SSRResult,
componentFactory: AstroComponentFactory | NonAstroPageComponent,
@@ -57,16 +79,19 @@ export async function renderPage(
const pageProps: Record<string, any> = { ...(props ?? {}), 'server:root': true };
let output: ComponentIterable;
-
try {
- output = await renderComponent(
+ const renderResult = await renderComponent(
result,
componentFactory.name,
componentFactory,
pageProps,
null,
- route
);
+ if(isAstroComponentInstance(renderResult)) {
+ output = renderResult.render();
+ } else {
+ output = renderResult;
+ }
} catch (e) {
if (AstroError.is(e) && !e.loc) {
e.setLocation({
@@ -94,9 +119,13 @@ export async function renderPage(
});
}
const factoryReturnValue = await componentFactory(result, props, children);
+ const factoryIsHeadAndContent = isHeadAndContent(factoryReturnValue);
+ if (isRenderTemplateResult(factoryReturnValue) || factoryIsHeadAndContent) {
+ // Wait for head content to be buffered up
+ await bufferHeadContent(result);
+ const templateResult = factoryIsHeadAndContent ? factoryReturnValue.content : factoryReturnValue;
- if (isAstroComponent(factoryReturnValue)) {
- let iterable = renderAstroComponent(factoryReturnValue);
+ let iterable = renderAstroTemplateResult(templateResult);
let init = result.response;
let headers = new Headers(init.headers);
let body: BodyInit;
diff --git a/packages/astro/src/runtime/server/render/stylesheet.ts b/packages/astro/src/runtime/server/render/stylesheet.ts
new file mode 100644
index 000000000..cc704bc0b
--- /dev/null
+++ b/packages/astro/src/runtime/server/render/stylesheet.ts
@@ -0,0 +1,25 @@
+import { SSRResult } from '../../../@types/astro';
+import { renderElement } from './util.js';
+import { markHTMLString } from '../escape.js';
+
+const stylesheetRel = 'stylesheet';
+
+export function renderStylesheet({ href }: { href: string }) {
+ return markHTMLString(renderElement('link', {
+ props: {
+ rel: stylesheetRel,
+ href
+ },
+ children: ''
+ }, false));
+}
+
+export function renderUniqueStylesheet(result: SSRResult, link: { href: string }) {
+ for (const existingLink of result.links) {
+ if(existingLink.props.rel === stylesheetRel && existingLink.props.href === link.href) {
+ return '';
+ }
+ }
+
+ return renderStylesheet(link);
+}
diff --git a/packages/astro/src/vite-plugin-astro/index.ts b/packages/astro/src/vite-plugin-astro/index.ts
index 09bf658f9..4d9accab2 100644
--- a/packages/astro/src/vite-plugin-astro/index.ts
+++ b/packages/astro/src/vite-plugin-astro/index.ts
@@ -18,6 +18,8 @@ import { normalizeFilename } from '../vite-plugin-utils/index.js';
import { cachedFullCompilation } from './compile.js';
import { handleHotUpdate } from './hmr.js';
import { parseAstroRequest, ParsedRequestResult } from './query.js';
+export type { AstroPluginMetadata };
+export { getAstroMetadata } from './metadata.js';
interface AstroPluginOptions {
settings: AstroSettings;
@@ -108,6 +110,7 @@ export default function astro({ settings, logging }: AstroPluginOptions): vite.P
if (!compileResult) {
return null;
}
+
switch (query.type) {
case 'style': {
if (typeof query.index === 'undefined') {
@@ -198,6 +201,7 @@ export default function astro({ settings, logging }: AstroPluginOptions): vite.P
astroConfig: config,
viteConfig: resolvedConfig,
filename,
+ id,
source,
};
@@ -215,6 +219,7 @@ export default function astro({ settings, logging }: AstroPluginOptions): vite.P
clientOnlyComponents: transformResult.clientOnlyComponents,
hydratedComponents: transformResult.hydratedComponents,
scripts: transformResult.scripts,
+ propagation: 'none',
};
return {
@@ -236,6 +241,7 @@ export default function astro({ settings, logging }: AstroPluginOptions): vite.P
astroConfig: config,
viteConfig: resolvedConfig,
filename: context.file,
+ id: context.modules[0]?.id ?? undefined,
source: await context.read(),
};
const compile = () => cachedCompilation(compileProps);
diff --git a/packages/astro/src/vite-plugin-astro/metadata.ts b/packages/astro/src/vite-plugin-astro/metadata.ts
new file mode 100644
index 000000000..866d01277
--- /dev/null
+++ b/packages/astro/src/vite-plugin-astro/metadata.ts
@@ -0,0 +1,9 @@
+import type { PluginMetadata } from './types';
+import type { ModuleInfo } from '../core/module-loader';
+
+export function getAstroMetadata(modInfo: ModuleInfo): PluginMetadata['astro'] | undefined {
+ if(modInfo.meta?.astro) {
+ return modInfo.meta.astro as PluginMetadata['astro'];
+ }
+ return undefined;
+}
diff --git a/packages/astro/src/vite-plugin-astro/types.ts b/packages/astro/src/vite-plugin-astro/types.ts
index bf6a1cee5..c9ac8332c 100644
--- a/packages/astro/src/vite-plugin-astro/types.ts
+++ b/packages/astro/src/vite-plugin-astro/types.ts
@@ -1,9 +1,11 @@
import type { TransformResult } from '@astrojs/compiler';
+import type { PropagationHint } from '../@types/astro';
export interface PluginMetadata {
astro: {
hydratedComponents: TransformResult['hydratedComponents'];
clientOnlyComponents: TransformResult['clientOnlyComponents'];
scripts: TransformResult['scripts'];
+ propagation: PropagationHint;
};
}
diff --git a/packages/astro/src/vite-plugin-head-propagation/index.ts b/packages/astro/src/vite-plugin-head-propagation/index.ts
new file mode 100644
index 000000000..dd8355c0f
--- /dev/null
+++ b/packages/astro/src/vite-plugin-head-propagation/index.ts
@@ -0,0 +1,54 @@
+import type { AstroSettings } from '../@types/astro';
+import type { ModuleInfo } from 'rollup';
+
+import * as vite from 'vite';
+import { getAstroMetadata } from '../vite-plugin-astro/index.js';
+
+const injectExp = /^\/\/\s*astro-head-inject/;
+/**
+ * If any component is marked as doing head injection, walk up the tree
+ * and mark parent Astro components as having head injection in the tree.
+ * This is used at runtime to determine if we should wait for head content
+ * to be be populated before rendering the entire tree.
+ */
+export default function configHeadPropagationVitePlugin({
+ settings,
+}: {
+ settings: AstroSettings;
+}): vite.Plugin {
+ function addHeadInjectionInTree(graph: vite.ModuleGraph, id: string, getInfo: (id: string) => ModuleInfo | null, seen: Set<string> = new Set()) {
+ const mod = server.moduleGraph.getModuleById(id);
+ for(const parent of mod?.importers || []) {
+ if(parent.id) {
+ if(seen.has(parent.id)) {
+ continue;
+ }
+ const info = getInfo(parent.id);
+ if(info?.meta.astro) {
+ const astroMetadata = getAstroMetadata(info);
+ if(astroMetadata) {
+ astroMetadata.propagation = 'in-tree';
+ }
+ }
+ addHeadInjectionInTree(graph, parent.id, getInfo, seen);
+ }
+ }
+ }
+
+ let server: vite.ViteDevServer;
+ return {
+ name: 'astro:head-propagation',
+ configureServer(_server) {
+ server = _server;
+ },
+ transform(source, id) {
+ if(!server) {
+ return;
+ }
+
+ if(injectExp.test(source)) {
+ addHeadInjectionInTree(server.moduleGraph, id, (child) => this.getModuleInfo(child));
+ }
+ }
+ };
+}
diff --git a/packages/astro/src/vite-plugin-load-fallback/index.ts b/packages/astro/src/vite-plugin-load-fallback/index.ts
index 76ca4e2bd..86346421c 100644
--- a/packages/astro/src/vite-plugin-load-fallback/index.ts
+++ b/packages/astro/src/vite-plugin-load-fallback/index.ts
@@ -44,7 +44,7 @@ export default function loadFallbackPlugin({
async resolveId(id, parent) {
// See if this can be loaded from our fs
if (parent) {
- const candidateId = npath.posix.join(npath.posix.dirname(parent), id);
+ const candidateId = npath.posix.join(npath.posix.dirname(slashify(parent)), id);
try {
// Check to see if this file exists and is not a directory.
const stats = await fs.promises.stat(candidateId);
diff --git a/packages/astro/src/vite-plugin-markdown-legacy/index.ts b/packages/astro/src/vite-plugin-markdown-legacy/index.ts
index a224a3193..f55f47248 100644
--- a/packages/astro/src/vite-plugin-markdown-legacy/index.ts
+++ b/packages/astro/src/vite-plugin-markdown-legacy/index.ts
@@ -207,6 +207,7 @@ ${setup}`.trim();
viteConfig: resolvedConfig,
filename,
source: astroResult,
+ id,
};
let transformResult = await cachedCompilation(compileProps);
@@ -232,6 +233,7 @@ ${tsResult}`;
clientOnlyComponents: transformResult.clientOnlyComponents,
hydratedComponents: transformResult.hydratedComponents,
scripts: transformResult.scripts,
+ propagation: 'none'
};
return {
diff --git a/packages/astro/src/vite-plugin-markdown/index.ts b/packages/astro/src/vite-plugin-markdown/index.ts
index f13bf5ca4..46d84fffc 100644
--- a/packages/astro/src/vite-plugin-markdown/index.ts
+++ b/packages/astro/src/vite-plugin-markdown/index.ts
@@ -157,6 +157,7 @@ export default function markdown({ settings, logging }: AstroPluginOptions): Plu
hydratedComponents: [],
clientOnlyComponents: [],
scripts: [],
+ propagation: 'none',
} as PluginMetadata['astro'],
vite: {
lang: 'ts',
diff --git a/packages/astro/test/units/dev/head-injection.test.js b/packages/astro/test/units/dev/head-injection.test.js
new file mode 100644
index 000000000..5f57d2400
--- /dev/null
+++ b/packages/astro/test/units/dev/head-injection.test.js
@@ -0,0 +1,160 @@
+import { expect } from 'chai';
+import * as cheerio from 'cheerio';
+
+import { runInContainer } from '../../../dist/core/dev/index.js';
+import { createFs, createRequestAndResponse } from '../test-utils.js';
+
+const root = new URL('../../fixtures/alias/', import.meta.url);
+
+describe('head injection', () => {
+ it('Dynamic injection from component created in the page frontmatter', async () => {
+ const fs = createFs(
+ {
+ '/src/components/Other.astro': `
+ <style>
+ div {
+ background: grey;
+ }
+ </style>
+ <div id="other">Other</div>
+ `,
+ '/src/common/head.js': `
+ // astro-head-inject
+ import Other from '../components/Other.astro';
+ import {
+ createComponent,
+ createHeadAndContent,
+ renderComponent,
+ renderTemplate,
+ renderUniqueStylesheet,
+ } from 'astro/runtime/server/index.js';
+
+ export function renderEntry() {
+ return createComponent({
+ factory(result, props, slots) {
+ return createHeadAndContent(
+ renderUniqueStylesheet(result, {
+ href: '/some/fake/styles.css'
+ }),
+ renderTemplate\`$\{renderComponent(result, 'Other', Other, props, slots)}\`
+ );
+ },
+ propagation: 'self'
+ });
+ }
+ `.trim(),
+ '/src/pages/index.astro': `
+ ---
+ import { renderEntry } from '../common/head.js';
+ const Head = renderEntry();
+ ---
+ <h1>testing</h1>
+ <Head />
+ `,
+ },
+ root
+ );
+
+ await runInContainer({
+ fs, root,
+ userConfig: {
+ vite: { server: { middlewareMode: true } }
+ }
+ }, async (container) => {
+ const { req, res, done, text } = createRequestAndResponse({
+ method: 'GET',
+ url: '/',
+ });
+ container.handle(req, res);
+ await done;
+ const html = await text();
+ const $ = cheerio.load(html);
+
+ expect($('link[rel=stylesheet][href="/some/fake/styles.css"]')).to.have.a.lengthOf(1);
+ expect($('#other')).to.have.a.lengthOf(1);
+ });
+ });
+
+ it('Dynamic injection from a layout component', async () => {
+ const fs = createFs(
+ {
+ '/src/components/Other.astro': `
+ <style>
+ div {
+ background: grey;
+ }
+ </style>
+ <div id="other">Other</div>
+ `,
+ '/src/common/head.js': `
+ // astro-head-inject
+ import Other from '../components/Other.astro';
+ import {
+ createComponent,
+ createHeadAndContent,
+ renderComponent,
+ renderTemplate,
+ renderUniqueStylesheet,
+ } from 'astro/runtime/server/index.js';
+
+ export function renderEntry() {
+ return createComponent({
+ factory(result, props, slots) {
+ return createHeadAndContent(
+ renderUniqueStylesheet(result, {
+ href: '/some/fake/styles.css'
+ }),
+ renderTemplate\`$\{renderComponent(result, 'Other', Other, props, slots)}\`
+ );
+ },
+ propagation: 'self'
+ });
+ }
+ `.trim(),
+ '/src/components/Layout.astro': `
+ ---
+ import { renderEntry } from '../common/head.js';
+ const ExtraHead = renderEntry();
+ ---
+ <html>
+ <head>
+ <title>Normal head stuff</title>
+ </head>
+ <body>
+ <slot />
+ <ExtraHead />
+ </body>
+ </html>
+ `,
+ '/src/pages/index.astro': `
+ ---
+ import Layout from '../components/Layout.astro';
+ ---
+ <Layout>
+ <h1>Test page</h1>
+ </Layout>
+ `,
+ },
+ root
+ );
+
+ await runInContainer({
+ fs, root,
+ userConfig: {
+ vite: { server: { middlewareMode: true } }
+ }
+ }, async (container) => {
+ const { req, res, done, text } = createRequestAndResponse({
+ method: 'GET',
+ url: '/',
+ });
+ container.handle(req, res);
+ await done;
+ const html = await text();
+ const $ = cheerio.load(html);
+
+ expect($('link[rel=stylesheet][href="/some/fake/styles.css"]')).to.have.a.lengthOf(1);
+ expect($('#other')).to.have.a.lengthOf(1);
+ });
+ });
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 82d8de6de..fd205edfc 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -373,7 +373,7 @@ importers:
packages/astro:
specifiers:
- '@astrojs/compiler': ^0.29.15
+ '@astrojs/compiler': ^0.30.0
'@astrojs/language-server': ^0.28.3
'@astrojs/markdown-remark': ^1.1.3
'@astrojs/telemetry': ^1.0.1
@@ -471,7 +471,7 @@ importers:
yargs-parser: ^21.0.1
zod: ^3.17.3
dependencies:
- '@astrojs/compiler': 0.29.15
+ '@astrojs/compiler': 0.30.0
'@astrojs/language-server': 0.28.3
'@astrojs/markdown-remark': link:../markdown/remark
'@astrojs/telemetry': link:../telemetry
@@ -3893,6 +3893,10 @@ packages:
/@astrojs/compiler/0.29.15:
resolution: {integrity: sha512-vicPD8oOPNkcFZvz71Uz/nJcadovurUQ3L0yMZNPb6Nn6T1nHhlSHt5nAKaurB2pYU9DrxOFWZS2/RdV+JsWmQ==}
+ /@astrojs/compiler/0.30.0:
+ resolution: {integrity: sha512-av2HV5NuyzI5E12hpn4k7XNEHbfF81/JUISPu6CclC5yKCxTS7z64hRU68tA8k7dYLATcxvjQtvN2H/dnxHaMw==}
+ dev: false
+
/@astrojs/language-server/0.28.3:
resolution: {integrity: sha512-fPovAX/X46eE2w03jNRMpQ7W9m2mAvNt4Ay65lD9wl1Z5vIQYxlg7Enp9qP225muTr4jSVB5QiLumFJmZMAaVA==}
hasBin: true