Implement hydrated components in the static build (#2260)

* Work on removing vite-postprocess

* Gets hydration totally working

* Formatting

* Update based on PR comments

* Bring back vite transform for non-static builds

* Upgrade compiler version

* Update the client-only test

* Add debugging for windows

* More debugging

* Pass pathname into the markdown plugin as well

* Include vite changes

* Revert "Include vite changes"

This reverts commit dee6348b233b6d6c6fea1e4dcc41c98c0452846c.
This commit is contained in:
Matthew Phillips 2021-12-30 09:01:00 -05:00 committed by GitHub
parent 8b34bd90f8
commit ae5255dd25
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 387 additions and 207 deletions

View file

@ -6,6 +6,7 @@
"dev": "astro dev --experimental-static-build", "dev": "astro dev --experimental-static-build",
"start": "astro dev", "start": "astro dev",
"build": "astro build --experimental-static-build", "build": "astro build --experimental-static-build",
"scan-build": "astro build",
"preview": "astro preview" "preview": "astro preview"
}, },
"devDependencies": { "devDependencies": {

View file

@ -0,0 +1,24 @@
<template>
<div id="vue" class="counter">
<button @click="subtract()">-</button>
<pre>{{ count }}</pre>
<button @click="add()">+</button>
</div>
</template>
<script>
import { ref } from 'vue';
export default {
setup() {
const count = ref(0);
const add = () => (count.value = count.value + 1);
const subtract = () => (count.value = count.value - 1);
return {
count,
add,
subtract,
};
},
};
</script>

View file

@ -0,0 +1,20 @@
---
import Greeting from '../components/Greeting.vue';
export async function getStaticPaths() {
const response = await fetch(`https://pokeapi.co/api/v2/pokemon?limit=2000`);
const result = await response.json();
const allPokemon = result.results;
return allPokemon.map(pokemon => ({params: {pokemon: pokemon.name}, props: {pokemon}}));
}
---
<html lang="en">
<head>
<title>Hello</title>
</head>
<body>
<h1>{Astro.props.pokemon.name}</h1>
<Greeting client:load />
</body>
</html>

View file

@ -2,6 +2,7 @@
import imgUrl from '../images/penguin.jpg'; import imgUrl from '../images/penguin.jpg';
import grayscaleUrl from '../images/random.jpg?grayscale=true'; import grayscaleUrl from '../images/random.jpg?grayscale=true';
import Greeting from '../components/Greeting.vue'; import Greeting from '../components/Greeting.vue';
import Counter from '../components/Counter.vue';
--- ---
<html> <html>
@ -26,9 +27,14 @@ import Greeting from '../components/Greeting.vue';
<Greeting /> <Greeting />
</section> </section>
<section> <section>
<h1>ImageTools</h1> <h1>ImageTools</h1>
<img src={grayscaleUrl} /> <img src={grayscaleUrl} />
</section> </section>
</body>
</html> <section>
<h1>Hydrated component</h1>
<Counter client:idle />
</section>
</body>
</html>

View file

@ -56,7 +56,7 @@
"test": "mocha --parallel --timeout 15000" "test": "mocha --parallel --timeout 15000"
}, },
"dependencies": { "dependencies": {
"@astrojs/compiler": "^0.6.0", "@astrojs/compiler": "^0.7.0",
"@astrojs/language-server": "^0.8.2", "@astrojs/language-server": "^0.8.2",
"@astrojs/markdown-remark": "^0.6.0", "@astrojs/markdown-remark": "^0.6.0",
"@astrojs/prism": "0.4.0", "@astrojs/prism": "0.4.0",

View file

@ -371,5 +371,6 @@ export interface SSRResult {
scripts: Set<SSRElement>; scripts: Set<SSRElement>;
links: Set<SSRElement>; links: Set<SSRElement>;
createAstro(Astro: AstroGlobalPartial, props: Record<string, any>, slots: Record<string, any> | null): AstroGlobal; createAstro(Astro: AstroGlobalPartial, props: Record<string, any>, slots: Record<string, any> | null): AstroGlobal;
resolve: (s: string) => Promise<string>;
_metadata: SSRMetadata; _metadata: SSRMetadata;
} }

View file

@ -14,6 +14,10 @@ export interface BuildInternals {
// A mapping to entrypoints (facadeId) to assets (styles) that are added. // A mapping to entrypoints (facadeId) to assets (styles) that are added.
facadeIdToAssetsMap: Map<string, string[]>; facadeIdToAssetsMap: Map<string, string[]>;
// A mapping of specifiers like astro/client/idle.js to the hashed bundled name.
// Used to render pages with the correct specifiers.
entrySpecifierToBundleMap: Map<string, string>;
} }
/** /**
@ -41,5 +45,6 @@ export function createBuildInternals(): BuildInternals {
astroStyleMap, astroStyleMap,
astroPageStyleMap, astroPageStyleMap,
facadeIdToAssetsMap, facadeIdToAssetsMap,
entrySpecifierToBundleMap: new Map<string, string>(),
}; };
} }

View file

@ -1,6 +1,6 @@
import type { OutputChunk, PreRenderedChunk, RollupOutput } from 'rollup'; import type { OutputChunk, PreRenderedChunk, RollupOutput } from 'rollup';
import type { Plugin as VitePlugin } from '../vite'; import type { Plugin as VitePlugin } from '../vite';
import type { AstroConfig, RouteCache } from '../../@types/astro'; import type { AstroConfig, RouteCache, SSRElement } from '../../@types/astro';
import type { AllPagesData } from './types'; import type { AllPagesData } from './types';
import type { LogOptions } from '../logger'; import type { LogOptions } from '../logger';
import type { ViteConfigWithSSR } from '../create-vite'; import type { ViteConfigWithSSR } from '../create-vite';
@ -9,12 +9,16 @@ import type { BuildInternals } from '../../core/build/internal.js';
import type { AstroComponentFactory } from '../../runtime/server'; import type { AstroComponentFactory } from '../../runtime/server';
import fs from 'fs'; import fs from 'fs';
import npath from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import glob from 'fast-glob';
import vite from '../vite.js'; import vite from '../vite.js';
import { debug, info, error } from '../../core/logger.js'; import { debug, info, error } from '../../core/logger.js';
import { createBuildInternals } from '../../core/build/internal.js'; import { createBuildInternals } from '../../core/build/internal.js';
import { rollupPluginAstroBuildCSS } from '../../vite-plugin-build-css/index.js'; import { rollupPluginAstroBuildCSS } from '../../vite-plugin-build-css/index.js';
import { renderComponent, getParamsAndProps } from '../ssr/index.js'; import { getParamsAndProps } from '../ssr/index.js';
import { createResult } from '../ssr/result.js';
import { renderPage } from '../../runtime/server/index.js';
export interface StaticBuildOptions { export interface StaticBuildOptions {
allPages: AllPagesData; allPages: AllPagesData;
@ -28,8 +32,11 @@ export interface StaticBuildOptions {
export async function staticBuild(opts: StaticBuildOptions) { export async function staticBuild(opts: StaticBuildOptions) {
const { allPages, astroConfig } = opts; const { allPages, astroConfig } = opts;
// The pages to be built for rendering purposes.
const pageInput = new Set<string>();
// The JavaScript entrypoints. // The JavaScript entrypoints.
const jsInput: Set<string> = new Set(); const jsInput = new Set<string>();
// A map of each page .astro file, to the PageBuildData which contains information // A map of each page .astro file, to the PageBuildData which contains information
// about that page, such as its paths. // about that page, such as its paths.
@ -37,26 +44,35 @@ export async function staticBuild(opts: StaticBuildOptions) {
for (const [component, pageData] of Object.entries(allPages)) { for (const [component, pageData] of Object.entries(allPages)) {
const [renderers, mod] = pageData.preload; const [renderers, mod] = pageData.preload;
const metadata = mod.$$metadata;
// Hydrated components are statically identified. const topLevelImports = new Set([
for (const path of mod.$$metadata.getAllHydratedComponentPaths()) { // Any component that gets hydrated
// Note that this part is not yet implemented in the static build. ...metadata.hydratedComponentPaths(),
//jsInput.add(path); // Any hydration directive like astro/client/idle.js
...metadata.hydrationDirectiveSpecifiers(),
// The client path for each renderer
...renderers.filter((renderer) => !!renderer.source).map((renderer) => renderer.source!),
]);
for (const specifier of topLevelImports) {
jsInput.add(specifier);
} }
let astroModuleId = new URL('./' + component, astroConfig.projectRoot).pathname; let astroModuleId = new URL('./' + component, astroConfig.projectRoot).pathname;
jsInput.add(astroModuleId); pageInput.add(astroModuleId);
facadeIdToPageDataMap.set(astroModuleId, pageData); facadeIdToPageDataMap.set(astroModuleId, pageData);
} }
// Build internals needed by the CSS plugin // Build internals needed by the CSS plugin
const internals = createBuildInternals(); const internals = createBuildInternals();
// Perform the SSR build // Run the SSR build and client build in parallel
const result = (await ssrBuild(opts, internals, jsInput)) as RollupOutput; const [ssrResult] = (await Promise.all([ssrBuild(opts, internals, pageInput), clientBuild(opts, internals, jsInput)])) as RollupOutput[];
// Generate each of the pages. // Generate each of the pages.
await generatePages(result, opts, internals, facadeIdToPageDataMap); await generatePages(ssrResult, opts, internals, facadeIdToPageDataMap);
await cleanSsrOutput(opts);
} }
async function ssrBuild(opts: StaticBuildOptions, internals: BuildInternals, input: Set<string>) { async function ssrBuild(opts: StaticBuildOptions, internals: BuildInternals, input: Set<string>) {
@ -67,7 +83,7 @@ async function ssrBuild(opts: StaticBuildOptions, internals: BuildInternals, inp
mode: 'production', mode: 'production',
build: { build: {
emptyOutDir: true, emptyOutDir: true,
minify: false, // 'esbuild', // significantly faster than "terser" but may produce slightly-bigger bundles minify: false,
outDir: fileURLToPath(astroConfig.dist), outDir: fileURLToPath(astroConfig.dist),
ssr: true, ssr: true,
rollupOptions: { rollupOptions: {
@ -79,7 +95,41 @@ async function ssrBuild(opts: StaticBuildOptions, internals: BuildInternals, inp
target: 'es2020', // must match an esbuild target target: 'es2020', // must match an esbuild target
}, },
plugins: [ plugins: [
vitePluginNewBuild(), vitePluginNewBuild(input, internals, 'mjs'),
rollupPluginAstroBuildCSS({
internals,
}),
...(viteConfig.plugins || []),
],
publicDir: viteConfig.publicDir,
root: viteConfig.root,
envPrefix: 'PUBLIC_',
server: viteConfig.server,
base: astroConfig.buildOptions.site ? new URL(astroConfig.buildOptions.site).pathname : '/',
});
}
async function clientBuild(opts: StaticBuildOptions, internals: BuildInternals, input: Set<string>) {
const { astroConfig, viteConfig } = opts;
return await vite.build({
logLevel: 'error',
mode: 'production',
build: {
emptyOutDir: false,
minify: 'esbuild',
outDir: fileURLToPath(astroConfig.dist),
rollupOptions: {
input: Array.from(input),
output: {
format: 'esm',
},
preserveEntrySignatures: 'exports-only',
},
target: 'es2020', // must match an esbuild target
},
plugins: [
vitePluginNewBuild(input, internals, 'js'),
rollupPluginAstroBuildCSS({ rollupPluginAstroBuildCSS({
internals, internals,
}), }),
@ -124,6 +174,7 @@ async function generatePage(output: OutputChunk, opts: StaticBuildOptions, inter
const generationOptions: Readonly<GeneratePathOptions> = { const generationOptions: Readonly<GeneratePathOptions> = {
pageData, pageData,
internals,
linkIds, linkIds,
Component, Component,
}; };
@ -136,13 +187,14 @@ async function generatePage(output: OutputChunk, opts: StaticBuildOptions, inter
interface GeneratePathOptions { interface GeneratePathOptions {
pageData: PageBuildData; pageData: PageBuildData;
internals: BuildInternals;
linkIds: string[]; linkIds: string[];
Component: AstroComponentFactory; Component: AstroComponentFactory;
} }
async function generatePath(path: string, opts: StaticBuildOptions, gopts: GeneratePathOptions) { async function generatePath(pathname: string, opts: StaticBuildOptions, gopts: GeneratePathOptions) {
const { astroConfig, logging, origin, routeCache } = opts; const { astroConfig, logging, origin, routeCache } = opts;
const { Component, linkIds, pageData } = gopts; const { Component, internals, linkIds, pageData } = gopts;
const [renderers, mod] = pageData.preload; const [renderers, mod] = pageData.preload;
@ -151,14 +203,36 @@ async function generatePath(path: string, opts: StaticBuildOptions, gopts: Gener
route: pageData.route, route: pageData.route,
routeCache, routeCache,
logging, logging,
pathname: path, pathname,
mod, mod,
}); });
info(logging, 'generate', `Generating: ${path}`); debug(logging, 'generate', `Generating: ${pathname}`);
const html = await renderComponent(renderers, Component, astroConfig, path, origin, params, pageProps, linkIds); const result = createResult({ astroConfig, origin, params, pathname, renderers });
const outFolder = new URL('.' + path + '/', astroConfig.dist); result.links = new Set<SSRElement>(
linkIds.map((href) => ({
props: {
rel: 'stylesheet',
href,
},
children: '',
}))
);
// Override the `resolve` method so that hydrated components are given the
// hashed filepath to the component.
result.resolve = async (specifier: string) => {
const hashedFilePath = internals.entrySpecifierToBundleMap.get(specifier);
if (typeof hashedFilePath !== 'string') {
throw new Error(`Cannot find the built path for ${specifier}`);
}
const relPath = npath.posix.relative(pathname, '/' + hashedFilePath);
const fullyRelativePath = relPath[0] === '.' ? relPath : './' + relPath;
return fullyRelativePath;
};
let html = await renderPage(result, Component, pageProps, null);
const outFolder = new URL('.' + pathname + '/', astroConfig.dist);
const outFile = new URL('./index.html', outFolder); const outFile = new URL('./index.html', outFolder);
await fs.promises.mkdir(outFolder, { recursive: true }); await fs.promises.mkdir(outFolder, { recursive: true });
await fs.promises.writeFile(outFile, html, 'utf-8'); await fs.promises.writeFile(outFile, html, 'utf-8');
@ -167,7 +241,20 @@ async function generatePath(path: string, opts: StaticBuildOptions, gopts: Gener
} }
} }
export function vitePluginNewBuild(): VitePlugin { async function cleanSsrOutput(opts: StaticBuildOptions) {
// The SSR output is all .mjs files, the client output is not.
const files = await glob('**/*.mjs', {
cwd: opts.astroConfig.dist.pathname,
});
await Promise.all(
files.map(async (filename) => {
const url = new URL(filename, opts.astroConfig.dist);
await fs.promises.rm(url);
})
);
}
export function vitePluginNewBuild(input: Set<string>, internals: BuildInternals, ext: 'js' | 'mjs'): VitePlugin {
return { return {
name: '@astro/rollup-plugin-new-build', name: '@astro/rollup-plugin-new-build',
@ -183,13 +270,34 @@ export function vitePluginNewBuild(): VitePlugin {
outputOptions(outputOptions) { outputOptions(outputOptions) {
Object.assign(outputOptions, { Object.assign(outputOptions, {
entryFileNames(_chunk: PreRenderedChunk) { entryFileNames(_chunk: PreRenderedChunk) {
return 'assets/[name].[hash].mjs'; return 'assets/[name].[hash].' + ext;
}, },
chunkFileNames(_chunk: PreRenderedChunk) { chunkFileNames(_chunk: PreRenderedChunk) {
return 'assets/[name].[hash].mjs'; return 'assets/[name].[hash].' + ext;
}, },
}); });
return outputOptions; return outputOptions;
}, },
async generateBundle(_options, bundle) {
const promises = [];
const mapping = new Map<string, string>();
for (const specifier of input) {
promises.push(
this.resolve(specifier).then((result) => {
if (result) {
mapping.set(result.id, specifier);
}
})
);
}
await Promise.all(promises);
for (const [, chunk] of Object.entries(bundle)) {
if (chunk.type === 'chunk' && chunk.facadeModuleId && mapping.has(chunk.facadeModuleId)) {
const specifier = mapping.get(chunk.facadeModuleId)!;
internals.entrySpecifierToBundleMap.set(specifier, chunk.fileName);
}
}
},
}; };
} }

