Adds support for client:media hydrator (#664)
* Adds support for client:media hydrator * Include a changeset * Pass in undefined when not hydrating
This commit is contained in:
parent
7aa8d4719f
commit
0340b0f0b1
14 changed files with 125 additions and 24 deletions
37
.changeset/wicked-gifts-cover.md
Normal file
37
.changeset/wicked-gifts-cover.md
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
---
|
||||||
|
'astro': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Adds support for the client:media hydrator
|
||||||
|
|
||||||
|
The new `client:media` hydrator allows you to define a component that should only be loaded when a media query matches. An example usage:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
---
|
||||||
|
import Sidebar from '../components/Sidebar.jsx';
|
||||||
|
---
|
||||||
|
|
||||||
|
<Sidebar client:media="(max-width: 700px)" />
|
||||||
|
```
|
||||||
|
|
||||||
|
This allows you to define components which, for example, only run on mobile devices. A common example is a slide-in sidebar that is needed to add navigation to a mobile app, but is never displayed in desktop view.
|
||||||
|
|
||||||
|
Since Astro components can have expressions, you can move common media queries to a module for sharing. For example here are defining:
|
||||||
|
|
||||||
|
__media.js__
|
||||||
|
|
||||||
|
```js
|
||||||
|
export const MOBILE = "(max-width: 700px)";
|
||||||
|
```
|
||||||
|
|
||||||
|
And then you can reference this in your page:
|
||||||
|
|
||||||
|
__index.astro__
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import Sidebar from '../components/Sidebar.jsx';
|
||||||
|
import { MOBILE } from '../media.js';
|
||||||
|
---
|
||||||
|
|
||||||
|
<Sidebar client:media={MOBILE} />
|
||||||
|
```
|
|
@ -30,6 +30,7 @@ Astro renders every component on the server **at build time**. To hydrate any se
|
||||||
- `<MyComponent client:load />` will hydrate the component on page load.
|
- `<MyComponent client:load />` will hydrate the component on page load.
|
||||||
- `<MyComponent client:idle />` will use [requestIdleCallback()][mdn-ric] to hydrate the component as soon as main thread is free.
|
- `<MyComponent client:idle />` will use [requestIdleCallback()][mdn-ric] to hydrate the component as soon as main thread is free.
|
||||||
- `<MyComponent client:visible />` will use an [IntersectionObserver][mdn-io] to hydrate the component when the element enters the viewport.
|
- `<MyComponent client:visible />` will use an [IntersectionObserver][mdn-io] to hydrate the component when the element enters the viewport.
|
||||||
|
- `<MyComponent client:media={QUERY} />` will use [matchMedia][mdn-mm] to hydrate the component when a media query is matched.
|
||||||
|
|
||||||
## Hydrate Astro Components
|
## Hydrate Astro Components
|
||||||
|
|
||||||
|
|
|
@ -180,9 +180,10 @@ export type Components = Map<string, ComponentInfo>;
|
||||||
|
|
||||||
export interface AstroComponentMetadata {
|
export interface AstroComponentMetadata {
|
||||||
displayName: string;
|
displayName: string;
|
||||||
hydrate?: 'load' | 'idle' | 'visible';
|
hydrate?: 'load' | 'idle' | 'visible' | 'media';
|
||||||
componentUrl?: string;
|
componentUrl?: string;
|
||||||
componentExport?: { value: string; namespace?: boolean };
|
componentExport?: { value: string; namespace?: boolean };
|
||||||
|
value?: undefined | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type AsyncRendererComponentFn<U> = (Component: any, props: any, children: string | undefined, metadata?: AstroComponentMetadata) => Promise<U>;
|
type AsyncRendererComponentFn<U> = (Component: any, props: any, children: string | undefined, metadata?: AstroComponentMetadata) => Promise<U>;
|
||||||
|
|
|
@ -1 +1,5 @@
|
||||||
export type GetHydrateCallback = () => Promise<(element: Element, innerHTML: string | null) => void>;
|
export type GetHydrateCallback = () => Promise<(element: Element, innerHTML: string | null) => void>;
|
||||||
|
|
||||||
|
export interface HydrateOptions {
|
||||||
|
value?: string;
|
||||||
|
}
|
|
@ -48,20 +48,25 @@ interface CodeGenOptions {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HydrationAttributes {
|
interface HydrationAttributes {
|
||||||
method?: 'load' | 'idle' | 'visible';
|
method?: 'load' | 'idle' | 'visible' | 'media';
|
||||||
|
value?: undefined | string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Searches through attributes to extract hydration-rlated attributes */
|
/** Searches through attributes to extract hydration-rlated attributes */
|
||||||
function findHydrationAttributes(attrs: Record<string, string>): HydrationAttributes {
|
function findHydrationAttributes(attrs: Record<string, string>): HydrationAttributes {
|
||||||
let method: HydrationAttributes['method'];
|
let method: HydrationAttributes['method'];
|
||||||
|
let value: undefined | string;
|
||||||
|
|
||||||
const hydrationDirectives = new Set(['client:load', 'client:idle', 'client:visible']);
|
const hydrationDirectives = new Set(['client:load', 'client:idle', 'client:visible', 'client:media']);
|
||||||
|
|
||||||
for (const [key, val] of Object.entries(attrs)) {
|
for (const [key, val] of Object.entries(attrs)) {
|
||||||
if (hydrationDirectives.has(key)) method = key.slice(7) as HydrationAttributes['method'];
|
if (hydrationDirectives.has(key)) {
|
||||||
|
method = key.slice(7) as HydrationAttributes['method'];
|
||||||
|
value = val === "true" ? undefined : val;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { method };
|
return { method, value };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Retrieve attributes from TemplateNode */
|
/** Retrieve attributes from TemplateNode */
|
||||||
|
@ -220,15 +225,17 @@ function getComponentWrapper(_name: string, hydration: HydrationAttributes, { ur
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const importInfo = method
|
let metadata: string = '';
|
||||||
? {
|
if(method) {
|
||||||
componentUrl: getComponentUrl(astroConfig, url, pathToFileURL(filename)),
|
const componentUrl = getComponentUrl(astroConfig, url, pathToFileURL(filename));
|
||||||
componentExport: getComponentExport(),
|
const componentExport = getComponentExport();
|
||||||
}
|
metadata = `{ hydrate: "${method}", displayName: "${name}", componentUrl: "${componentUrl}", componentExport: ${JSON.stringify(componentExport)}, value: ${hydration.value || 'null'} }`;
|
||||||
: {};
|
} else {
|
||||||
|
metadata = `{ hydrate: undefined, displayName: "${name}", value: ${hydration.value || 'null'} }`
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
wrapper: `__astro_component(${name}, ${JSON.stringify({ hydrate: method, displayName: _name, ...importInfo })})`,
|
wrapper: `__astro_component(${name}, ${metadata})`,
|
||||||
wrapperImports: [`import {__astro_component} from 'astro/dist/internal/__astro_component.js';`],
|
wrapperImports: [`import {__astro_component} from 'astro/dist/internal/__astro_component.js';`],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import type { GetHydrateCallback } from '../../@types/hydrate';
|
import type { GetHydrateCallback, HydrateOptions } from '../../@types/hydrate';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hydrate this component as soon as the main thread is free
|
* Hydrate this component as soon as the main thread is free
|
||||||
* (or after a short delay, if `requestIdleCallback`) isn't supported
|
* (or after a short delay, if `requestIdleCallback`) isn't supported
|
||||||
*/
|
*/
|
||||||
export default async function onIdle(astroId: string, getHydrateCallback: GetHydrateCallback) {
|
export default async function onIdle(astroId: string, _options: HydrateOptions, getHydrateCallback: GetHydrateCallback) {
|
||||||
const cb = async () => {
|
const cb = async () => {
|
||||||
const roots = document.querySelectorAll(`astro-root[uid="${astroId}"]`);
|
const roots = document.querySelectorAll(`astro-root[uid="${astroId}"]`);
|
||||||
const innerHTML = roots[0].querySelector(`astro-fragment`)?.innerHTML ?? null;
|
const innerHTML = roots[0].querySelector(`astro-fragment`)?.innerHTML ?? null;
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import type { GetHydrateCallback } from '../../@types/hydrate';
|
import type { GetHydrateCallback, HydrateOptions } from '../../@types/hydrate';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hydrate this component immediately
|
* Hydrate this component immediately
|
||||||
*/
|
*/
|
||||||
export default async function onLoad(astroId: string, getHydrateCallback: GetHydrateCallback) {
|
export default async function onLoad(astroId: string, _options: HydrateOptions, getHydrateCallback: GetHydrateCallback) {
|
||||||
const roots = document.querySelectorAll(`astro-root[uid="${astroId}"]`);
|
const roots = document.querySelectorAll(`astro-root[uid="${astroId}"]`);
|
||||||
const innerHTML = roots[0].querySelector(`astro-fragment`)?.innerHTML ?? null;
|
const innerHTML = roots[0].querySelector(`astro-fragment`)?.innerHTML ?? null;
|
||||||
const hydrate = await getHydrateCallback();
|
const hydrate = await getHydrateCallback();
|
||||||
|
|
23
packages/astro/src/frontend/hydrate/media.ts
Normal file
23
packages/astro/src/frontend/hydrate/media.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import type { GetHydrateCallback, HydrateOptions } from '../../@types/hydrate';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hydrate this component when a matching media query is found
|
||||||
|
*/
|
||||||
|
export default async function onMedia(astroId: string, options: HydrateOptions, getHydrateCallback: GetHydrateCallback) {
|
||||||
|
const roots = document.querySelectorAll(`astro-root[uid="${astroId}"]`);
|
||||||
|
const innerHTML = roots[0].querySelector(`astro-fragment`)?.innerHTML ?? null;
|
||||||
|
|
||||||
|
const cb = async () => {
|
||||||
|
const hydrate = await getHydrateCallback();
|
||||||
|
for (const root of roots) {
|
||||||
|
hydrate(root, innerHTML);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const mql = matchMedia(options.value!);
|
||||||
|
if(mql.matches) {
|
||||||
|
cb();
|
||||||
|
} else {
|
||||||
|
mql.addEventListener('change', cb, {once:true});
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,11 +1,11 @@
|
||||||
import type { GetHydrateCallback } from '../../@types/hydrate';
|
import type { GetHydrateCallback, HydrateOptions } from '../../@types/hydrate';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hydrate this component when one of it's children becomes visible.
|
* Hydrate this component when one of it's children becomes visible.
|
||||||
* We target the children because `astro-root` is set to `display: contents`
|
* We target the children because `astro-root` is set to `display: contents`
|
||||||
* which doesn't work with IntersectionObserver
|
* which doesn't work with IntersectionObserver
|
||||||
*/
|
*/
|
||||||
export default async function onVisible(astroId: string, getHydrateCallback: GetHydrateCallback) {
|
export default async function onVisible(astroId: string, _options: HydrateOptions, getHydrateCallback: GetHydrateCallback) {
|
||||||
const roots = document.querySelectorAll(`astro-root[uid="${astroId}"]`);
|
const roots = document.querySelectorAll(`astro-root[uid="${astroId}"]`);
|
||||||
const innerHTML = roots[0].querySelector(`astro-fragment`)?.innerHTML ?? null;
|
const innerHTML = roots[0].querySelector(`astro-fragment`)?.innerHTML ?? null;
|
||||||
|
|
||||||
|
|
|
@ -72,8 +72,10 @@ interface HydrateScriptOptions {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** For hydrated components, generate a <script type="module"> to load the component */
|
/** For hydrated components, generate a <script type="module"> to load the component */
|
||||||
async function generateHydrateScript({ instance, astroId, props }: HydrateScriptOptions, { hydrate, componentUrl, componentExport }: Required<AstroComponentMetadata>) {
|
async function generateHydrateScript(scriptOptions: HydrateScriptOptions, metadata: Required<AstroComponentMetadata>) {
|
||||||
|
const { instance, astroId, props } = scriptOptions;
|
||||||
const { source } = instance;
|
const { source } = instance;
|
||||||
|
const { hydrate, componentUrl, componentExport } = metadata;
|
||||||
|
|
||||||
let hydrationSource = '';
|
let hydrationSource = '';
|
||||||
if (instance.hydrationPolyfills.length) {
|
if (instance.hydrationPolyfills.length) {
|
||||||
|
@ -92,7 +94,7 @@ async function generateHydrateScript({ instance, astroId, props }: HydrateScript
|
||||||
|
|
||||||
const hydrationScript = `<script type="module">
|
const hydrationScript = `<script type="module">
|
||||||
import setup from '/_astro_frontend/hydrate/${hydrate}.js';
|
import setup from '/_astro_frontend/hydrate/${hydrate}.js';
|
||||||
setup("${astroId}", async () => {
|
setup("${astroId}", {${metadata.value ? `value: "${metadata.value}"` : ''}}, async () => {
|
||||||
${hydrationSource}
|
${hydrationSource}
|
||||||
});
|
});
|
||||||
</script>`;
|
</script>`;
|
||||||
|
|
|
@ -26,6 +26,15 @@ DynamicComponents('Loads client-only packages', async ({ runtime }) => {
|
||||||
assert.equal(result.statusCode, 200, 'Can load react renderer');
|
assert.equal(result.statusCode, 200, 'Can load react renderer');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
DynamicComponents('Loads pages using client:media hydrator', async ({ runtime }) => {
|
||||||
|
let result = await runtime.load('/media');
|
||||||
|
if (result.error) throw new Error(result.error);
|
||||||
|
|
||||||
|
let html = result.contents;
|
||||||
|
assert.ok(html.includes(`value: "(max-width: 700px)"`), 'static value rendered');
|
||||||
|
assert.ok(html.includes(`value: "(max-width: 600px)"`), 'dynamic value rendered');
|
||||||
|
});
|
||||||
|
|
||||||
DynamicComponents('Can be built', async ({ build }) => {
|
DynamicComponents('Can be built', async ({ build }) => {
|
||||||
try {
|
try {
|
||||||
await build();
|
await build();
|
||||||
|
|
|
@ -64,8 +64,6 @@ Markdown('Renders recursively', async ({ runtime }) => {
|
||||||
const result = await runtime.load('/recursive');
|
const result = await runtime.load('/recursive');
|
||||||
if (result.error) throw new Error(result.error);
|
if (result.error) throw new Error(result.error);
|
||||||
|
|
||||||
console.log(result.contents);
|
|
||||||
|
|
||||||
const $ = doc(result.contents);
|
const $ = doc(result.contents);
|
||||||
assert.equal($('.a > h1').text(), 'A', 'Rendered title .a correctly');
|
assert.equal($('.a > h1').text(), 'A', 'Rendered title .a correctly');
|
||||||
assert.equal($('.b > h1').text(), 'B', 'Rendered title .b correctly');
|
assert.equal($('.b > h1').text(), 'B', 'Rendered title .b correctly');
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
export default function () {
|
export default function () {
|
||||||
|
const [count, setCount] = useState(0);
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<button type="button">Increment -</button>
|
<div>Count: {count}</div>
|
||||||
|
<button type="button" onClick={() => setCount(count+1)}>Increment</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
17
packages/astro/test/fixtures/astro-dynamic/src/pages/media.astro
vendored
Normal file
17
packages/astro/test/fixtures/astro-dynamic/src/pages/media.astro
vendored
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
---
|
||||||
|
import Counter from '../components/Counter.jsx';
|
||||||
|
const MOBILE = "(max-width: 600px)";
|
||||||
|
---
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Media hydration</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Inline value -->
|
||||||
|
<Counter client:media="(max-width: 700px)" />
|
||||||
|
|
||||||
|
<!-- Using a variable -->
|
||||||
|
<Counter client:media={MOBILE} />
|
||||||
|
</body>
|
||||||
|
</html>
|
Loading…
Add table
Reference in a new issue