diff --git a/.changeset/slow-planets-film.md b/.changeset/slow-planets-film.md
new file mode 100644
index 000000000..2fd9e0a39
--- /dev/null
+++ b/.changeset/slow-planets-film.md
@@ -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 ``. Shorthand can be used for `@astrojs` renderers, i.e. `` will use `@astrojs/renderer-react`.
+
+An example usage:
+
+```jsx
+---
+import BarChart from '../components/BarChart.jsx';
+---
+
+
+/**
+ * If multiple renderers are included in the Astro config,
+ * this will ensure that the component is hydrated with
+ * the Preact renderer.
+ */
+
+/**
+ * If a custom renderer is required, use the same name
+ * provided in the Astro config.
+ */
+
+```
+
+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.
\ No newline at end of file
diff --git a/docs/src/pages/core-concepts/component-hydration.md b/docs/src/pages/core-concepts/component-hydration.md
index ed2ab453d..bb0fbce29 100644
--- a/docs/src/pages/core-concepts/component-hydration.md
+++ b/docs/src/pages/core-concepts/component-hydration.md
@@ -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.
+### ``
+
+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. ``.
+
## 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.
diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts
index c13256136..f14f73e42 100644
--- a/packages/astro/src/@types/astro.ts
+++ b/packages/astro/src/@types/astro.ts
@@ -190,7 +190,7 @@ export type Components = Map;
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;
diff --git a/packages/astro/src/compiler/codegen/index.ts b/packages/astro/src/compiler/codegen/index.ts
index bcdd4ef9d..26946e9da 100644
--- a/packages/astro/src/compiler/codegen/index.ts
+++ b/packages/astro/src/compiler/codegen/index.ts
@@ -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;
exportStatements: Set;
importStatements: Set;
+ componentImports: Map;
customElementCandidates: Map;
}
@@ -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(),
};
diff --git a/packages/astro/src/config_manager.ts b/packages/astro/src/config_manager.ts
index c33f560fc..3705a1b3f 100644
--- a/packages/astro/src/config_manager.ts
+++ b/packages/astro/src/config_manager.ts
@@ -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])},
diff --git a/packages/astro/src/frontend/hydrate/only.ts b/packages/astro/src/frontend/hydrate/only.ts
new file mode 100644
index 000000000..62e90b660
--- /dev/null
+++ b/packages/astro/src/frontend/hydrate/only.ts
@@ -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);
+ }
+}
diff --git a/packages/astro/src/internal/__astro_component.ts b/packages/astro/src/internal/__astro_component.ts
index c4e8a680f..bba6d4e68 100644
--- a/packages/astro/src/internal/__astro_component.ts
+++ b/packages/astro/src/internal/__astro_component.ts
@@ -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();
+/** For client:only components, attempt to infer the required renderer. */
+function inferClientRenderer(metadata: Partial) {
+ // 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 {
+async function resolveRenderer(Component: any, props: any = {}, children?: string, metadata: Partial = {}): Promise {
+ // 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) => ``).join('');
diff --git a/packages/astro/test/astro-client-only.test.js b/packages/astro/test/astro-client-only.test.js
new file mode 100644
index 000000000..1c4534807
--- /dev/null
+++ b/packages/astro/test/astro-client-only.test.js
@@ -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>/;
+ 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();
diff --git a/packages/astro/test/astro-dynamic.test.js b/packages/astro/test/astro-dynamic.test.js
index 62ecccbfc..f0ddaa198 100644
--- a/packages/astro/test/astro-dynamic.test.js
+++ b/packages/astro/test/astro-dynamic.test.js
@@ -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>/;
+ 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();
diff --git a/packages/astro/test/fixtures/astro-client-only/astro.config.mjs b/packages/astro/test/fixtures/astro-client-only/astro.config.mjs
new file mode 100644
index 000000000..7e94b713a
--- /dev/null
+++ b/packages/astro/test/fixtures/astro-client-only/astro.config.mjs
@@ -0,0 +1,8 @@
+export default {
+ buildOptions: {
+ sitemap: false,
+ },
+ renderers: [
+ '@astrojs/renderer-svelte',
+ ],
+};
diff --git a/packages/astro/test/fixtures/astro-client-only/snowpack.config.json b/packages/astro/test/fixtures/astro-client-only/snowpack.config.json
new file mode 100644
index 000000000..8f034781d
--- /dev/null
+++ b/packages/astro/test/fixtures/astro-client-only/snowpack.config.json
@@ -0,0 +1,3 @@
+{
+ "workspaceRoot": "../../../../../"
+}
diff --git a/packages/astro/test/fixtures/astro-client-only/src/components/PersistentCounter.svelte b/packages/astro/test/fixtures/astro-client-only/src/components/PersistentCounter.svelte
new file mode 100644
index 000000000..1b74b3408
--- /dev/null
+++ b/packages/astro/test/fixtures/astro-client-only/src/components/PersistentCounter.svelte
@@ -0,0 +1,22 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/packages/astro/test/fixtures/astro-client-only/src/components/logResize.js b/packages/astro/test/fixtures/astro-client-only/src/components/logResize.js
new file mode 100644
index 000000000..336b2396f
--- /dev/null
+++ b/packages/astro/test/fixtures/astro-client-only/src/components/logResize.js
@@ -0,0 +1,3 @@
+window.addEventListener("resize", function() {
+ console.log("window resized");
+});
\ No newline at end of file
diff --git a/packages/astro/test/fixtures/astro-client-only/src/pages/index.astro b/packages/astro/test/fixtures/astro-client-only/src/pages/index.astro
new file mode 100644
index 000000000..b31297456
--- /dev/null
+++ b/packages/astro/test/fixtures/astro-client-only/src/pages/index.astro
@@ -0,0 +1,9 @@
+---
+import PersistentCounter from '../components/PersistentCounter.svelte';
+---
+
+Client only pages
+
+
+
+
\ No newline at end of file
diff --git a/packages/astro/test/fixtures/astro-dynamic/src/components/PersistentCounter.svelte b/packages/astro/test/fixtures/astro-dynamic/src/components/PersistentCounter.svelte
new file mode 100644
index 000000000..1b74b3408
--- /dev/null
+++ b/packages/astro/test/fixtures/astro-dynamic/src/components/PersistentCounter.svelte
@@ -0,0 +1,22 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/packages/astro/test/fixtures/astro-dynamic/src/components/logResize.js b/packages/astro/test/fixtures/astro-dynamic/src/components/logResize.js
new file mode 100644
index 000000000..336b2396f
--- /dev/null
+++ b/packages/astro/test/fixtures/astro-dynamic/src/components/logResize.js
@@ -0,0 +1,3 @@
+window.addEventListener("resize", function() {
+ console.log("window resized");
+});
\ No newline at end of file
diff --git a/packages/astro/test/fixtures/astro-dynamic/src/pages/client-only.astro b/packages/astro/test/fixtures/astro-dynamic/src/pages/client-only.astro
new file mode 100644
index 000000000..c9b1ca389
--- /dev/null
+++ b/packages/astro/test/fixtures/astro-dynamic/src/pages/client-only.astro
@@ -0,0 +1,9 @@
+---
+import PersistentCounter from '../components/PersistentCounter.svelte';
+---
+
+Dynamic pages
+
+
+
+
\ No newline at end of file