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:
parent
b133d8819d
commit
824c1f2024
9 changed files with 131 additions and 47 deletions
5
.changeset/polite-ladybugs-train.md
Normal file
5
.changeset/polite-ladybugs-train.md
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
'astro': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Re-implement client:only support
|
|
@ -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",
|
||||||
|
|
|
@ -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,27 +144,83 @@ 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;
|
||||||
for (const r of renderers) {
|
if (metadata.hydrate !== 'only') {
|
||||||
if (await r.ssr.check(Component, props, children)) {
|
for (const r of renderers) {
|
||||||
renderer = r;
|
if (await r.ssr.check(Component, props, children)) {
|
||||||
break;
|
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 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}>`);
|
|
||||||
} else {
|
Using the \`client:only\` hydration strategy, Astro needs a hint to use the correct renderer.
|
||||||
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?`);
|
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 {
|
} 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.
|
// 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.
|
// 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. */
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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', () => {});
|
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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"
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue