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:
Matthew Phillips 2021-07-12 16:27:08 -04:00 committed by GitHub
parent 7aa8d4719f
commit 0340b0f0b1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 125 additions and 24 deletions

View 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} />
```

View file

@ -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

View file

@ -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>;

View file

@ -1 +1,5 @@
export type GetHydrateCallback = () => Promise<(element: Element, innerHTML: string | null) => void>;
export interface HydrateOptions {
value?: string;
}

View file

@ -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';`],
};
}

View file

@ -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;

View file

@ -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();

View 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});
}
}

View file

@ -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;

View file

@ -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>`;

View file

@ -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();

View file

@ -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');

View file

@ -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>
);
}

View 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>