[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 { AstroComponentMetadata } from '../@types/astro';
import type { SSRResult } from '../@types/ssr';
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';
@ -141,7 +142,7 @@ export async function renderSlot(result: any, slotted: string, fallback?: any) {
return fallback; 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; Component = await Component;
const children = await renderSlot(result, slots?.default); const children = await renderSlot(result, slots?.default);
const { renderers } = result._metadata; const { renderers } = result._metadata;
@ -167,9 +168,16 @@ export async function renderComponent(result: any, displayName: string, Componen
metadata.hydrateArgs = hydrationDirective[1]; metadata.hydrateArgs = hydrationDirective[1];
} }
const isCustomElement = typeof Component === 'string';
for (const [url, exported] of Object.entries(result._metadata.importedModules)) { for (const [url, exported] of Object.entries(result._metadata.importedModules)) {
for (const [key, value] of Object.entries(exported as any)) { 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.componentExport = { value: key };
metadata.componentUrl = url; metadata.componentUrl = url;
break; break;
@ -194,6 +202,11 @@ export async function renderComponent(result: any, displayName: string, Componen
({ html } = await renderer.ssr.renderToStaticMarkup(Component, props, children)); ({ 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) { if (!hydrationDirective) {
return html.replace(/\<\/?astro-fragment\>/g, ''); return html.replace(/\<\/?astro-fragment\>/g, '');
} }

View file

@ -1,6 +1,8 @@
import type { BuildResult } from 'esbuild'; import type { BuildResult } from 'esbuild';
import type { ViteDevServer } from 'vite'; 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 type { LogOptions } from '../logger';
import cheerio from 'cheerio'; 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) // 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; let browserHash: string | undefined;
const cache = new Map(); const cache = new Map<string, Promise<Renderer>>();
// TODO: improve validation and error handling here. // 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( const renderers = await Promise.all(
ids.map(async (renderer) => { ids.map(renderer => {
if (cache.has(renderer)) return cache.get(renderer); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (cache.has(renderer)) return cache.get(renderer)!;
const resolvedRenderer: any = {}; let promise = resolveRenderer(viteServer, renderer);
// We can dynamically import the renderer by itself because it shouldn't have cache.set(renderer, promise);
// any non-standard imports, the index is just meta info. return promise;
// 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;
}) })
); );
@ -118,20 +126,26 @@ async function resolveImportedModules(viteServer: ViteDevServer, file: URL) {
/** use Vite to SSR */ /** use Vite to SSR */
export async function ssr({ astroConfig, filePath, logging, mode, origin, pathname, route, routeCache, viteServer }: SSROptions): Promise<string> { export async function ssr({ astroConfig, filePath, logging, mode, origin, pathname, route, routeCache, viteServer }: SSROptions): Promise<string> {
try { 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; 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 // 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 // 2. handle dynamic routes
let params: Params = {}; let params: Params = {};
let pageProps: Props = {}; let pageProps: Props = {};
if (route && !route.pathname) { if (route && !route.pathname) {
if (route.params.length) { if (route.params.length) {
const paramsMatch = route.pattern.exec(pathname)!; const paramsMatch = route.pattern.exec(pathname);
params = getParams(route.params)(paramsMatch); if(paramsMatch) {
params = getParams(route.params)(paramsMatch);
}
} }
validateGetStaticPathsModule(mod); validateGetStaticPathsModule(mod);
routeCache[route.component] = 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})`); if (!Component.isAstroComponentFactory) throw new Error(`Unable to SSR non-Astro component (${route?.component})`);
const result = { const result: SSRResult = {
styles: new Set(), styles: new Set(),
scripts: new Set(), scripts: new Set(),
/** This function returns the `Astro` faux-global */ /** 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 site = new URL(origin);
const url = new URL('.' + pathname, site); const url = new URL('.' + pathname, site);
const canonicalURL = getCanonicalURL(pathname, astroConfig.buildOptions.site || origin); const canonicalURL = getCanonicalURL(pathname, astroConfig.buildOptions.site || origin);
@ -175,30 +189,46 @@ export async function ssr({ astroConfig, filePath, logging, mode, origin, pathna
return { return {
isPage: true, isPage: true,
site, site,
request: { url, canonicalURL }, request: {
canonicalURL,
params: {},
url
},
props, props,
fetchContent, fetchContent,
slots: Object.fromEntries( slots: Object.fromEntries(
Object.entries(slots || {}).map(([slotName]) => [slotName, true]) 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 }, _metadata: { importedModules, renderers },
}; };
const createFetchContent = (currentFilePath: string) => { function createFetchContent(currentFilePath: string) {
const fetchContentCache = new Map<string, any>(); 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 cwd = path.dirname(currentFilePath);
const cacheKey = `${cwd}:${pattern}`; const cacheKey = `${cwd}:${pattern}`;
if (fetchContentCache.has(cacheKey)) { if (fetchContentCache.has(cacheKey)) {
return fetchContentCache.get(cacheKey); return fetchContentCache.get(cacheKey);
} }
const files = await glob(pattern, { cwd, absolute: true }); const files = await glob(pattern, { cwd, absolute: true });
const contents = await Promise.all( const contents: FetchContentResult<T>[] = await Promise.all(
files.map(async (file) => { files.map(async (file) => {
const { metadata: astro = {}, frontmatter = {} } = (await viteServer.ssrLoadModule(file)) as any; const loadedModule = await viteServer.ssrLoadModule(file);
return { ...frontmatter, astro }; 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); fetchContentCache.set(cacheKey, contents);

View file

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

View file

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

View file

@ -3,5 +3,11 @@
"version": "0.0.1", "version": "0.0.1",
"private": true, "private": true,
"main": "index.js", "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 {};