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: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:media={QUERY} />` will use [matchMedia][mdn-mm] to hydrate the component when a media query is matched.
|
||||
|
||||
## Hydrate Astro Components
|
||||
|
||||
|
|
|
@ -180,9 +180,10 @@ export type Components = Map<string, ComponentInfo>;
|
|||
|
||||
export interface AstroComponentMetadata {
|
||||
displayName: string;
|
||||
hydrate?: 'load' | 'idle' | 'visible';
|
||||
hydrate?: 'load' | 'idle' | 'visible' | 'media';
|
||||
componentUrl?: string;
|
||||
componentExport?: { value: string; namespace?: boolean };
|
||||
value?: undefined | string;
|
||||
}
|
||||
|
||||
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 interface HydrateOptions {
|
||||
value?: string;
|
||||
}
|
|
@ -48,20 +48,25 @@ interface CodeGenOptions {
|
|||
}
|
||||
|
||||
interface HydrationAttributes {
|
||||
method?: 'load' | 'idle' | 'visible';
|
||||
method?: 'load' | 'idle' | 'visible' | 'media';
|
||||
value?: undefined | string
|
||||
}
|
||||
|
||||
/** Searches through attributes to extract hydration-rlated attributes */
|
||||
function findHydrationAttributes(attrs: Record<string, string>): HydrationAttributes {
|
||||
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)) {
|
||||
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 */
|
||||
|
@ -220,15 +225,17 @@ function getComponentWrapper(_name: string, hydration: HydrationAttributes, { ur
|
|||
}
|
||||
};
|
||||
|
||||
const importInfo = method
|
||||
? {
|
||||
componentUrl: getComponentUrl(astroConfig, url, pathToFileURL(filename)),
|
||||
componentExport: getComponentExport(),
|
||||
}
|
||||
: {};
|
||||
let metadata: string = '';
|
||||
if(method) {
|
||||
const componentUrl = getComponentUrl(astroConfig, url, pathToFileURL(filename));
|
||||
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 {
|
||||
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';`],
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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
|
||||
* (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 roots = document.querySelectorAll(`astro-root[uid="${astroId}"]`);
|
||||
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
|
||||
*/
|
||||
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 innerHTML = roots[0].querySelector(`astro-fragment`)?.innerHTML ?? null;
|
||||
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.
|
||||
* We target the children because `astro-root` is set to `display: contents`
|
||||
* 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 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 */
|
||||
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 { hydrate, componentUrl, componentExport } = metadata;
|
||||
|
||||
let hydrationSource = '';
|
||||
if (instance.hydrationPolyfills.length) {
|
||||
|
@ -92,7 +94,7 @@ async function generateHydrateScript({ instance, astroId, props }: HydrateScript
|
|||
|
||||
const hydrationScript = `<script type="module">
|
||||
import setup from '/_astro_frontend/hydrate/${hydrate}.js';
|
||||
setup("${astroId}", async () => {
|
||||
setup("${astroId}", {${metadata.value ? `value: "${metadata.value}"` : ''}}, async () => {
|
||||
${hydrationSource}
|
||||
});
|
||||
</script>`;
|
||||
|
|
|
@ -26,6 +26,15 @@ DynamicComponents('Loads client-only packages', async ({ runtime }) => {
|
|||
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 }) => {
|
||||
try {
|
||||
await build();
|
||||
|
|
|
@ -64,8 +64,6 @@ Markdown('Renders recursively', async ({ runtime }) => {
|
|||
const result = await runtime.load('/recursive');
|
||||
if (result.error) throw new Error(result.error);
|
||||
|
||||
console.log(result.contents);
|
||||
|
||||
const $ = doc(result.contents);
|
||||
assert.equal($('.a > h1').text(), 'A', 'Rendered title .a 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 () {
|
||||
const [count, setCount] = useState(0);
|
||||
return (
|
||||
<div>
|
||||
<button type="button">Increment -</button>
|
||||
<div>Count: {count}</div>
|
||||
<button type="button" onClick={() => setCount(count+1)}>Increment</button>
|
||||
</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…
Reference in a new issue