[next] Support for custom elements (#1528)

* [next] Support for custom elements

* Fix eslint errors

* eslint again
This commit is contained in:
Matthew Phillips 2021-10-12 09:50:52 -04:00 committed by Drew Powers
parent dc7260bea2
commit 190fba394d
8 changed files with 147 additions and 64 deletions

View file

@ -0,0 +1,37 @@
type AstroRenderedHTML = string;
export type FetchContentResultBase = {
astro: {
headers: string[];
source: string;
html: AstroRenderedHTML;
};
url: URL;
};
export type FetchContentResult<T> = FetchContentResultBase & T;
export type Params = Record<string, string | undefined>;
interface AstroPageRequest {
url: URL;
canonicalURL: URL;
params: Params;
}
export interface AstroBuiltinProps {
'client:load'?: boolean;
'client:idle'?: boolean;
'client:media'?: string;
'client:visible'?: boolean;
}
export interface Astro {
isPage: boolean;
fetchContent<T = any>(globStr: string): Promise<FetchContentResult<T>[]>;
props: Record<string, number | string | any>;
request: AstroPageRequest;
resolve: (path: string) => string;
site: URL;
slots: Record<string, true | undefined>;
}

View file

@ -0,0 +1,14 @@
import { Astro as AstroGlobal } from './astro-file';
import { Renderer } from './astro';
export interface SSRMetadata {
importedModules: Record<string, any>;
renderers: Renderer[];
}
export interface SSRResult {
styles: Set<string>;
scripts: Set<string>;
createAstro(props: Record<string, any>, slots: Record<string, any> | null): AstroGlobal;
_metadata: SSRMetadata;
}

View file

@ -1,4 +1,5 @@
import type { AstroComponentMetadata } from '../@types/astro';
import type { SSRResult } from '../@types/ssr';
import { valueToEstree } from 'estree-util-value-to-estree';
import * as astring from 'astring';
@ -141,7 +142,7 @@ export async function renderSlot(result: any, slotted: string, fallback?: any) {
return fallback;
}
export async function renderComponent(result: any, displayName: string, Component: unknown, _props: Record<string | number, any>, slots: any = {}) {
export async function renderComponent(result: SSRResult, displayName: string, Component: unknown, _props: Record<string | number, any>, slots: any = {}) {
Component = await Component;
const children = await renderSlot(result, slots?.default);
const { renderers } = result._metadata;
@ -167,9 +168,16 @@ export async function renderComponent(result: any, displayName: string, Componen
metadata.hydrateArgs = hydrationDirective[1];
}
const isCustomElement = typeof Component === 'string';
for (const [url, exported] of Object.entries(result._metadata.importedModules)) {
for (const [key, value] of Object.entries(exported as any)) {
if (Component === value) {
if(isCustomElement) {
if (key === 'tagName' && Component === value) {
metadata.componentExport = { value: key };
metadata.componentUrl = url;
break;
}
} else if(Component === value) {
metadata.componentExport = { value: key };
metadata.componentUrl = url;
break;
@ -194,6 +202,11 @@ export async function renderComponent(result: any, displayName: string, Componen
({ html } = await renderer.ssr.renderToStaticMarkup(Component, props, children));
}
if (renderer?.polyfills?.length) {
let polyfillScripts = renderer.polyfills.map((src) => `<script type="module">import "${src}";</script>`).join('');
html = html + polyfillScripts;
}
if (!hydrationDirective) {
return html.replace(/\<\/?astro-fragment\>/g, '');
}

View file

@ -1,6 +1,8 @@
import type { BuildResult } from 'esbuild';
import type { ViteDevServer } from 'vite';
import type { AstroConfig, ComponentInstance, GetStaticPathsResult, Params, Props, RouteCache, RouteData, RuntimeMode, SSRError } from '../@types/astro';
import type { AstroConfig, ComponentInstance, GetStaticPathsResult, Params, Props, Renderer, RouteCache, RouteData, RuntimeMode, SSRError } from '../@types/astro';
import type { SSRResult } from '../@types/ssr';
import type { FetchContentResultBase, FetchContentResult } from '../@types/astro-file';
import type { LogOptions } from '../logger';
import cheerio from 'cheerio';
@ -40,32 +42,38 @@ interface SSROptions {
// this prevents client-side errors such as the "double React bug" (https://reactjs.org/warnings/invalid-hook-call-warning.html#mismatching-versions-of-react-and-react-dom)
let browserHash: string | undefined;
const cache = new Map();
const cache = new Map<string, Promise<Renderer>>();
// TODO: improve validation and error handling here.
async function resolveRenderers(viteServer: ViteDevServer, ids: string[]) {
async function resolveRenderer(viteServer: ViteDevServer, renderer: string) {
const resolvedRenderer: any = {};
// We can dynamically import the renderer by itself because it shouldn't have
// any non-standard imports, the index is just meta info.
// The other entrypoints need to be loaded through Vite.
const {
default: { name, client, polyfills, hydrationPolyfills, server },
} = await import(renderer);
resolvedRenderer.name = name;
if (client) resolvedRenderer.source = path.posix.join(renderer, client);
if (Array.isArray(hydrationPolyfills)) resolvedRenderer.hydrationPolyfills = hydrationPolyfills.map((src: string) => path.posix.join(renderer, src));
if (Array.isArray(polyfills)) resolvedRenderer.polyfills = polyfills.map((src: string) => path.posix.join(renderer, src));
const { url } = await viteServer.moduleGraph.ensureEntryFromUrl(path.posix.join(renderer, server));
const { default: rendererSSR } = await viteServer.ssrLoadModule(url);
resolvedRenderer.ssr = rendererSSR;
const completedRenderer: Renderer = resolvedRenderer;
return completedRenderer;
}
async function resolveRenderers(viteServer: ViteDevServer, ids: string[]): Promise<Renderer[]> {
const renderers = await Promise.all(
ids.map(async (renderer) => {
if (cache.has(renderer)) return cache.get(renderer);
const resolvedRenderer: any = {};
// We can dynamically import the renderer by itself because it shouldn't have
// any non-standard imports, the index is just meta info.
// The other entrypoints need to be loaded through Vite.
const {
default: { name, client, polyfills, hydrationPolyfills, server },
} = await import(renderer);
resolvedRenderer.name = name;
if (client) resolvedRenderer.source = path.posix.join(renderer, client);
if (Array.isArray(hydrationPolyfills)) resolvedRenderer.hydrationPolyfills = hydrationPolyfills.map((src: string) => path.posix.join(renderer, src));
if (Array.isArray(polyfills)) resolvedRenderer.polyfills = polyfills.map((src: string) => path.posix.join(renderer, src));
const { url } = await viteServer.moduleGraph.ensureEntryFromUrl(path.posix.join(renderer, server));
const { default: rendererSSR } = await viteServer.ssrLoadModule(url);
resolvedRenderer.ssr = rendererSSR;
cache.set(renderer, resolvedRenderer);
return resolvedRenderer;
ids.map(renderer => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (cache.has(renderer)) return cache.get(renderer)!;
let promise = resolveRenderer(viteServer, renderer);
cache.set(renderer, promise);
return promise;
})
);
@ -118,20 +126,26 @@ async function resolveImportedModules(viteServer: ViteDevServer, file: URL) {
/** use Vite to SSR */
export async function ssr({ astroConfig, filePath, logging, mode, origin, pathname, route, routeCache, viteServer }: SSROptions): Promise<string> {
try {
// 1. load module
// 1. resolve renderers
// Important this happens before load module in case a renderer provides polyfills.
const renderers = await resolveRenderers(viteServer, astroConfig.renderers);
// 1.5. load module
const mod = (await viteServer.ssrLoadModule(fileURLToPath(filePath))) as ComponentInstance;
// 1.5. resolve renderers and imported modules.
// 1.75. resolve renderers
// important that this happens _after_ ssrLoadModule, otherwise `importedModules` would be empty
const [renderers, importedModules] = await Promise.all([resolveRenderers(viteServer, astroConfig.renderers), resolveImportedModules(viteServer, filePath)]);
const importedModules = await resolveImportedModules(viteServer, filePath);
// 2. handle dynamic routes
let params: Params = {};
let pageProps: Props = {};
if (route && !route.pathname) {
if (route.params.length) {
const paramsMatch = route.pattern.exec(pathname)!;
params = getParams(route.params)(paramsMatch);
const paramsMatch = route.pattern.exec(pathname);
if(paramsMatch) {
params = getParams(route.params)(paramsMatch);
}
}
validateGetStaticPathsModule(mod);
routeCache[route.component] =
@ -163,11 +177,11 @@ export async function ssr({ astroConfig, filePath, logging, mode, origin, pathna
if (!Component.isAstroComponentFactory) throw new Error(`Unable to SSR non-Astro component (${route?.component})`);
const result = {
const result: SSRResult = {
styles: new Set(),
scripts: new Set(),
/** This function returns the `Astro` faux-global */
createAstro: (props: any, slots: Record<string, any> | null) => {
createAstro: (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);
@ -175,30 +189,46 @@ export async function ssr({ astroConfig, filePath, logging, mode, origin, pathna
return {
isPage: true,
site,
request: { url, canonicalURL },
request: {
canonicalURL,
params: {},
url
},
props,
fetchContent,
slots: Object.fromEntries(
Object.entries(slots || {}).map(([slotName]) => [slotName, true])
)
),
// Only temporary to get types working.
resolve(_s: string) {
throw new Error('Astro.resolve() is not currently supported in next.');
}
};
},
_metadata: { importedModules, renderers },
};
const createFetchContent = (currentFilePath: string) => {
function createFetchContent(currentFilePath: string) {
const fetchContentCache = new Map<string, any>();
return async (pattern: string) => {
return async function fetchContent<T>(pattern: string): Promise<FetchContentResult<T>[]> {
const cwd = path.dirname(currentFilePath);
const cacheKey = `${cwd}:${pattern}`;
if (fetchContentCache.has(cacheKey)) {
return fetchContentCache.get(cacheKey);
}
const files = await glob(pattern, { cwd, absolute: true });
const contents = await Promise.all(
const contents: FetchContentResult<T>[] = await Promise.all(
files.map(async (file) => {
const { metadata: astro = {}, frontmatter = {} } = (await viteServer.ssrLoadModule(file)) as any;
return { ...frontmatter, astro };
const loadedModule = await viteServer.ssrLoadModule(file);
const astro = (loadedModule.metadata || {}) as FetchContentResultBase['astro'];
const frontmatter = loadedModule.frontmatter || {};
//eslint-disable-next-line no-shadow
const result: FetchContentResult<T> = {
...frontmatter,
astro,
url: new URL('http://example.com') // TODO fix
};
return result;
})
);
fetchContentCache.set(cacheKey, contents);

View file

@ -1,8 +1,7 @@
/**
* UNCOMMENT: add support for custom elements
import { expect } from 'chai';
import cheerio from 'cheerio';
import { loadFixture } from './test-utils.js';
import path from 'path';
let fixture;
@ -49,17 +48,8 @@ describe('Custom Elements', () => {
expect($('my-element template[shadowroot=open]')).to.have.lengthOf(1);
// Hydration
// test 3: Component URL is included
expect(html).to.include('/src/components/my-element.js');
});
it('Polyfills are added before the hydration script', async () => {
const html = await fixture.readFile('/load/index.html');
const $ = cheerio.load(html);
// test 3: Component and polyfill scripts included
expect($('script[type=module]')).to.have.lengthOf(2);
expect($('script[type=module]').attr('src')).to.equal('/_snowpack/link/packages/astro/test/fixtures/custom-elements/my-component-lib/polyfill.js');
expect($($('script[type=module]').get(1)).html()).to.include('/_snowpack/link/packages/astro/test/fixtures/custom-elements/my-component-lib/hydration-polyfill.js');
});
it('Polyfills are added even if not hydrating', async () => {
@ -67,10 +57,6 @@ describe('Custom Elements', () => {
const $ = cheerio.load(html);
expect($('script[type=module]')).to.have.lengthOf(1);
expect($('script[type=module]').attr('src')).to.equal('/_snowpack/link/packages/astro/test/fixtures/custom-elements/my-component-lib/polyfill.js');
expect($($('script[type=module]').get(1)).html()).not.to.include(
'/_snowpack/link/packages/astro/test/fixtures/custom-elements/my-component-lib/hydration-polyfill.js'
);
});
it('Custom elements not claimed by renderer are rendered as regular HTML', async () => {
@ -88,7 +74,4 @@ describe('Custom Elements', () => {
// test 1: Element rendered
expect($('client-only-element')).to.have.lengthOf(1);
});
});
*/
it.skip('is skipped', () => {});
});

View file

@ -1,7 +1,6 @@
export default {
name: '@astrojs/test-custom-element-renderer',
server: './server',
server: './server.js',
polyfills: [
'./polyfill.js'
],

View file

@ -3,5 +3,11 @@
"version": "0.0.1",
"private": true,
"main": "index.js",
"type": "module"
"type": "module",
"exports": {
".": "./index.js",
"./server.js": "./server.js",
"./polyfill.js": "./polyfill.js",
"./hydration-polyfill.js": "./hydration-polyfill.js"
}
}

View file

@ -1 +1,2 @@
console.log('this is a polyfill');
console.log('this is a polyfill');
export default {};