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

View file

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

View file

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

View file

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

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

View file

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

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. * 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;

View file

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

View file

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

View file

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

View file

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

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>