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"
},
"dependencies": {
"@astrojs/compiler": "^0.2.27",
"@astrojs/compiler": "^0.3.1",
"@astrojs/language-server": "^0.7.16",
"@astrojs/markdown-remark": "^0.4.0-next.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');
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 = {}) {
Component = await Component;
const children = await renderSlot(result, slots?.default);
const { renderers } = result._metadata;
if (Component === Fragment) {
return children;
@ -107,12 +128,13 @@ export async function renderComponent(result: SSRResult, displayName: string, Co
return output;
}
let metadata: AstroComponentMetadata = { displayName };
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?`);
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?`);
}
const { renderers } = result._metadata;
const metadata: AstroComponentMetadata = { displayName };
const { hydration, props } = extractDirectives(_props);
let html = '';
@ -122,27 +144,83 @@ export async function renderComponent(result: SSRResult, displayName: string, Co
metadata.componentExport = hydration.componentExport;
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.
let renderer: Renderer | undefined;
for (const r of renderers) {
if (await r.ssr.check(Component, props, children)) {
renderer = r;
break;
if (metadata.hydrate !== 'only') {
for (const r of renderers) {
if (await r.ssr.check(Component, props, children)) {
renderer = r;
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 (!renderer) {
// 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 (typeof Component === 'string') {
html = await renderAstroComponent(await render`<${Component}${spreadAttributes(props)}>${children}</${Component}>`);
} 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?`);
if (metadata.hydrate === 'only') {
// TODO: improve error message
throw new Error(`Unable to render ${metadata.displayName}!
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 {
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 {
({ html } = await renderer.ssr.renderToStaticMarkup(Component, props, children));
if (metadata.hydrate === 'only') {
html = await renderSlot(result, slots?.fallback);
} else {
({ 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.
@ -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.
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. */

View file

@ -16,6 +16,10 @@ export class Metadata {
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 {
const metadata = this.getComponentMetadata(Component);
return metadata?.componentUrl || null;
@ -58,7 +62,7 @@ export class Metadata {
private findComponentMetadata(Component: any): ComponentMetadata | null {
const isCustomElement = typeof Component === 'string';
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)) {
if (isCustomElement) {
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 cheerio from 'cheerio';
import { loadFixture } from './test-utils.js';
@ -18,22 +16,19 @@ describe('Client only components', () => {
// test 1: <astro-root> is empty
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
const exp = /import\("(.+?)"\)/g;
const exp = /import\("(.\/client.*)"\)/g;
let match, svelteRenderer;
while ((match = exp.exec(result.contents))) {
if (match[1].includes('renderers/renderer-svelte/client.js')) {
svelteRenderer = match[1];
}
while ((match = exp.exec(script))) {
svelteRenderer = match[1].replace(/^\./, '/assets/');
}
expect(svelteRenderer).to.be.ok;
// test 3: can load svelte renderer
// result = await fixture.fetch(svelteRenderer);
// expect(result.status).to.equal(200);
const svelteClient = await fixture.readFile(svelteRenderer);
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)"`);
});
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 $ = cheerio.load(html);
// test 1: <astro-root> is empty
expect($('<astro-root>').html()).to.equal('');
const script = $('script').text();
console.log(script);
// Grab the svelte import
const exp = /import\("(.+?)"\)/g;
let match, svelteRenderer;
while ((match = exp.exec(result.contents))) {
if (match[1].includes('renderers/renderer-svelte/client.js')) {
svelteRenderer = match[1];
}
}
// const exp = /import\("(.+?)"\)/g;
// let match, svelteRenderer;
// while ((match = exp.exec(result.contents))) {
// if (match[1].includes('renderers/renderer-svelte/client.js')) {
// svelteRenderer = match[1];
// }
// }
// test 2: Svelte renderer is on the page
expect(svelteRenderer).to.be.ok;
// expect(svelteRenderer).to.be.ok;
// test 3: Can load svelte renderer
// const result = await fixture.fetch(svelteRenderer);

View file

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

View file

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