Implement client:only handling (#1716)

* WIP: improve `client:only` handling

* feat: implement `client:only` in renderer

* test: reenable client:only tests

* feat: improve SSR error messages

* fix: add `resolvePath` method to Metadata

* test: fix client-only test

* chore: fix custom-elements handling

* test: revert `custom-elements` test change

* fix: do not assign a default renderer even if there's only one configured

* chore: bump compiler

* chore: add changeset
This commit is contained in:
Nate Moore 2021-11-16 10:01:14 -06:00 committed by GitHub
parent b133d8819d
commit 824c1f2024
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 131 additions and 47 deletions

View file

@ -0,0 +1,5 @@
---
'astro': patch
---
Re-implement client:only support

View file

@ -53,7 +53,7 @@
"test": "mocha --parallel --timeout 15000" "test": "mocha --parallel --timeout 15000"
}, },
"dependencies": { "dependencies": {
"@astrojs/compiler": "^0.2.27", "@astrojs/compiler": "^0.3.1",
"@astrojs/language-server": "^0.7.16", "@astrojs/language-server": "^0.7.16",
"@astrojs/markdown-remark": "^0.4.0-next.1", "@astrojs/markdown-remark": "^0.4.0-next.1",
"@astrojs/markdown-support": "0.3.1", "@astrojs/markdown-support": "0.3.1",

View file

@ -93,10 +93,31 @@ export async function renderSlot(_result: any, slotted: string, fallback?: any)
export const Fragment = Symbol('Astro.Fragment'); export const Fragment = Symbol('Astro.Fragment');
function guessRenderers(componentUrl?: string): string[] {
const extname = componentUrl?.split('.').pop();
switch (extname) {
case 'svelte':
return ['@astrojs/renderer-svelte'];
case 'vue':
return ['@astrojs/renderer-vue'];
case 'jsx':
case 'tsx':
return ['@astrojs/renderer-react', '@astrojs/renderer-preact'];
default:
return ['@astrojs/renderer-react', '@astrojs/renderer-preact', '@astrojs/renderer-vue', '@astrojs/renderer-svelte'];
}
}
function formatList(values: string[]): string {
if (values.length === 1) {
return values[0];
}
return `${values.slice(0, -1).join(', ')} or ${values[values.length - 1]}`;
}
export async function renderComponent(result: SSRResult, 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;
if (Component === Fragment) { if (Component === Fragment) {
return children; return children;
@ -107,12 +128,13 @@ export async function renderComponent(result: SSRResult, displayName: string, Co
return output; return output;
} }
let metadata: AstroComponentMetadata = { displayName }; if (Component === null && !_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?`);
if (Component == null) {
throw new Error(`Unable to render ${metadata.displayName} because it is ${Component}!\nDid you forget to import the component or is it possible there is a typo?`);
} }
const { renderers } = result._metadata;
const metadata: AstroComponentMetadata = { displayName };
const { hydration, props } = extractDirectives(_props); const { hydration, props } = extractDirectives(_props);
let html = ''; let html = '';
@ -122,28 +144,84 @@ export async function renderComponent(result: SSRResult, displayName: string, Co
metadata.componentExport = hydration.componentExport; metadata.componentExport = hydration.componentExport;
metadata.componentUrl = hydration.componentUrl; metadata.componentUrl = hydration.componentUrl;
} }
const probableRendererNames = guessRenderers(metadata.componentUrl);
if (Array.isArray(renderers) && renderers.length === 0) {
const message = `Unable to render ${metadata.displayName}!
There are no \`renderers\` set in your \`astro.config.mjs\` file.
Did you mean to enable ${formatList(probableRendererNames.map((r) => '`' + r + '`'))}?`;
throw new Error(message);
}
// Call the renderers `check` hook to see if any claim this component. // Call the renderers `check` hook to see if any claim this component.
let renderer: Renderer | undefined; let renderer: Renderer | undefined;
if (metadata.hydrate !== 'only') {
for (const r of renderers) { for (const r of renderers) {
if (await r.ssr.check(Component, props, children)) { if (await r.ssr.check(Component, props, children)) {
renderer = r; renderer = r;
break; break;
} }
} }
} else {
// Attempt: use explicitly passed renderer name
if (metadata.hydrateArgs) {
const rendererName = metadata.hydrateArgs;
renderer = renderers.filter(({ name }) => name === `@astrojs/renderer-${rendererName}` || name === rendererName)[0];
}
// Attempt: can we guess the renderer from the export extension?
if (!renderer) {
const extname = metadata.componentUrl?.split('.').pop();
renderer = renderers.filter(({ name }) => name === `@astrojs/renderer-${extname}` || name === extname)[0];
}
}
// If no one claimed the renderer // If no one claimed the renderer
if (!renderer) { if (!renderer) {
// This is a custom element without a renderer. Because of that, render it if (metadata.hydrate === 'only') {
// as a string and the user is responsible for adding a script tag for the component definition. // TODO: improve error message
if (typeof Component === 'string') { throw new Error(`Unable to render ${metadata.displayName}!
html = await renderAstroComponent(await render`<${Component}${spreadAttributes(props)}>${children}</${Component}>`);
Using the \`client:only\` hydration strategy, Astro needs a hint to use the correct renderer.
Did you mean to pass <${metadata.displayName} client:only="${probableRendererNames.map((r) => r.replace('@astrojs/renderer-', '')).join('|')}" />
`);
} else if (typeof Component !== 'string') {
const matchingRenderers = renderers.filter((r) => probableRendererNames.includes(r.name));
const plural = renderers.length > 1;
if (matchingRenderers.length === 0) {
throw new Error(`Unable to render ${metadata.displayName}!
There ${plural ? 'are' : 'is'} ${renderers.length} renderer${plural ? 's' : ''} configured in your \`astro.config.mjs\` file,
but ${plural ? 'none were' : 'it was not'} able to server-side render ${metadata.displayName}.
Did you mean to enable ${formatList(probableRendererNames.map((r) => '`' + r + '`'))}?`);
} else { } else {
throw new Error(`Astro is unable to render ${metadata.displayName}!\nIs there a renderer to handle this type of component defined in your Astro config?`); throw new Error(`Unable to render ${metadata.displayName}!
This component likely uses ${formatList(probableRendererNames)},
but Astro encountered an error during server-side rendering.
Please ensure that ${metadata.displayName}:
1. Does not unconditionally access browser-specific globals like \`window\` or \`document\`.
If this is unavoidable, use the \`client:only\` hydration directive.
2. Does not conditionally return \`null\` or \`undefined\` when rendered on the server.
If you're still stuck, please open an issue on GitHub or join us at https://astro.build/chat.`);
}
} }
} else {
if (metadata.hydrate === 'only') {
html = await renderSlot(result, slots?.fallback);
} else { } else {
({ html } = await renderer.ssr.renderToStaticMarkup(Component, props, children)); ({ html } = await renderer.ssr.renderToStaticMarkup(Component, props, children));
} }
}
// This is a custom element without a renderer. Because of that, render it
// as a string and the user is responsible for adding a script tag for the component definition.
if (!html && typeof Component === 'string') {
html = await renderAstroComponent(await render`<${Component}${spreadAttributes(props)}>${children}</${Component}>`);
}
// This is used to add polyfill scripts to the page, if the renderer needs them. // This is used to add polyfill scripts to the page, if the renderer needs them.
if (renderer?.polyfills?.length) { if (renderer?.polyfills?.length) {
@ -162,7 +240,7 @@ export async function renderComponent(result: SSRResult, displayName: string, Co
// 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, astroId, props }, metadata as Required<AstroComponentMetadata>));
return `<astro-root uid="${astroId}">${html}</astro-root>`; return `<astro-root uid="${astroId}">${html ?? ''}</astro-root>`;
} }
/** Create the Astro.fetchContent() runtime function. */ /** Create the Astro.fetchContent() runtime function. */

View file

@ -16,6 +16,10 @@ export class Metadata {
this.metadataCache = new Map<any, ComponentMetadata | null>(); this.metadataCache = new Map<any, ComponentMetadata | null>();
} }
resolvePath(specifier: string): string {
return specifier.startsWith('.') ? new URL(specifier, this.fileURL).pathname : specifier;
}
getPath(Component: any): string | null { getPath(Component: any): string | null {
const metadata = this.getComponentMetadata(Component); const metadata = this.getComponentMetadata(Component);
return metadata?.componentUrl || null; return metadata?.componentUrl || null;
@ -58,7 +62,7 @@ export class Metadata {
private findComponentMetadata(Component: any): ComponentMetadata | null { private findComponentMetadata(Component: any): ComponentMetadata | null {
const isCustomElement = typeof Component === 'string'; const isCustomElement = typeof Component === 'string';
for (const { module, specifier } of this.modules) { for (const { module, specifier } of this.modules) {
const id = specifier.startsWith('.') ? new URL(specifier, this.fileURL).pathname : specifier; const id = this.resolvePath(specifier);
for (const [key, value] of Object.entries(module)) { for (const [key, value] of Object.entries(module)) {
if (isCustomElement) { if (isCustomElement) {
if (key === 'tagName' && Component === value) { if (key === 'tagName' && Component === value) {

View file

@ -1,5 +1,3 @@
/**
* UNCOMMENT: fix "Error: Unable to render PersistentCounter because it is null!"
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';
@ -18,22 +16,19 @@ describe('Client only components', () => {
// test 1: <astro-root> is empty // test 1: <astro-root> is empty
expect($('astro-root').html()).to.equal(''); expect($('astro-root').html()).to.equal('');
const src = $('script').attr('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\("(.+?)"\)/g; const exp = /import\("(.\/client.*)"\)/g;
let match, svelteRenderer; let match, svelteRenderer;
while ((match = exp.exec(result.contents))) { while ((match = exp.exec(script))) {
if (match[1].includes('renderers/renderer-svelte/client.js')) { svelteRenderer = match[1].replace(/^\./, '/assets/');
svelteRenderer = match[1];
}
} }
expect(svelteRenderer).to.be.ok; expect(svelteRenderer).to.be.ok;
// test 3: can load svelte renderer // test 3: can load svelte renderer
// result = await fixture.fetch(svelteRenderer); const svelteClient = await fixture.readFile(svelteRenderer);
// expect(result.status).to.equal(200); expect(svelteClient).to.be.ok;
}); });
}); });
*/
it.skip('is skipped', () => {});

View file

@ -30,24 +30,26 @@ describe('Dynamic components', () => {
expect(js).to.include(`value:"(max-width: 600px)"`); expect(js).to.include(`value:"(max-width: 600px)"`);
}); });
it.skip('Loads pages using client:only hydrator', async () => { it('Loads pages using client:only hydrator', async () => {
const html = await fixture.readFile('/client-only/index.html'); const html = await fixture.readFile('/client-only/index.html');
const $ = cheerio.load(html); const $ = cheerio.load(html);
// test 1: <astro-root> is empty // test 1: <astro-root> is empty
expect($('<astro-root>').html()).to.equal(''); expect($('<astro-root>').html()).to.equal('');
const script = $('script').text();
console.log(script);
// Grab the svelte import // Grab the svelte import
const exp = /import\("(.+?)"\)/g; // const exp = /import\("(.+?)"\)/g;
let match, svelteRenderer; // let match, svelteRenderer;
while ((match = exp.exec(result.contents))) { // while ((match = exp.exec(result.contents))) {
if (match[1].includes('renderers/renderer-svelte/client.js')) { // if (match[1].includes('renderers/renderer-svelte/client.js')) {
svelteRenderer = match[1]; // svelteRenderer = match[1];
} // }
} // }
// test 2: Svelte renderer is on the page // test 2: Svelte renderer is on the page
expect(svelteRenderer).to.be.ok; // expect(svelteRenderer).to.be.ok;
// test 3: Can load svelte renderer // test 3: Can load svelte renderer
// const result = await fixture.fetch(svelteRenderer); // const result = await fixture.fetch(svelteRenderer);

View file

@ -1,7 +1,7 @@
import './shim.js'; import './shim.js';
function getConstructor(Component) { function getConstructor(Component) {
if(typeof Component === 'string') { if (typeof Component === 'string') {
const tagName = Component; const tagName = Component;
Component = customElements.get(tagName); Component = customElements.get(tagName);
} }
@ -10,13 +10,13 @@ function getConstructor(Component) {
function check(component) { function check(component) {
const Component = getConstructor(component); const Component = getConstructor(component);
if(typeof Component === 'function' && globalThis.HTMLElement.isPrototypeOf(Component)) { if (typeof Component === 'function' && globalThis.HTMLElement.isPrototypeOf(Component)) {
return true; return true;
} }
return false; return false;
} }
function renderToStaticMarkup(component) { function renderToStaticMarkup(component, props, innerHTML) {
const Component = getConstructor(component); const Component = getConstructor(component);
const el = new Component(); const el = new Component();
el.connectedCallback(); el.connectedCallback();

View file

@ -106,10 +106,10 @@
"@algolia/logger-common" "4.10.5" "@algolia/logger-common" "4.10.5"
"@algolia/requester-common" "4.10.5" "@algolia/requester-common" "4.10.5"
"@astrojs/compiler@^0.2.27": "@astrojs/compiler@^0.3.1":
version "0.2.27" version "0.3.1"
resolved "https://registry.yarnpkg.com/@astrojs/compiler/-/compiler-0.2.27.tgz#ab78494a9a364abdbb80f236f939f01057eec868" resolved "https://registry.yarnpkg.com/@astrojs/compiler/-/compiler-0.3.1.tgz#5cff0bf9f0769a6f91443a663733727b8a6e3598"
integrity sha512-F5j2wzus8+BR8XmD5+KM0dP3H5ZFs62mqsMploCc7//v6DXICoaCi1rftnP84ewELLOpWX2Rxg1I3P3iIVo90A== integrity sha512-4jShqZVcWF3pWcfjWU05PVc2rF9JP9E89fllEV8Zi/UpPicemn9zxl3r4O6ahGfBjBRTQp42CFLCETktGPRPyg==
dependencies: dependencies:
typescript "^4.3.5" typescript "^4.3.5"