refactor how client directives work

This commit is contained in:
Matthew Phillips 2022-09-01 10:49:00 -04:00
parent 005d53145f
commit dd6c06e229
12 changed files with 128 additions and 14 deletions

View file

@ -881,6 +881,7 @@ export interface AstroConfig extends z.output<typeof AstroConfigSchema> {
adapter: AstroAdapter | undefined;
renderers: AstroRenderer[];
scripts: { stage: InjectedScriptStage; content: string }[];
clientDirectives: ClientDirectiveMap;
};
}
@ -1200,6 +1201,7 @@ export interface SSRElement {
export interface SSRMetadata {
renderers: SSRLoadedRenderer[];
clientDirectives: ClientDirectiveMap;
pathname: string;
hasHydrationScript: boolean;
hasDirectives: Set<string>;
@ -1220,3 +1222,18 @@ export interface SSRResult {
}
export type MarkdownAstroData = { frontmatter: object };
/* Client Directives */
export type ClientDirectiveMap = Map<string, { type: 'inline' | 'external', src: string }>;
type Hydrate = () => Promise<void>;
type Load = () => Promise<Hydrate>;
type DirectiveOptions = {
// The component displayName
name: string;
// The attribute value provided,
// for ex `client:interactive="click"
value: string;
}
export type ClientDirective = (load: Load, opts: DirectiveOptions, element: HTMLElement) => void;

View file

@ -353,6 +353,12 @@ export async function validateConfig(
renderers: [jsxRenderer],
injectedRoutes: [],
adapter: undefined,
clientDirectives: new Map([
['visible', {
type: 'external',
src: 'astro/runtime/client/visible.js'
}]
]),
},
};

View file

@ -16,6 +16,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 astroClientDirective from '../vite-plugin-client-directive/index.js';
import { createCustomViteLogger } from './errors.js';
import { resolveDependency } from './util.js';
@ -86,6 +87,7 @@ export async function createVite(
astroPostprocessVitePlugin({ config: astroConfig }),
astroIntegrationsContainerPlugin({ config: astroConfig, logging }),
astroScriptsPageSSRPlugin({ config: astroConfig }),
astroClientDirective({ config: astroConfig, logging }),
],
publicDir: fileURLToPath(astroConfig.publicDir),
root: fileURLToPath(astroConfig.root),

View file

@ -1,6 +1,7 @@
import type { MarkdownRenderingOptions } from '@astrojs/markdown-remark';
import type {
ComponentInstance,
ClientDirectiveMap,
Params,
Props,
RouteData,
@ -79,6 +80,7 @@ export interface RenderOptions {
scripts: Set<SSRElement>;
resolve: (s: string) => Promise<string>;
renderers: SSRLoadedRenderer[];
clientDirectives: ClientDirectiveMap;
route?: RouteData;
routeCache: RouteCache;
site?: string;
@ -91,6 +93,7 @@ export interface RenderOptions {
export async function render(opts: RenderOptions): Promise<Response> {
const {
adapterName,
clientDirectives,
links,
styles,
logging,
@ -145,6 +148,7 @@ export async function render(opts: RenderOptions): Promise<Response> {
pathname,
resolve,
renderers,
clientDirectives,
request,
site,
scripts,

View file

@ -168,6 +168,7 @@ export async function render(
let response = await coreRender({
adapterName: astroConfig.adapter?.name,
clientDirectives: astroConfig._ctx.clientDirectives,
links,
styles,
logging,

View file

@ -3,6 +3,7 @@ import { bold } from 'kleur/colors';
import type {
AstroGlobal,
AstroGlobalPartial,
ClientDirectiveMap,
Params,
Props,
RuntimeMode,
@ -36,6 +37,7 @@ export interface CreateResultArgs {
pathname: string;
props: Props;
renderers: SSRLoadedRenderer[];
clientDirectives: ClientDirectiveMap;
resolve: (s: string) => Promise<string>;
site: string | undefined;
links?: Set<SSRElement>;
@ -122,7 +124,7 @@ class Slots {
let renderMarkdown: any = null;
export function createResult(args: CreateResultArgs): SSRResult {
const { markdown, params, pathname, props: pageProps, renderers, request, resolve } = args;
const { clientDirectives, markdown, params, pathname, props: pageProps, renderers, request, resolve } = args;
const url = new URL(request.url);
const headers = new Headers();
@ -274,6 +276,7 @@ const canonicalURL = new URL(Astro.url.pathname, Astro.site);
resolve,
_metadata: {
renderers,
clientDirectives,
pathname,
hasHydrationScript: false,
hasDirectives: new Set(),

View file

@ -1,11 +1,13 @@
import { ClientDirective } from '../../@types/astro';
/**
* Hydrate this component when one of it's children becomes visible
* We target the children because `astro-island` is set to `display: contents`
* which doesn't work with IntersectionObserver
*/
(self.Astro = self.Astro || {}).visible = (getHydrateCallback, _opts, root) => {
const visible: ClientDirective = (load, _opts, root) => {
const cb = async () => {
let hydrate = await getHydrateCallback();
let hydrate = await load();
await hydrate();
};
@ -24,4 +26,5 @@
io.observe(child);
}
};
window.dispatchEvent(new Event('astro:visible'));
export default visible;

View file

@ -62,7 +62,7 @@ declare const Astro: {
start() {
const opts = JSON.parse(this.getAttribute('opts')!) as Record<string, any>;
const directive = this.getAttribute('client') as directiveAstroKeys;
if (Astro[directive] === undefined) {
if (typeof Astro === 'undefined' || Astro[directive] === undefined) {
window.addEventListener(`astro:${directive}`, () => this.start(), { once: true });
return;
}

View file

@ -15,7 +15,7 @@ export const Renderer = Symbol.for('astro:renderer');
// Rendering produces either marked strings of HTML or instructions for hydration.
// These directive instructions bubble all the way up to renderPage so that we
// can ensure they are added only once, and as soon as possible.
export function stringifyChunk(result: SSRResult, chunk: string | RenderInstruction) {
export function stringifyChunk(result: SSRResult, chunk: string | RenderInstruction): string | Promise<string> {
switch ((chunk as any).type) {
case 'directive': {
const { hydration } = chunk as RenderInstruction;
@ -29,7 +29,7 @@ export function stringifyChunk(result: SSRResult, chunk: string | RenderInstruct
? 'directive'
: null;
if (prescriptType) {
let prescripts = getPrescripts(prescriptType, hydration.directive);
let prescripts = getPrescripts(result, prescriptType, hydration.directive);
return markHTMLString(prescripts);
} else {
return '';

View file

@ -6,6 +6,7 @@ import { isAstroComponent, isAstroComponentFactory, renderAstroComponent } from
import { stringifyChunk } from './common.js';
import { renderComponent } from './component.js';
import { maybeRenderHead } from './head.js';
import { string } from 'zod';
const encoder = new TextEncoder();
const needsHeadRenderingSymbol = Symbol.for('astro.needsHeadRendering');
@ -72,7 +73,13 @@ export async function renderPage(
let i = 0;
try {
for await (const chunk of iterable) {
let html = stringifyChunk(result, chunk);
let html: string;
let stringChunk = stringifyChunk(result, chunk);
if((stringChunk as any).then !== undefined) {
html = await stringChunk;
} else {
html = (stringChunk as string);
}
if (i === 0) {
if (!/<!doctype html/i.test(html)) {
@ -94,7 +101,13 @@ export async function renderPage(
body = '';
let i = 0;
for await (const chunk of iterable) {
let html = stringifyChunk(result, chunk);
let html: string;
let stringChunk = stringifyChunk(result, chunk);
if((stringChunk as any).then !== undefined) {
html = await stringChunk;
} else {
html = (stringChunk as string);
}
if (i === 0) {
if (!/<!doctype html/i.test(html)) {
body += '<!DOCTYPE html>\n';

View file

@ -14,6 +14,8 @@ export function determineIfNeedsHydrationScript(result: SSRResult): boolean {
return (result._metadata.hasHydrationScript = true);
}
const ISLAND_STYLES = `<style>astro-island,astro-slot{display:contents}</style>`;
export const hydrationScripts: Record<string, string> = {
idle: idlePrebuilt,
load: loadPrebuilt,
@ -40,18 +42,47 @@ function getDirectiveScriptText(directive: string): string {
return directiveScriptText;
}
export function getPrescripts(type: PrescriptType, directive: string): string {
function getDirectiveScript(result: SSRResult, directive: string): string | Promise<string> {
if(!result._metadata.clientDirectives.has(directive)) {
// TODO better error message
throw new Error(`Unable to find directive ${directive}`);
}
let { type, src } = result._metadata.clientDirectives.get(directive)!;
switch(type) {
case 'external': {
return result.resolve(`${src}?astro-client-directive=${directive}`).then(value => {
return `<script type="module" src="${value}"></script>`;
});
}
case 'inline': {
throw new Error(`Inline not yet supported`);
}
}
}
function isPromise<T>(value: any): value is Promise<T> {
if(typeof value.then === 'function') {
return true;
}
return false;
}
export function getPrescripts(result: SSRResult, type: PrescriptType, directive: string): string | Promise<string> {
// Note that this is a classic script, not a module script.
// This is so that it executes immediate, and when the browser encounters
// an astro-island element the callbacks will fire immediately, causing the JS
// deps to be loaded immediately.
switch (type) {
case 'both':
return `<style>astro-island,astro-slot{display:contents}</style><script>${
getDirectiveScriptText(directive) + islandScript
}</script>`;
let directiveScript = getDirectiveScript(result, directive);
if(isPromise<string>(directiveScript)) {
return directiveScript.then(scriptText => {
return `${ISLAND_STYLES}${scriptText}<script>${islandScript}</script>`;
});
}
return `${ISLAND_STYLES}${directiveScript}<script>${islandScript}</script>`
case 'directive':
return `<script>${getDirectiveScriptText(directive)}</script>`;
return getDirectiveScript(result, directive);
}
return '';
}

View file

@ -0,0 +1,34 @@
import type * as vite from 'vite';
import type { AstroConfig, ManifestData } from '../@types/astro';
import { error, info, LogOptions, warn } from '../core/logger/core.js';
interface AstroPluginOptions {
config: AstroConfig;
logging: LogOptions;
}
export default function createPlugin({ config, logging }: AstroPluginOptions): vite.Plugin {
return {
name: 'astro:client-directive',
transform(code, id, opts = {}) {
let idx = id.indexOf('?astro-client-directive');
if(idx !== -1) {
let entrypoint = id.slice(0, idx);
let params = new URLSearchParams(id.slice(idx));
let directive = params.get('astro-client-directive');
return `
import directive from '${entrypoint}';
(self.Astro = self.Astro || {}).${directive} = directive;
window.dispatchEvent(new Event('astro:${directive}'));
`.trim()
}
/*if (opts.ssr) return;
if (!id.includes('vite/dist/client/client.mjs')) return;
return code
.replace(/\.tip \{[^}]*\}/gm, '.tip {\n display: none;\n}')
.replace(/\[vite\]/g, '[astro]');*/
},
};
}