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:
parent
64bbd89f42
commit
1971ab3c60
17 changed files with 274 additions and 7 deletions
34
.changeset/slow-planets-film.md
Normal file
34
.changeset/slow-planets-film.md
Normal 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.
|
|
@ -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.
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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(),
|
||||
};
|
||||
|
||||
|
|
|
@ -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])},
|
||||
|
|
14
packages/astro/src/frontend/hydrate/only.ts
Normal file
14
packages/astro/src/frontend/hydrate/only.ts
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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('');
|
||||
|
|
44
packages/astro/test/astro-client-only.test.js
Normal file
44
packages/astro/test/astro-client-only.test.js
Normal 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();
|
|
@ -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();
|
||||
|
|
8
packages/astro/test/fixtures/astro-client-only/astro.config.mjs
vendored
Normal file
8
packages/astro/test/fixtures/astro-client-only/astro.config.mjs
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
export default {
|
||||
buildOptions: {
|
||||
sitemap: false,
|
||||
},
|
||||
renderers: [
|
||||
'@astrojs/renderer-svelte',
|
||||
],
|
||||
};
|
3
packages/astro/test/fixtures/astro-client-only/snowpack.config.json
vendored
Normal file
3
packages/astro/test/fixtures/astro-client-only/snowpack.config.json
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"workspaceRoot": "../../../../../"
|
||||
}
|
22
packages/astro/test/fixtures/astro-client-only/src/components/PersistentCounter.svelte
vendored
Normal file
22
packages/astro/test/fixtures/astro-client-only/src/components/PersistentCounter.svelte
vendored
Normal 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>
|
||||
|
3
packages/astro/test/fixtures/astro-client-only/src/components/logResize.js
vendored
Normal file
3
packages/astro/test/fixtures/astro-client-only/src/components/logResize.js
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
window.addEventListener("resize", function() {
|
||||
console.log("window resized");
|
||||
});
|
9
packages/astro/test/fixtures/astro-client-only/src/pages/index.astro
vendored
Normal file
9
packages/astro/test/fixtures/astro-client-only/src/pages/index.astro
vendored
Normal 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>
|
22
packages/astro/test/fixtures/astro-dynamic/src/components/PersistentCounter.svelte
vendored
Normal file
22
packages/astro/test/fixtures/astro-dynamic/src/components/PersistentCounter.svelte
vendored
Normal 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>
|
||||
|
3
packages/astro/test/fixtures/astro-dynamic/src/components/logResize.js
vendored
Normal file
3
packages/astro/test/fixtures/astro-dynamic/src/components/logResize.js
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
window.addEventListener("resize", function() {
|
||||
console.log("window resized");
|
||||
});
|
9
packages/astro/test/fixtures/astro-dynamic/src/pages/client-only.astro
vendored
Normal file
9
packages/astro/test/fixtures/astro-dynamic/src/pages/client-only.astro
vendored
Normal 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>
|
Loading…
Reference in a new issue