Add support for client:only hydrator (#935)

* Adding support for client:only hydration

* Adding documentation for client:only

* Adding changeset

* Updating the test to use a browser-only API

* Adding a browser-specific import script, this reproduces the issue where client:only imports must be removed

* typo fix

* removing mispelled test component

* WIP: delaying inclusion of component imports until the hydration method is known

* WIP: tweaking the test to use window instead of document

* When only one renderer is included, use that for client:only hydration

* temporary test script snuck into the last commit

* WIP: adding check for a client:only renderer hint

* refactor: Remove client:only components instead of delaying all component import statements

* Updating the changeset and docs for the renderer hint

* refactor: pull client:only render matching out to it's own function

* Updating renderer hinting to match full name, with shorthand for internal renderers

Co-authored-by: Tony Sullivan <tony.f.sullivan@gmail.com>
This commit is contained in:
Tony Sullivan 2021-08-17 17:44:56 +00:00 committed by GitHub
parent 64bbd89f42
commit 1971ab3c60
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 274 additions and 7 deletions

View file

@ -0,0 +1,34 @@
---
'astro': minor
---
Adds support for client:only hydrator
The new `client:only` hydrator allows you to define a component that should be skipped during the build and only hydrated in the browser.
In most cases it is best to render placeholder content during the build, but that may not always be feasible if an NPM dependency attempts to use browser APIs as soon as is imported.
**Note** If more than one renderer is included in your Astro config, you need to include a hint to determine which renderer to use. Renderers will be matched to the name provided in your Astro config, similar to `<MyComponent client:only="@astrojs/renderer-react" />`. Shorthand can be used for `@astrojs` renderers, i.e. `<MyComponent client:only="react" />` will use `@astrojs/renderer-react`.
An example usage:
```jsx
---
import BarChart from '../components/BarChart.jsx';
---
<BarChart client:only />
/**
* If multiple renderers are included in the Astro config,
* this will ensure that the component is hydrated with
* the Preact renderer.
*/
<BarChart client:only="preact" />
/**
* If a custom renderer is required, use the same name
* provided in the Astro config.
*/
<BarChart client:only="my-custom-renderer" />
```
This allows you to import a chart component dependent on d3.js while making sure that the component isn't rendered at all at build time.

View file

@ -53,7 +53,7 @@ Besides the obvious performance benefits of sending less JavaScript down to the
## Hydrate Interactive Components
Astro renders every component on the server **at build time**. To hydrate components on the client **at runtime**, you may use any of the following `client:*` directives. A directive is a component attribute (always with a `:`) which tells Astro how your component should be rendered.
Astro renders every component on the server **at build time**, unless [client:only](#mycomponent-clientonly-) is used. To hydrate components on the client **at runtime**, you may use any of the following `client:*` directives. A directive is a component attribute (always with a `:`) which tells Astro how your component should be rendered.
```astro
---
@ -81,6 +81,12 @@ Hydrate the component as soon as the element enters the viewport (uses [Intersec
Hydrate the component as soon as the browser matches the given media query (uses [matchMedia][mdn-mm]). Useful for sidebar toggles, or other elements that should only display on mobile or desktop devices.
### `<MyComponent client:only />`
Hydrates the component at page load, similar to `client:load`. The component will be **skipped** at build time, useful for components that are entirely dependent on client-side APIs. This is best avoided unless absolutely needed, in most cases it is best to render placeholder content on the server and delay any browser API calls until the component hydrates in the browser.
If more than one renderer is included in the Astro [config](/reference/configuration-reference), `client:only` needs a hint to know which renderer to use for the component. For example, `client:only="react"` would make sure that the component is hydrated in the browser with the React renderer. For custom renderers not provided by `@astrojs`, use the full name of the renderer provided in your Astro config, i.e. `<client:only="my-custom-renderer" />`.
## Can I Hydrate Astro Components?
[Astro components](./astro-components) (`.astro` files) are HTML-only templating components with no client-side runtime. If you try to hydrate an Astro component with a `client:` modifier, you will get an error.

View file

@ -190,7 +190,7 @@ export type Components = Map<string, ComponentInfo>;
export interface AstroComponentMetadata {
displayName: string;
hydrate?: 'load' | 'idle' | 'visible' | 'media';
hydrate?: 'load' | 'idle' | 'visible' | 'media' | 'only';
componentUrl?: string;
componentExport?: { value: string; namespace?: boolean };
value?: undefined | string;

View file

@ -31,7 +31,7 @@ const traverse: typeof babelTraverse.default = (babelTraverse.default as any).de
const babelGenerator: typeof _babelGenerator = _babelGenerator.default;
const { transformSync } = esbuild;
const hydrationDirectives = new Set(['client:load', 'client:idle', 'client:visible', 'client:media']);
const hydrationDirectives = new Set(['client:load', 'client:idle', 'client:visible', 'client:media', 'client:only']);
interface CodeGenOptions {
compileOptions: CompileOptions;
@ -40,7 +40,7 @@ interface CodeGenOptions {
}
interface HydrationAttributes {
method?: 'load' | 'idle' | 'visible' | 'media';
method?: 'load' | 'idle' | 'visible' | 'media' | 'only';
value?: undefined | string;
}
@ -228,6 +228,11 @@ function getComponentWrapper(_name: string, hydration: HydrationAttributes, { ur
metadata = `{ hydrate: "${method}", displayName: "${name}", componentUrl: "${componentUrl}", componentExport: ${JSON.stringify(componentExport)}, value: ${
hydration.value || 'null'
} }`;
// for client:only components, only render a Fragment on the server
if (method === 'only') {
name = 'Fragment';
}
} else {
metadata = `{ hydrate: undefined, displayName: "${name}", value: ${hydration.value || 'null'} }`;
}
@ -317,6 +322,7 @@ interface CodegenState {
declarations: Set<string>;
exportStatements: Set<string>;
importStatements: Set<string>;
componentImports: Map<string, string[]>;
customElementCandidates: Map<string, string>;
}
@ -445,6 +451,15 @@ function compileModule(ast: Ast, module: Script, state: CodegenState, compileOpt
importSpecifier: specifier,
url: importUrl,
});
if (!state.componentImports.has(componentName)) {
state.componentImports.set(componentName, []);
}
// Track component imports to be used for server-rendered components
const { start, end } = componentImport;
state.componentImports.get(componentName)?.push(
module.content.slice(start || undefined, end || undefined)
);
}
const { start, end } = componentImport;
if (ast.meta.features & FEATURE_CUSTOM_ELEMENT && componentImport.specifiers.length === 0) {
@ -712,6 +727,11 @@ async function compileHtml(enterNode: TemplateNode, state: CodegenState, compile
importStatements.add(wrapperImport);
}
}
if (hydrationAttributes.method === 'only') {
// Remove component imports for client-only components
const componentImports = state.componentImports.get(componentName) || [];
componentImports.map((componentImport) => state.importStatements.delete(componentImport));
}
if (curr === 'markdown') {
await pushMarkdownToBuffer();
}
@ -873,6 +893,7 @@ export async function codegen(ast: Ast, { compileOptions, filename, fileID }: Co
declarations: new Set(),
importStatements: new Set(),
exportStatements: new Set(),
componentImports: new Map(),
customElementCandidates: new Map(),
};

View file

@ -149,6 +149,7 @@ import { setRenderers } from 'astro/dist/internal/__astro_component.js';
let rendererInstances = [${renderers
.map(
(r, i) => `{
name: "${r.name}",
source: ${rendererClientPackages[i] ? `"${rendererClientPackages[i]}"` : 'null'},
renderer: typeof __renderer_${i} === 'function' ? __renderer_${i}(${r.options ? JSON.stringify(r.options) : 'null'}) : __renderer_${i},
polyfills: ${JSON.stringify(rendererPolyfills[i])},

View file

@ -0,0 +1,14 @@
import type { GetHydrateCallback, HydrateOptions } from '../../@types/hydrate';
/**
* Hydrate this component immediately
*/
export default async function onLoad(astroId: string, _options: HydrateOptions, getHydrateCallback: GetHydrateCallback) {
const roots = document.querySelectorAll(`astro-root[uid="${astroId}"]`);
const innerHTML = roots[0].querySelector(`astro-fragment`)?.innerHTML ?? null;
const hydrate = await getHydrateCallback();
for (const root of roots) {
hydrate(root, innerHTML);
}
}

View file

@ -24,6 +24,7 @@ const serialize = (value: Value) =>
});
export interface RendererInstance {
name: string | null;
source: string | null;
renderer: Renderer;
polyfills: string[];
@ -31,6 +32,7 @@ export interface RendererInstance {
}
const astroHtmlRendererInstance: RendererInstance = {
name: null,
source: '',
renderer: astroHtml as Renderer,
polyfills: [],
@ -49,8 +51,44 @@ function isCustomElementTag(name: string | Function) {
const rendererCache = new Map<any, RendererInstance>();
/** For client:only components, attempt to infer the required renderer. */
function inferClientRenderer(metadata: Partial<AstroComponentMetadata>) {
// If there's only one renderer, assume it's the required renderer
if (rendererInstances.length === 1) {
return rendererInstances[0];
} else if (metadata.value) {
// Attempt to find the renderer by matching the hydration value
const hint = metadata.value;
let match = rendererInstances.find((instance) => instance.name === hint);
if (!match) {
// Didn't find an exact match, try shorthand hints for the internal renderers
const fullHintName = `@astrojs/renderer-${hint}`;
match = rendererInstances.find((instance) => instance.name === fullHintName);
}
if (!match) {
throw new Error(
`Couldn't find a renderer for <${metadata.displayName} client:only="${metadata.value}" />. Is there a renderer that matches the "${metadata.value}" hint in your Astro config?`
);
}
return match;
} else {
// Multiple renderers included but no hint was provided
throw new Error(
`Can't determine the renderer for ${metadata.displayName}. Include a hint similar to <${metadata.displayName} client:only="react" /> when multiple renderers are included in your Astro config.`
);
}
}
/** For a given component, resolve the renderer. Results are cached if this instance is encountered again */
async function resolveRenderer(Component: any, props: any = {}, children?: string): Promise<RendererInstance | undefined> {
async function resolveRenderer(Component: any, props: any = {}, children?: string, metadata: Partial<AstroComponentMetadata> = {}): Promise<RendererInstance | undefined> {
// For client:only components, the component can't be imported
// during SSR. We need to infer the required renderer.
if (metadata.hydrate === 'only') {
return inferClientRenderer(metadata);
}
if (rendererCache.has(Component)) {
return rendererCache.get(Component)!;
}
@ -172,7 +210,7 @@ export function __astro_component(Component: any, metadata: AstroComponentMetada
return Component.__render(props, prepareSlottedChildren(_children));
}
const children = removeSlottedChildren(_children);
let instance = await resolveRenderer(Component, props, children);
let instance = await resolveRenderer(Component, props, children, metadata);
if (!instance) {
if (isCustomElementTag(Component)) {
@ -188,7 +226,13 @@ export function __astro_component(Component: any, metadata: AstroComponentMetada
throw new Error(`No renderer found for ${name}! Did you forget to add a renderer to your Astro config?`);
}
}
let { html } = await instance.renderer.renderToStaticMarkup(Component, props, children, metadata);
let html = '';
// Skip SSR for components using client:only hydration
if (metadata.hydrate !== 'only') {
const rendered = await instance.renderer.renderToStaticMarkup(Component, props, children, metadata);
html = rendered.html;
}
if (instance.polyfills.length) {
let polyfillScripts = instance.polyfills.map((src) => `<script type="module" src="${src}"></script>`).join('');

View file

@ -0,0 +1,44 @@
import { suite } from 'uvu';
import * as assert from 'uvu/assert';
import { setup, setupBuild } from './helpers.js';
const ClientOnlyComponents = suite('Client only components tests');
setup(ClientOnlyComponents, './fixtures/astro-client-only');
setupBuild(ClientOnlyComponents, './fixtures/astro-client-only');
ClientOnlyComponents('Loads pages using client:only hydrator', async ({ runtime }) => {
let result = await runtime.load('/');
assert.ok(!result.error, `build error: ${result.error}`);
let html = result.contents;
const rootExp = /<astro-root\s[^>]*><\/astro-root>/;
assert.ok(rootExp.exec(html), 'astro-root is empty');
// 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];
}
}
assert.ok(svelteRenderer, 'Svelte renderer is on the page');
result = await runtime.load(svelteRenderer);
assert.equal(result.statusCode, 200, 'Can load svelte renderer');
});
ClientOnlyComponents('Can be built', async ({ build }) => {
try {
await build();
assert.ok(true, 'Can build a project with svelte dynamic components');
} catch (err) {
console.log(err);
assert.ok(false, 'build threw');
}
});
ClientOnlyComponents.run();

View file

@ -35,6 +35,30 @@ DynamicComponents('Loads pages using client:media hydrator', async ({ runtime })
assert.ok(html.includes(`value: "(max-width: 600px)"`), 'dynamic value rendered');
});
DynamicComponents('Loads pages using client:only hydrator', async ({ runtime }) => {
let result = await runtime.load('/client-only');
assert.ok(!result.error, `build error: ${result.error}`);
let html = result.contents;
const rootExp = /<astro-root\s[^>]*><\/astro-root>/;
assert.ok(rootExp.exec(html), 'astro-root is empty');
// 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];
}
}
assert.ok(svelteRenderer, 'Svelte renderer is on the page');
result = await runtime.load(svelteRenderer);
assert.equal(result.statusCode, 200, 'Can load svelte renderer');
});
DynamicComponents('Can be built', async ({ build }) => {
try {
await build();

View file

@ -0,0 +1,8 @@
export default {
buildOptions: {
sitemap: false,
},
renderers: [
'@astrojs/renderer-svelte',
],
};

View file

@ -0,0 +1,3 @@
{
"workspaceRoot": "../../../../../"
}

View file

@ -0,0 +1,22 @@
<script>
import './logResize';
let count = parseInt(localStorage.getItem('test:count')) || 0;
$: localStorage.setItem('test:count', count);
function add() {
count += 1;
}
function subtract() {
count -= 1;
}
</script>
<div class="counter">
<button on:click={subtract}>-</button>
<pre>{ count }</pre>
<button on:click={add}>+</button>
</div>

View file

@ -0,0 +1,3 @@
window.addEventListener("resize", function() {
console.log("window resized");
});

View file

@ -0,0 +1,9 @@
---
import PersistentCounter from '../components/PersistentCounter.svelte';
---
<html>
<head><title>Client only pages</title></head>
<body>
<PersistentCounter client:only />
</body>
</html>

View file

@ -0,0 +1,22 @@
<script>
import './logResize';
let count = parseInt(localStorage.getItem('test:count')) || 0;
$: localStorage.setItem('test:count', count);
function add() {
count += 1;
}
function subtract() {
count -= 1;
}
</script>
<div class="counter">
<button on:click={subtract}>-</button>
<pre>{ count }</pre>
<button on:click={add}>+</button>
</div>

View file

@ -0,0 +1,3 @@
window.addEventListener("resize", function() {
console.log("window resized");
});

View file

@ -0,0 +1,9 @@
---
import PersistentCounter from '../components/PersistentCounter.svelte';
---
<html>
<head><title>Dynamic pages</title></head>
<body>
<PersistentCounter client:only="svelte" />
</body>
</html>