View file

@ -1,34 +1,19 @@
import type { BuildResult } from 'esbuild'; import type { BuildResult } from 'esbuild';
import type vite from '../vite'; import type vite from '../vite';
import type { import type { AstroConfig, ComponentInstance, GetStaticPathsResult, Params, Props, Renderer, RouteCache, RouteData, RuntimeMode, SSRElement, SSRError } from '../../@types/astro';
AstroConfig,
AstroGlobal,
AstroGlobalPartial,
ComponentInstance,
GetStaticPathsResult,
Params,
Props,
Renderer,
RouteCache,
RouteData,
RuntimeMode,
SSRElement,
SSRError,
SSRResult,
} from '../../@types/astro';
import type { LogOptions } from '../logger'; import type { LogOptions } from '../logger';
import type { AstroComponentFactory } from '../../runtime/server/index';
import eol from 'eol'; import eol from 'eol';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { renderPage, renderSlot } from '../../runtime/server/index.js'; import { renderPage } from '../../runtime/server/index.js';
import { canonicalURL as getCanonicalURL, codeFrame, resolveDependency } from '../util.js'; import { codeFrame, resolveDependency } from '../util.js';
import { getStylesForURL } from './css.js'; import { getStylesForURL } from './css.js';
import { injectTags } from './html.js'; import { injectTags } from './html.js';
import { generatePaginateFunction } from './paginate.js'; import { generatePaginateFunction } from './paginate.js';
import { getParams, validateGetStaticPathsModule, validateGetStaticPathsResult } from './routing.js'; import { getParams, validateGetStaticPathsModule, validateGetStaticPathsResult } from './routing.js';
import { createResult } from './result.js';
const svelteStylesRE = /svelte\?svelte&type=style/; const svelteStylesRE = /svelte\?svelte&type=style/;
@ -139,75 +124,6 @@ export async function preload({ astroConfig, filePath, viteServer }: SSROptions)
return [renderers, mod]; return [renderers, mod];
} }
export async function renderComponent(
renderers: Renderer[],
Component: AstroComponentFactory,
astroConfig: AstroConfig,
pathname: string,
origin: string,
params: Params,
pageProps: Props,
links: string[] = []
): Promise<string> {
const _links = new Set<SSRElement>(
links.map((href) => ({
props: {
rel: 'stylesheet',
href,
},
children: '',
}))
);
const result: SSRResult = {
styles: new Set<SSRElement>(),
scripts: new Set<SSRElement>(),
links: _links,
/** This function returns the `Astro` faux-global */
createAstro(astroGlobal: AstroGlobalPartial, props: Record<string, any>, slots: Record<string, any> | null) {
const site = new URL(origin);
const url = new URL('.' + pathname, site);
const canonicalURL = getCanonicalURL('.' + pathname, astroConfig.buildOptions.site || origin);
return {
__proto__: astroGlobal,
props,
request: {
canonicalURL,
params,
url,
},
slots: Object.fromEntries(Object.entries(slots || {}).map(([slotName]) => [slotName, true])),
// This is used for <Markdown> but shouldn't be used publicly
privateRenderSlotDoNotUse(slotName: string) {
return renderSlot(result, slots ? slots[slotName] : null);
},
// <Markdown> also needs the same `astroConfig.markdownOptions.render` as `.md` pages
async privateRenderMarkdownDoNotUse(content: string, opts: any) {
let mdRender = astroConfig.markdownOptions.render;
let renderOpts = {};
if (Array.isArray(mdRender)) {
renderOpts = mdRender[1];
mdRender = mdRender[0];
}
if (typeof mdRender === 'string') {
({ default: mdRender } = await import(mdRender));
}
const { code } = await mdRender(content, { ...renderOpts, ...(opts ?? {}) });
return code;
},
} as unknown as AstroGlobal;
},
_metadata: {
renderers,
pathname,
experimentalStaticBuild: astroConfig.buildOptions.experimentalStaticBuild,
},
};
let html = await renderPage(result, Component, pageProps, null);
return html;
}
export async function getParamsAndProps({ export async function getParamsAndProps({
route, route,
routeCache, routeCache,
@ -292,57 +208,10 @@ export async function render(renderers: Renderer[], mod: ComponentInstance, ssrO
if (!Component) throw new Error(`Expected an exported Astro component but received typeof ${typeof Component}`); if (!Component) throw new Error(`Expected an exported Astro component but received typeof ${typeof Component}`);
if (!Component.isAstroComponentFactory) throw new Error(`Unable to SSR non-Astro component (${route?.component})`); if (!Component.isAstroComponentFactory) throw new Error(`Unable to SSR non-Astro component (${route?.component})`);
// Create the result object that will be passed into the render function. const result = createResult({ astroConfig, origin, params, pathname, renderers });
// This object starts here as an empty shell (not yet the result) but then result.resolve = async (s: string) => {
// calling the render() function will populate the object with scripts, styles, etc. const [, resolvedPath] = await viteServer.moduleGraph.resolveUrl(s);
const result: SSRResult = { return resolvedPath;
styles: new Set<SSRElement>(),
scripts: new Set<SSRElement>(),
links: new Set<SSRElement>(),
/** This function returns the `Astro` faux-global */
createAstro(astroGlobal: AstroGlobalPartial, props: Record<string, any>, slots: Record<string, any> | null) {
const site = new URL(origin);
const url = new URL('.' + pathname, site);
const canonicalURL = getCanonicalURL('.' + pathname, astroConfig.buildOptions.site || origin);
return {
__proto__: astroGlobal,
props,
request: {
canonicalURL,
params,
url,
},
slots: Object.fromEntries(Object.entries(slots || {}).map(([slotName]) => [slotName, true])),
// This is used for <Markdown> but shouldn't be used publicly
privateRenderSlotDoNotUse(slotName: string) {
return renderSlot(result, slots ? slots[slotName] : null);
},
// <Markdown> also needs the same `astroConfig.markdownOptions.render` as `.md` pages
async privateRenderMarkdownDoNotUse(content: string, opts: any) {
let mdRender = astroConfig.markdownOptions.render;
let renderOpts = {};
if (Array.isArray(mdRender)) {
renderOpts = mdRender[1];
mdRender = mdRender[0];
}
// ['rehype-toc', opts]
if (typeof mdRender === 'string') {
({ default: mdRender } = await import(mdRender));
}
// [import('rehype-toc'), opts]
else if (mdRender instanceof Promise) {
({ default: mdRender } = await mdRender);
}
const { code } = await mdRender(content, { ...renderOpts, ...(opts ?? {}) });
return code;
},
} as unknown as AstroGlobal;
},
_metadata: {
renderers,
pathname,
experimentalStaticBuild: astroConfig.buildOptions.experimentalStaticBuild,
},
}; };
let html = await renderPage(result, Component, pageProps, null); let html = await renderPage(result, Component, pageProps, null);
@ -387,7 +256,7 @@ export async function render(renderers: Renderer[], mod: ComponentInstance, ssrO
html = injectTags(html, tags); html = injectTags(html, tags);
// run transformIndexHtml() in dev to run Vite dev transformations // run transformIndexHtml() in dev to run Vite dev transformations
if (mode === 'development') { if (mode === 'development' && !astroConfig.buildOptions.experimentalStaticBuild) {
const relativeURL = filePath.href.replace(astroConfig.projectRoot.href, '/'); const relativeURL = filePath.href.replace(astroConfig.projectRoot.href, '/');
html = await viteServer.transformIndexHtml(relativeURL, html, pathname); html = await viteServer.transformIndexHtml(relativeURL, html, pathname);
} }

View file

@ -0,0 +1,75 @@
import type { AstroConfig, AstroGlobal, AstroGlobalPartial, Params, Renderer, SSRElement, SSRResult } from '../../@types/astro';
import { canonicalURL as getCanonicalURL } from '../util.js';
import { renderSlot } from '../../runtime/server/index.js';
export interface CreateResultArgs {
astroConfig: AstroConfig;
origin: string;
params: Params;
pathname: string;
renderers: Renderer[];
}
export function createResult(args: CreateResultArgs): SSRResult {
const { astroConfig, origin, params, pathname, renderers } = args;
// Create the result object that will be passed into the render function.
// This object starts here as an empty shell (not yet the result) but then
// calling the render() function will populate the object with scripts, styles, etc.
const result: SSRResult = {
styles: new Set<SSRElement>(),
scripts: new Set<SSRElement>(),
links: new Set<SSRElement>(),
/** This function returns the `Astro` faux-global */
createAstro(astroGlobal: AstroGlobalPartial, props: Record<string, any>, slots: Record<string, any> | null) {
const site = new URL(origin);
const url = new URL('.' + pathname, site);
const canonicalURL = getCanonicalURL('.' + pathname, astroConfig.buildOptions.site || origin);
return {
__proto__: astroGlobal,
props,
request: {
canonicalURL,
params,
url,
},
slots: Object.fromEntries(Object.entries(slots || {}).map(([slotName]) => [slotName, true])),
// This is used for <Markdown> but shouldn't be used publicly
privateRenderSlotDoNotUse(slotName: string) {
return renderSlot(result, slots ? slots[slotName] : null);
},
// <Markdown> also needs the same `astroConfig.markdownOptions.render` as `.md` pages
async privateRenderMarkdownDoNotUse(content: string, opts: any) {
let mdRender = astroConfig.markdownOptions.render;
let renderOpts = {};
if (Array.isArray(mdRender)) {
renderOpts = mdRender[1];
mdRender = mdRender[0];
}
// ['rehype-toc', opts]
if (typeof mdRender === 'string') {
({ default: mdRender } = await import(mdRender));
}
// [import('rehype-toc'), opts]
else if (mdRender instanceof Promise) {
({ default: mdRender } = await mdRender);
}
const { code } = await mdRender(content, { ...renderOpts, ...(opts ?? {}) });
return code;
},
} as unknown as AstroGlobal;
},
// This is a stub and will be implemented by dev and build.
async resolve(s: string): Promise<string> {
return '';
},
_metadata: {
renderers,
pathname,
experimentalStaticBuild: astroConfig.buildOptions.experimentalStaticBuild,
},
};
return result;
}

View file

@ -1,8 +1,8 @@
import type { AstroComponentMetadata } from '../../@types/astro'; import type { AstroComponentMetadata } from '../../@types/astro';
import type { SSRElement } from '../../@types/astro'; import type { SSRElement, SSRResult } from '../../@types/astro';
import { valueToEstree } from 'estree-util-value-to-estree'; import { valueToEstree } from 'estree-util-value-to-estree';
import * as astring from 'astring'; import * as astring from 'astring';
import { serializeListValue } from './util.js'; import { hydrationSpecifier, serializeListValue } from './util.js';
const { generate, GENERATOR } = astring; const { generate, GENERATOR } = astring;
@ -69,6 +69,11 @@ export function extractDirectives(inputProps: Record<string | number, any>): Ext
extracted.hydration.componentExport.value = value; extracted.hydration.componentExport.value = value;
break; break;
} }
// This is a special prop added to prove that the client hydration method
// was added statically.
case 'client:component-hydration': {
break;
}
default: { default: {
extracted.hydration.directive = key.split(':')[1]; extracted.hydration.directive = key.split(':')[1];
extracted.hydration.value = value; extracted.hydration.value = value;
@ -93,18 +98,20 @@ export function extractDirectives(inputProps: Record<string | number, any>): Ext
extracted.props[key] = value; extracted.props[key] = value;
} }
} }
return extracted; return extracted;
} }
interface HydrateScriptOptions { interface HydrateScriptOptions {
renderer: any; renderer: any;
result: SSRResult;
astroId: string; astroId: string;
props: Record<string | number, any>; props: Record<string | number, any>;
} }
/** For hydrated components, generate a <script type="module"> to load the component */ /** For hydrated components, generate a <script type="module"> to load the component */
export async function generateHydrateScript(scriptOptions: HydrateScriptOptions, metadata: Required<AstroComponentMetadata>): Promise<SSRElement> { export async function generateHydrateScript(scriptOptions: HydrateScriptOptions, metadata: Required<AstroComponentMetadata>): Promise<SSRElement> {
const { renderer, astroId, props } = scriptOptions; const { renderer, result, astroId, props } = scriptOptions;
const { hydrate, componentUrl, componentExport } = metadata; const { hydrate, componentUrl, componentExport } = metadata;
if (!componentExport) { if (!componentExport) {
@ -117,7 +124,9 @@ export async function generateHydrateScript(scriptOptions: HydrateScriptOptions,
} }
hydrationSource += renderer.source hydrationSource += renderer.source
? `const [{ ${componentExport.value}: Component }, { default: hydrate }] = await Promise.all([import("${componentUrl}"), import("${renderer.source}")]); ? `const [{ ${componentExport.value}: Component }, { default: hydrate }] = await Promise.all([import("${await result.resolve(componentUrl)}"), import("${await result.resolve(
renderer.source
)}")]);
return (el, children) => hydrate(el)(Component, ${serializeProps(props)}, children); return (el, children) => hydrate(el)(Component, ${serializeProps(props)}, children);
` `
: `await import("${componentUrl}"); : `await import("${componentUrl}");
@ -126,7 +135,7 @@ export async function generateHydrateScript(scriptOptions: HydrateScriptOptions,
const hydrationScript = { const hydrationScript = {
props: { type: 'module', 'data-astro-component-hydration': true }, props: { type: 'module', 'data-astro-component-hydration': true },
children: `import setup from 'astro/client/${hydrate}.js'; children: `import setup from '${await result.resolve(hydrationSpecifier(hydrate))}';
setup("${astroId}", {${metadata.hydrateArgs ? `value: ${JSON.stringify(metadata.hydrateArgs)}` : ''}}, async () => { setup("${astroId}", {${metadata.hydrateArgs ? `value: ${JSON.stringify(metadata.hydrateArgs)}` : ''}}, async () => {
${hydrationSource} ${hydrationSource}
}); });

View file

@ -249,7 +249,7 @@ If you're still stuck, please open an issue on GitHub or join us at https://astr
// Rather than appending this inline in the page, puts this into the `result.scripts` set that will be appended to the head. // Rather than appending this inline in the page, puts this into the `result.scripts` set that will be appended to the head.
// INVESTIGATE: This will likely be a problem in streaming because the `<head>` will be gone at this point. // INVESTIGATE: This will likely be a problem in streaming because the `<head>` will be gone at this point.
result.scripts.add(await generateHydrateScript({ renderer, astroId, props }, metadata as Required<AstroComponentMetadata>)); result.scripts.add(await generateHydrateScript({ renderer, result, astroId, props }, metadata as Required<AstroComponentMetadata>));
return `<astro-root uid="${astroId}">${html ?? ''}</astro-root>`; return `<astro-root uid="${astroId}">${html ?? ''}</astro-root>`;
} }

View file

@ -1,3 +1,5 @@
import { hydrationSpecifier } from './util.js';
interface ModuleInfo { interface ModuleInfo {
module: Record<string, any>; module: Record<string, any>;
specifier: string; specifier: string;
@ -8,11 +10,28 @@ interface ComponentMetadata {
componentUrl: string; componentUrl: string;
} }
interface CreateMetadataOptions {
modules: ModuleInfo[];
hydratedComponents: any[];
hydrationDirectives: Set<string>;
hoisted: any[];
}
export class Metadata { export class Metadata {
public fileURL: URL; public fileURL: URL;
public modules: ModuleInfo[];
public hoisted: any[];
public hydratedComponents: any[];
public hydrationDirectives: Set<string>;
private metadataCache: Map<any, ComponentMetadata | null>; private metadataCache: Map<any, ComponentMetadata | null>;
constructor(fileURL: string, public modules: ModuleInfo[], public hydratedComponents: any[], public hoisted: any[]) {
this.fileURL = new URL(fileURL); constructor(filePathname: string, opts: CreateMetadataOptions) {
this.modules = opts.modules;
this.hoisted = opts.hoisted;
this.hydratedComponents = opts.hydratedComponents;
this.hydrationDirectives = opts.hydrationDirectives;
this.fileURL = new URL(filePathname, 'http://example.com');
this.metadataCache = new Map<any, ComponentMetadata | null>(); this.metadataCache = new Map<any, ComponentMetadata | null>();
} }
@ -30,24 +49,50 @@ export class Metadata {
return metadata?.componentExport || null; return metadata?.componentExport || null;
} }
// Recursively collect all of the hydrated components' paths. /**
getAllHydratedComponentPaths(): Set<string> { * Gets the paths of all hydrated components within this component
const paths = new Set<string>(); * and children components.
for (const component of this.hydratedComponents) { */
const path = this.getPath(component); *hydratedComponentPaths() {
if (path) { const found = new Set<string>();
paths.add(path); for (const metadata of this.deepMetadata()) {
} for (const component of metadata.hydratedComponents) {
} const path = this.getPath(component);
if (path && !found.has(path)) {
for (const { module: mod } of this.modules) { found.add(path);
if (typeof mod.$$metadata !== 'undefined') { yield path;
for (const path of mod.$$metadata.getAllHydratedComponentPaths()) { }
paths.add(path); }
}
}
/**
* Gets all of the hydration specifiers used within this component.
*/
*hydrationDirectiveSpecifiers() {
for (const directive of this.hydrationDirectives) {
yield hydrationSpecifier(directive);
}
}
private *deepMetadata(): Generator<Metadata, void, unknown> {
// Yield self
yield this;
// Keep a Set of metadata objects so we only yield them out once.
const seen = new Set<Metadata>();
for (const { module: mod } of this.modules) {
if (typeof mod.$$metadata !== 'undefined') {
const md = mod.$$metadata as Metadata;
// Call children deepMetadata() which will yield the child metadata
// and any of its children metadatas
for (const childMetdata of md.deepMetadata()) {
if (!seen.has(childMetdata)) {
seen.add(childMetdata);
yield childMetdata;
}
} }
} }
} }
return paths;
} }
private getComponentMetadata(Component: any): ComponentMetadata | null { private getComponentMetadata(Component: any): ComponentMetadata | null {
@ -83,12 +128,6 @@ export class Metadata {
} }
} }
interface CreateMetadataOptions { export function createMetadata(filePathname: string, options: CreateMetadataOptions) {
modules: ModuleInfo[]; return new Metadata(filePathname, options);
hydratedComponents: any[];
hoisted: any[];
}
export function createMetadata(fileURL: string, options: CreateMetadataOptions) {
return new Metadata(fileURL, options.modules, options.hydratedComponents, options.hoisted);
} }

View file

@ -1,3 +1,10 @@
function formatList(values: string[]): string {
if (values.length === 1) {
return values[0];
}
return `${values.slice(0, -1).join(', ')} or ${values[values.length - 1]}`;
}
export function serializeListValue(value: any) { export function serializeListValue(value: any) {
const hash: Record<string, any> = {}; const hash: Record<string, any> = {};
@ -27,3 +34,12 @@ export function serializeListValue(value: any) {
} }
} }
} }
/**
* Get the import specifier for a given hydration directive.
* @param hydrate The hydration directive such as `idle` or `visible`
* @returns
*/
export function hydrationSpecifier(hydrate: string) {
return `astro/client/${hydrate}.js`;
}

View file

@ -29,8 +29,10 @@ function isSSR(options: undefined | boolean | { ssr: boolean }): boolean {
async function compile(config: AstroConfig, filename: string, source: string, viteTransform: TransformHook, opts: boolean | undefined) { async function compile(config: AstroConfig, filename: string, source: string, viteTransform: TransformHook, opts: boolean | undefined) {
// pages and layouts should be transformed as full documents (implicit <head> <body> etc) // pages and layouts should be transformed as full documents (implicit <head> <body> etc)
// everything else is treated as a fragment // everything else is treated as a fragment
const normalizedID = fileURLToPath(new URL(`file://${filename}`)); const filenameURL = new URL(`file://${filename}`);
const normalizedID = fileURLToPath(filenameURL);
const isPage = normalizedID.startsWith(fileURLToPath(config.pages)) || normalizedID.startsWith(fileURLToPath(config.layouts)); const isPage = normalizedID.startsWith(fileURLToPath(config.pages)) || normalizedID.startsWith(fileURLToPath(config.layouts));
const pathname = filenameURL.pathname.substr(config.projectRoot.pathname.length - 1);
let cssTransformError: Error | undefined; let cssTransformError: Error | undefined;
@ -39,6 +41,7 @@ async function compile(config: AstroConfig, filename: string, source: string, vi
// result passed to esbuild, but also available in the catch handler. // result passed to esbuild, but also available in the catch handler.
const transformResult = await transform(source, { const transformResult = await transform(source, {
as: isPage ? 'document' : 'fragment', as: isPage ? 'document' : 'fragment',
pathname,
projectRoot: config.projectRoot.toString(), projectRoot: config.projectRoot.toString(),
site: config.buildOptions.site, site: config.buildOptions.site,
sourcefile: filename, sourcefile: filename,

View file

@ -69,7 +69,7 @@ export function rollupPluginAstroBuildHTML(options: PluginOptions): VitePlugin {
const [renderers, mod] = pageData.preload; const [renderers, mod] = pageData.preload;
// Hydrated components are statically identified. // Hydrated components are statically identified.
for (const path of mod.$$metadata.getAllHydratedComponentPaths()) { for (const path of mod.$$metadata.hydratedComponentPaths()) {
jsInput.add(path); jsInput.add(path);
} }

View file

@ -51,8 +51,12 @@ ${setup}`.trim();
astroResult = `${prelude}\n${astroResult}`; astroResult = `${prelude}\n${astroResult}`;
} }
const filenameURL = new URL(`file://${id}`);
const pathname = filenameURL.pathname.substr(config.projectRoot.pathname.length - 1);
// Transform from `.astro` to valid `.ts` // Transform from `.astro` to valid `.ts`
let { code: tsResult } = await transform(astroResult, { let { code: tsResult } = await transform(astroResult, {
pathname,
projectRoot: config.projectRoot.toString(), projectRoot: config.projectRoot.toString(),
site: config.buildOptions.site, site: config.buildOptions.site,
sourcefile: id, sourcefile: id,

View file

@ -20,7 +20,7 @@ describe('Client only components', () => {
const script = await fixture.readFile(src); const script = await fixture.readFile(src);
// test 2: svelte renderer is on the page // test 2: svelte renderer is on the page
const exp = /import\("(.\/client.*)"\)/g; const exp = /import\("(.\/@astrojs_renderer-svelte_client.*)"\)/g;
let match, svelteRenderer; let match, svelteRenderer;
while ((match = exp.exec(script))) { while ((match = exp.exec(script))) {
svelteRenderer = match[1].replace(/^\./, '/assets/'); svelteRenderer = match[1].replace(/^\./, '/assets/');

View file

@ -122,10 +122,10 @@
jsonpointer "^5.0.0" jsonpointer "^5.0.0"
leven "^3.1.0" leven "^3.1.0"
"@astrojs/compiler@^0.6.0": "@astrojs/compiler@^0.7.0":
version "0.6.2" version "0.7.0"
resolved "https://registry.yarnpkg.com/@astrojs/compiler/-/compiler-0.6.2.tgz#f9f6d2bfabc70921fa2be9da49767f878a1bc1e4" resolved "https://registry.yarnpkg.com/@astrojs/compiler/-/compiler-0.7.0.tgz#b2c93c0df6cfe84360043918bb581f8cec1fac31"
integrity sha512-okzco1cwAPC1Fs1EovCckQpZFLAkuysTM+0qVXQ41fE6mLxmq/4i7fFR7l0Wy/0JapgcRQbK5xN4Y08ku4EPQg== integrity sha512-lnEdXsGSCMbUfVfXewvYKzTOyHYxsiZHmePNc5YBGjNUvJsFljr4ttMAE958gvwRWltOuaVqc4HuNSx6ylL/hQ==
dependencies: dependencies:
typescript "^4.3.5" typescript "^4.3.5"