diff --git a/.changeset/wicked-gifts-cover.md b/.changeset/wicked-gifts-cover.md new file mode 100644 index 000000000..0680e0651 --- /dev/null +++ b/.changeset/wicked-gifts-cover.md @@ -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'; +--- + + +``` + +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'; +--- + + +``` \ No newline at end of file diff --git a/docs/core-concepts/component-hydration.md b/docs/core-concepts/component-hydration.md index 240eab25b..209e4a8ed 100644 --- a/docs/core-concepts/component-hydration.md +++ b/docs/core-concepts/component-hydration.md @@ -30,6 +30,7 @@ Astro renders every component on the server **at build time**. To hydrate any se - `` will hydrate the component on page load. - `` will use [requestIdleCallback()][mdn-ric] to hydrate the component as soon as main thread is free. - `` will use an [IntersectionObserver][mdn-io] to hydrate the component when the element enters the viewport. +- `` will use [matchMedia][mdn-mm] to hydrate the component when a media query is matched. ## Hydrate Astro Components diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index ce2e2bb03..3ff0ceb7e 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -180,9 +180,10 @@ export type Components = Map; 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 = (Component: any, props: any, children: string | undefined, metadata?: AstroComponentMetadata) => Promise; diff --git a/packages/astro/src/@types/hydrate.ts b/packages/astro/src/@types/hydrate.ts index ff1ab0781..88d6a0cc3 100644 --- a/packages/astro/src/@types/hydrate.ts +++ b/packages/astro/src/@types/hydrate.ts @@ -1 +1,5 @@ export type GetHydrateCallback = () => Promise<(element: Element, innerHTML: string | null) => void>; + +export interface HydrateOptions { + value?: string; +} \ No newline at end of file diff --git a/packages/astro/src/compiler/codegen/index.ts b/packages/astro/src/compiler/codegen/index.ts index f264b5878..52f7fd576 100644 --- a/packages/astro/src/compiler/codegen/index.ts +++ b/packages/astro/src/compiler/codegen/index.ts @@ -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): 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';`], }; } diff --git a/packages/astro/src/frontend/hydrate/idle.ts b/packages/astro/src/frontend/hydrate/idle.ts index 2fd96b9cb..f270d0928 100644 --- a/packages/astro/src/frontend/hydrate/idle.ts +++ b/packages/astro/src/frontend/hydrate/idle.ts @@ -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; diff --git a/packages/astro/src/frontend/hydrate/load.ts b/packages/astro/src/frontend/hydrate/load.ts index 38ac1a0ea..62e90b660 100644 --- a/packages/astro/src/frontend/hydrate/load.ts +++ b/packages/astro/src/frontend/hydrate/load.ts @@ -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(); diff --git a/packages/astro/src/frontend/hydrate/media.ts b/packages/astro/src/frontend/hydrate/media.ts new file mode 100644 index 000000000..39c57c4f9 --- /dev/null +++ b/packages/astro/src/frontend/hydrate/media.ts @@ -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}); + } +} diff --git a/packages/astro/src/frontend/hydrate/visible.ts b/packages/astro/src/frontend/hydrate/visible.ts index d4dacdf51..eb55e63e9 100644 --- a/packages/astro/src/frontend/hydrate/visible.ts +++ b/packages/astro/src/frontend/hydrate/visible.ts @@ -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; diff --git a/packages/astro/src/internal/__astro_component.ts b/packages/astro/src/internal/__astro_component.ts index 6738600e3..1ddd40b05 100644 --- a/packages/astro/src/internal/__astro_component.ts +++ b/packages/astro/src/internal/__astro_component.ts @@ -72,8 +72,10 @@ interface HydrateScriptOptions { } /** For hydrated components, generate a `; diff --git a/packages/astro/test/astro-dynamic.test.js b/packages/astro/test/astro-dynamic.test.js index 30bcf456c..9decd539c 100644 --- a/packages/astro/test/astro-dynamic.test.js +++ b/packages/astro/test/astro-dynamic.test.js @@ -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(); diff --git a/packages/astro/test/astro-markdown.test.js b/packages/astro/test/astro-markdown.test.js index 73e17a5f8..3517c88f1 100644 --- a/packages/astro/test/astro-markdown.test.js +++ b/packages/astro/test/astro-markdown.test.js @@ -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'); diff --git a/packages/astro/test/fixtures/astro-dynamic/src/components/Counter.jsx b/packages/astro/test/fixtures/astro-dynamic/src/components/Counter.jsx index 7a7d6ea1f..5c9d142e2 100644 --- a/packages/astro/test/fixtures/astro-dynamic/src/components/Counter.jsx +++ b/packages/astro/test/fixtures/astro-dynamic/src/components/Counter.jsx @@ -1,9 +1,11 @@ -import React from 'react'; +import React, { useState } from 'react'; export default function () { + const [count, setCount] = useState(0); return (
- +
Count: {count}
+
); } diff --git a/packages/astro/test/fixtures/astro-dynamic/src/pages/media.astro b/packages/astro/test/fixtures/astro-dynamic/src/pages/media.astro new file mode 100644 index 000000000..3435f8df4 --- /dev/null +++ b/packages/astro/test/fixtures/astro-dynamic/src/pages/media.astro @@ -0,0 +1,17 @@ +--- +import Counter from '../components/Counter.jsx'; +const MOBILE = "(max-width: 600px)"; +--- + + + Media hydration + + + + + + + + + + \ No newline at end of file