summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGravatar Nate Moore <natemoo-re@users.noreply.github.com> 2021-11-16 10:01:14 -0600
committerGravatar GitHub <noreply@github.com> 2021-11-16 10:01:14 -0600
commit824c1f20249d28154139ff14fd654c34ac68d2a8 (patch)
treee153912c650dc5bf6281814ac542e64b3501af67
parentb133d8819d2b8f6f81c3bfd360a9ac748325cb2a (diff)
downloadastro-824c1f20249d28154139ff14fd654c34ac68d2a8.tar.gz
astro-824c1f20249d28154139ff14fd654c34ac68d2a8.tar.zst
astro-824c1f20249d28154139ff14fd654c34ac68d2a8.zip
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
-rw-r--r--.changeset/polite-ladybugs-train.md5
-rw-r--r--packages/astro/package.json2
-rw-r--r--packages/astro/src/runtime/server/index.ts112
-rw-r--r--packages/astro/src/runtime/server/metadata.ts6
-rw-r--r--packages/astro/test/astro-client-only.test.js19
-rw-r--r--packages/astro/test/astro-dynamic.test.js20
-rw-r--r--packages/astro/test/fixtures/astro-dynamic/src/pages/client-only.astro (renamed from packages/astro/test/fixtures/astro-dynamic/src/skipped-pages/client-only.astro)0
-rw-r--r--packages/astro/test/fixtures/custom-elements/my-component-lib/server.js6
-rw-r--r--yarn.lock8
9 files changed, 131 insertions, 47 deletions
diff --git a/.changeset/polite-ladybugs-train.md b/.changeset/polite-ladybugs-train.md
new file mode 100644
index 000000000..ca194e5df
--- /dev/null
+++ b/.changeset/polite-ladybugs-train.md
@@ -0,0 +1,5 @@
+---
+'astro': patch
+---
+
+Re-implement client:only support
diff --git a/packages/astro/package.json b/packages/astro/package.json
index 134011378..5e238c565 100644
--- a/packages/astro/package.json
+++ b/packages/astro/package.json
@@ -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",
diff --git a/packages/astro/src/runtime/server/index.ts b/packages/astro/src/runtime/server/index.ts
index 3985e7b51..1aa87d318 100644
--- a/packages/astro/src/runtime/server/index.ts
+++ b/packages/astro/src/runtime/server/index.ts
@@ -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. */
diff --git a/packages/astro/src/runtime/server/metadata.ts b/packages/astro/src/runtime/server/metadata.ts
index 96a99ab22..e30a740a7 100644
--- a/packages/astro/src/runtime/server/metadata.ts
+++ b/packages/astro/src/runtime/server/metadata.ts
@@ -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) {
diff --git a/packages/astro/test/astro-client-only.test.js b/packages/astro/test/astro-client-only.test.js
index 28a34ba8a..893db1c59 100644
--- a/packages/astro/test/astro-client-only.test.js
+++ b/packages/astro/test/astro-client-only.test.js
@@ -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', () => {});
diff --git a/packages/astro/test/astro-dynamic.test.js b/packages/astro/test/astro-dynamic.test.js
index 0e68db2db..a61baeda2 100644
--- a/packages/astro/test/astro-dynamic.test.js
+++ b/packages/astro/test/astro-dynamic.test.js
@@ -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);
diff --git a/packages/astro/test/fixtures/astro-dynamic/src/skipped-pages/client-only.astro b/packages/astro/test/fixtures/astro-dynamic/src/pages/client-only.astro
index c9b1ca389..c9b1ca389 100644
--- a/packages/astro/test/fixtures/astro-dynamic/src/skipped-pages/client-only.astro
+++ b/packages/astro/test/fixtures/astro-dynamic/src/pages/client-only.astro
diff --git a/packages/astro/test/fixtures/custom-elements/my-component-lib/server.js b/packages/astro/test/fixtures/custom-elements/my-component-lib/server.js
index 9970c2fbf..32466e4ed 100644
--- a/packages/astro/test/fixtures/custom-elements/my-component-lib/server.js
+++ b/packages/astro/test/fixtures/custom-elements/my-component-lib/server.js
@@ -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();
diff --git a/yarn.lock b/yarn.lock
index 943f1176a..9f4445c35 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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"