Skip to content

Commit

Permalink
Add support for client:only hydrator (#935)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
2 people authored and FredKSchott committed Aug 18, 2021
1 parent de86695 commit c18ca58
Show file tree
Hide file tree
Showing 17 changed files with 274 additions and 7 deletions.
34 changes: 34 additions & 0 deletions .changeset/slow-planets-film.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 7 additions & 1 deletion docs/src/pages/core-concepts/component-hydration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
---
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/src/@types/astro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
25 changes: 23 additions & 2 deletions packages/astro/src/compiler/codegen/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -40,7 +40,7 @@ interface CodeGenOptions {
}

interface HydrationAttributes {
method?: 'load' | 'idle' | 'visible' | 'media';
method?: 'load' | 'idle' | 'visible' | 'media' | 'only';
value?: undefined | string;
}

Expand Down Expand Up @@ -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'} }`;
}
Expand Down Expand Up @@ -317,6 +322,7 @@ interface CodegenState {
declarations: Set<string>;
exportStatements: Set<string>;
importStatements: Set<string>;
componentImports: Map<string, string[]>;
customElementCandidates: Map<string, string>;
}

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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(),
};

Expand Down
1 change: 1 addition & 0 deletions packages/astro/src/config_manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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])},
Expand Down
14 changes: 14 additions & 0 deletions packages/astro/src/frontend/hydrate/only.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
50 changes: 47 additions & 3 deletions packages/astro/src/internal/__astro_component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,15 @@ const serialize = (value: Value) =>
});

export interface RendererInstance {
name: string | null;
source: string | null;
renderer: Renderer;
polyfills: string[];
hydrationPolyfills: string[];
}

const astroHtmlRendererInstance: RendererInstance = {
name: null,
source: '',
renderer: astroHtml as Renderer,
polyfills: [],
Expand All @@ -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)!;
}
Expand Down Expand Up @@ -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)) {
Expand All @@ -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('');
Expand Down
44 changes: 44 additions & 0 deletions packages/astro/test/astro-client-only.test.js
Original file line number Diff line number Diff line change
@@ -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();
24 changes: 24 additions & 0 deletions packages/astro/test/astro-dynamic.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export default {
buildOptions: {
sitemap: false,
},
renderers: [
'@astrojs/renderer-svelte',
],
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"workspaceRoot": "../../../../../"
}
Original file line number Diff line number Diff line change
@@ -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>

Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
window.addEventListener("resize", function() {
console.log("window resized");
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
import PersistentCounter from '../components/PersistentCounter.svelte';
---
<html>
<head><title>Client only pages</title></head>
<body>
<PersistentCounter client:only />
</body>
</html>
Loading

0 comments on commit c18ca58

Please sign in to comment.