Move hydration directives to special attributes (#618)
* feat: ♻️ updating hydration to work with the directive syntax * test: ✅ Updating tests for the hydration directive syntax * refactor: Updating example projects for the hydration directive syntax * test: ✅ Found a test fixture still needing an update to the hydration directive syntax * style: Prettier strikes again! Reverting code formatting changes * refactor: ♻️ moving directive matching to a Set * refactor: Updating syntax to `client:load` * refactor: ♻️ Simplifying the `client:` directive match Per PR feedback from @matthewp * chore: errant console.warn() snuck into the last commit * feat: 🔊 Adding a super fancy build warning to update to the directive syntax * refactor: ♻️ Removing unnecessary checks when matching supported hydration directives `val` isn't being used for now, but leaving it in the attr destructuring as a reminder since it'll be needed for `client:media` * test: ✅ Including the original hydration syntax in a test to make sure it builds * style: 📝 Adding a comment to make it clear why the old hydration syntax is included in a the test markup * fix: 🐛 updating `head` logic to recognize hydration directive syntax * docs: Adding changeset * refactor: 🔥 Removing unnecessary `!hasComponents` check * docs: 📝 Adding more detail to the changset Co-authored-by: Tony Sullivan <tony.f.sullivan@gmail.com>
This commit is contained in:
parent
ea5afcd633
commit
0a7b6deaec
25 changed files with 117 additions and 59 deletions
15
.changeset/famous-years-bow.md
Normal file
15
.changeset/famous-years-bow.md
Normal file
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
'astro': minor
|
||||
---
|
||||
|
||||
## Adds directive syntax for component hydration
|
||||
|
||||
This change updates the syntax for partial hydration from `<Button:load />` to `<Button client:load />`.
|
||||
|
||||
**Why?**
|
||||
|
||||
Partial hydration is about to get super powers! This clears the way for more dynamic partial hydration, i.e. `<MobileMenu client:media="(max-width: 40em)" />`.
|
||||
|
||||
**How to upgrade**
|
||||
|
||||
Just update `:load`, `:idle`, and `:visible` to match the `client:load` format, thats it! Don't worry, the original syntax is still supported but it's recommended to future-proof your project by updating to the newer syntax.
|
|
@ -54,7 +54,7 @@ Of course, sometimes client-side JavaScript is inevitable. Image carousels, shop
|
|||
|
||||
In other full-stack web frameworks this level of per-component optimization would be impossible without loading the entire page in JavaScript, delaying interactivity. In Astro, this kind of [partial hydration](https://addyosmani.com/blog/rehydration/) is built into the tool itself.
|
||||
|
||||
You can even [automatically defer components](https://codepen.io/jonneal/full/ZELvMvw) to only load once they become visible on the page with the `:visible` modifier.
|
||||
You can even [automatically defer components](https://codepen.io/jonneal/full/ZELvMvw) to only load once they become visible on the page with the `client:visible` directive.
|
||||
|
||||
This new approach to web architecture is called [islands architecture](https://jasonformat.com/islands-architecture/). We didn't coin the term, but Astro may have perfected the technique. We are confident that an HTML-first, JavaScript-only-as-needed approach is the best solution for the majority of content-based websites.
|
||||
|
||||
|
|
|
@ -212,7 +212,7 @@ const githubEditUrl = `https://github.com/USER/REPO/blob/main/${currentFile}`
|
|||
<div />
|
||||
|
||||
<div>
|
||||
<ThemeToggle:idle />
|
||||
<ThemeToggle client:idle />
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
@ -232,7 +232,7 @@ const githubEditUrl = `https://github.com/USER/REPO/blob/main/${currentFile}`
|
|||
</article>
|
||||
</div>
|
||||
<aside class="sidebar" id="sidebar-content">
|
||||
<DocSidebar:idle headers={headers} editHref={editHref} />
|
||||
<DocSidebar client:idle headers={headers} editHref={editHref} />
|
||||
</aside>
|
||||
</main>
|
||||
</body>
|
||||
|
|
|
@ -35,22 +35,22 @@ import SvelteCounter from '../components/SvelteCounter.svelte';
|
|||
<body>
|
||||
<main>
|
||||
|
||||
<react.Counter:visible>
|
||||
<react.Counter client:visible>
|
||||
<h1>Hello React!</h1>
|
||||
<p>What's up?</p>
|
||||
</react.Counter:visible>
|
||||
</react.Counter>
|
||||
|
||||
<PreactCounter:visible>
|
||||
<PreactCounter client:visible>
|
||||
<h1>Hello Preact!</h1>
|
||||
</PreactCounter:visible>
|
||||
</PreactCounter>
|
||||
|
||||
<VueCounter:visible>
|
||||
<VueCounter client:visible>
|
||||
<h1>Hello Vue!</h1>
|
||||
</VueCounter:visible>
|
||||
</VueCounter>
|
||||
|
||||
<SvelteCounter:visible>
|
||||
<SvelteCounter client:visible>
|
||||
<h1>Hello Svelte!</h1>
|
||||
</SvelteCounter:visible>
|
||||
</SvelteCounter>
|
||||
|
||||
<A />
|
||||
|
||||
|
|
|
@ -33,9 +33,9 @@ import Counter from '../components/Counter.jsx'
|
|||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<Counter:visible>
|
||||
<Counter client:visible>
|
||||
<h1>Hello Preact!</h1>
|
||||
</Counter:visible>
|
||||
</Counter>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -33,9 +33,9 @@ import Counter from '../components/Counter.jsx'
|
|||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<Counter:visible>
|
||||
<Counter client:visible>
|
||||
<h1>Hello React!</h1>
|
||||
</Counter:visible>
|
||||
</Counter>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -33,9 +33,9 @@ import Counter from '../components/Counter.svelte'
|
|||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<Counter:visible>
|
||||
<Counter client:visible>
|
||||
<h1>Hello Svelte!</h1>
|
||||
</Counter:visible>
|
||||
</Counter>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -33,9 +33,9 @@ import Counter from '../components/Counter.vue'
|
|||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<Counter:visible>
|
||||
<Counter client:visible>
|
||||
<h1>Hello Vue!</h1>
|
||||
</Counter:visible>
|
||||
</Counter>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -48,7 +48,7 @@ const description = 'Snowpack community news and companies that use Snowpack.';
|
|||
working on!</div>
|
||||
</article>
|
||||
|
||||
{news.reverse().map((item: any) => <Card:idle item={item} />)}
|
||||
{news.reverse().map((item: any) => <Card client:idle item={item} />)}
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
|
|
|
@ -68,7 +68,7 @@ let description = 'Snowpack plugins allow for configuration-minimal tooling inte
|
|||
|
||||
<div style="margin-top:4rem;"></div>
|
||||
|
||||
<PluginSearchPage:load />
|
||||
<PluginSearchPage client:load />
|
||||
</MainLayout>
|
||||
</body>
|
||||
|
||||
|
|
|
@ -32,10 +32,10 @@ const items = ['A', 'B', 'C'];
|
|||
|
||||
## Embed framework components
|
||||
|
||||
<ReactCounter:visible />
|
||||
<PreactCounter:visible />
|
||||
<VueCounter:visible />
|
||||
<SvelteCounter:visible />
|
||||
<ReactCounter client:visible />
|
||||
<PreactCounter client:visible />
|
||||
<VueCounter client:visible />
|
||||
<SvelteCounter client:visible />
|
||||
|
||||
## Use Expressions
|
||||
|
||||
|
@ -43,11 +43,11 @@ const items = ['A', 'B', 'C'];
|
|||
|
||||
## Oh yeah...
|
||||
|
||||
<ReactCounter:visible>
|
||||
<ReactCounter client:visible>
|
||||
🤯 It's also _recursive_!
|
||||
|
||||
### Markdown can be embedded in any child component
|
||||
</ReactCounter:visible>
|
||||
</ReactCounter>
|
||||
|
||||
## Code
|
||||
|
||||
|
|
|
@ -37,10 +37,10 @@ import AdminsPreact from '../components/AdminsPreact.jsx';
|
|||
<a href="https://github.com/nanostores/nanostores">nanostores</a></h1>
|
||||
</div>
|
||||
</header>
|
||||
<AdminsReact:load />
|
||||
<AdminsSvelte:load />
|
||||
<AdminsVue:load />
|
||||
<AdminsPreact:load />
|
||||
<AdminsReact client:load />
|
||||
<AdminsSvelte client:load />
|
||||
<AdminsVue client:load />
|
||||
<AdminsPreact client:load />
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -118,9 +118,9 @@ TODO: Astro dynamic components guide
|
|||
By default, Astro outputs zero client-side JS. If you'd like to include an interactive component in the client output, you may use any of the following techniques.
|
||||
|
||||
- `<MyComponent />` will render an HTML-only version of `MyComponent` (default)
|
||||
- `<MyComponent:load />` will render `MyComponent` on page load
|
||||
- `<MyComponent:idle />` will use [requestIdleCallback()][mdn-ric] to render `MyComponent` as soon as main thread is free
|
||||
- `<MyComponent:visible />` will use an [IntersectionObserver][mdn-io] to render `MyComponent` when the element enters the viewport
|
||||
- `<MyComponent client:load />` will render `MyComponent` on page load
|
||||
- `<MyComponent client:idle />` will use [requestIdleCallback()][mdn-ric] to render `MyComponent` as soon as main thread is free
|
||||
- `<MyComponent client:visible />` will use an [IntersectionObserver][mdn-io] to render `MyComponent` when the element enters the viewport
|
||||
|
||||
### ⚛️ State Management
|
||||
|
||||
|
|
|
@ -47,6 +47,23 @@ interface CodeGenOptions {
|
|||
fileID: string;
|
||||
}
|
||||
|
||||
interface HydrationAttributes {
|
||||
method?: 'load' | 'idle' | 'visible';
|
||||
}
|
||||
|
||||
/** Searches through attributes to extract hydration-rlated attributes */
|
||||
function findHydrationAttributes(attrs: Record<string, string>): HydrationAttributes {
|
||||
let method: HydrationAttributes['method'];
|
||||
|
||||
const hydrationDirectives = new Set(['client:load', 'client:idle', 'client:visible']);
|
||||
|
||||
for (const [key, val] of Object.entries(attrs)) {
|
||||
if (hydrationDirectives.has(key)) method = key.slice(7) as HydrationAttributes['method'];
|
||||
}
|
||||
|
||||
return { method };
|
||||
}
|
||||
|
||||
/** Retrieve attributes from TemplateNode */
|
||||
async function getAttributes(attrs: Attribute[], state: CodegenState, compileOptions: CompileOptions): Promise<Record<string, string>> {
|
||||
let result: Record<string, string> = {};
|
||||
|
@ -154,18 +171,32 @@ function getComponentUrl(astroConfig: AstroConfig, url: string, parentUrl: strin
|
|||
interface GetComponentWrapperOptions {
|
||||
filename: string;
|
||||
astroConfig: AstroConfig;
|
||||
compileOptions: CompileOptions;
|
||||
}
|
||||
|
||||
const PlainExtensions = new Set(['.js', '.jsx', '.ts', '.tsx']);
|
||||
/** Generate Astro-friendly component import */
|
||||
function getComponentWrapper(_name: string, { url, importSpecifier }: ComponentInfo, opts: GetComponentWrapperOptions) {
|
||||
function getComponentWrapper(_name: string, hydration: HydrationAttributes, { url, importSpecifier }: ComponentInfo, opts: GetComponentWrapperOptions) {
|
||||
const { astroConfig, filename } = opts;
|
||||
const [name, kind] = _name.split(':');
|
||||
|
||||
let name = _name;
|
||||
let method = hydration.method;
|
||||
|
||||
/** Legacy support for original hydration syntax */
|
||||
if (name.indexOf(':') > 0) {
|
||||
const [legacyName, legacyMethod] = _name.split(':');
|
||||
name = legacyName;
|
||||
method = legacyMethod as HydrationAttributes['method'];
|
||||
|
||||
const { compileOptions, filename } = opts;
|
||||
const shortname = path.posix.relative(compileOptions.astroConfig.projectRoot.pathname, filename);
|
||||
warn(compileOptions.logging, shortname, yellow(`Deprecation warning: Partial hydration now uses a directive syntax. Please update to "<${name} client:${method} />"`));
|
||||
}
|
||||
|
||||
// Special flow for custom elements
|
||||
if (isCustomElementTag(name)) {
|
||||
if (isCustomElementTag(_name)) {
|
||||
return {
|
||||
wrapper: `__astro_component(...__astro_element_registry.astroComponentArgs("${name}", ${JSON.stringify({ hydrate: kind, displayName: _name })}))`,
|
||||
wrapper: `__astro_component(...__astro_element_registry.astroComponentArgs("${name}", ${JSON.stringify({ hydrate: method, displayName: _name })}))`,
|
||||
wrapperImports: [
|
||||
`import {AstroElementRegistry} from 'astro/dist/internal/element-registry.js';`,
|
||||
`import {__astro_component} from 'astro/dist/internal/__astro_component.js';`,
|
||||
|
@ -183,21 +214,21 @@ function getComponentWrapper(_name: string, { url, importSpecifier }: ComponentI
|
|||
return { value: importSpecifier.imported.value };
|
||||
}
|
||||
case 'ImportNamespaceSpecifier': {
|
||||
const [_, value] = name.split('.');
|
||||
const [_, value] = _name.split('.');
|
||||
return { value };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const importInfo = kind
|
||||
? {
|
||||
componentUrl: getComponentUrl(astroConfig, url, pathToFileURL(filename)),
|
||||
componentExport: getComponentExport(),
|
||||
}
|
||||
: {};
|
||||
const importInfo = method
|
||||
? {
|
||||
componentUrl: getComponentUrl(astroConfig, url, pathToFileURL(filename)),
|
||||
componentExport: getComponentExport()
|
||||
}
|
||||
: {};
|
||||
|
||||
return {
|
||||
wrapper: `__astro_component(${name}, ${JSON.stringify({ hydrate: kind, displayName: _name, ...importInfo })})`,
|
||||
wrapper: `__astro_component(${name}, ${JSON.stringify({ hydrate: method, displayName: _name, ...importInfo })})`,
|
||||
wrapperImports: [`import {__astro_component} from 'astro/dist/internal/__astro_component.js';`],
|
||||
};
|
||||
}
|
||||
|
@ -633,6 +664,7 @@ async function compileHtml(enterNode: TemplateNode, state: CodegenState, compile
|
|||
}
|
||||
try {
|
||||
const attributes = await getAttributes(node.attributes, state, compileOptions);
|
||||
const hydrationAttributes = findHydrationAttributes(attributes);
|
||||
|
||||
buffers.out += buffers.out === '' ? '' : ',';
|
||||
|
||||
|
@ -671,7 +703,7 @@ async function compileHtml(enterNode: TemplateNode, state: CodegenState, compile
|
|||
curr = 'markdown';
|
||||
return;
|
||||
}
|
||||
const { wrapper, wrapperImports } = getComponentWrapper(name, componentInfo ?? ({} as any), { astroConfig, filename });
|
||||
const { wrapper, wrapperImports } = getComponentWrapper(name, hydrationAttributes, componentInfo ?? ({} as any), { astroConfig, filename, compileOptions });
|
||||
if (wrapperImports) {
|
||||
for (let wrapperImport of wrapperImports) {
|
||||
importStatements.add(wrapperImport);
|
||||
|
|
|
@ -21,8 +21,18 @@ export default function (opts: TransformOptions): Transformer {
|
|||
},
|
||||
InlineComponent: {
|
||||
enter(node) {
|
||||
if (hasComponents) {
|
||||
return
|
||||
}
|
||||
|
||||
if (node.attributes && node.attributes.some(({ name }: any) => name.startsWith('client:'))) {
|
||||
hasComponents = true;
|
||||
return;
|
||||
}
|
||||
|
||||
/** Check for legacy hydration */
|
||||
const [_name, kind] = node.name.split(':');
|
||||
if (kind && !hasComponents) {
|
||||
if (kind) {
|
||||
hasComponents = true;
|
||||
}
|
||||
},
|
||||
|
|
|
@ -7,6 +7,6 @@ import Tour from '../components/Tour.jsx';
|
|||
<title>Stuff</title>
|
||||
</head>
|
||||
<body>
|
||||
<Tour:load />
|
||||
<Tour client:load />
|
||||
</body>
|
||||
</html>
|
|
@ -5,8 +5,8 @@ import SvelteCounterRenamed from '../components/SvelteCounter.svelte';
|
|||
<html>
|
||||
<head><title>Dynamic pages</title></head>
|
||||
<body>
|
||||
<CounterRenamed:load />
|
||||
<CounterRenamed client:load />
|
||||
|
||||
<SvelteCounterRenamed:load />
|
||||
<SvelteCounterRenamed client:load />
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -5,8 +5,9 @@ import SvelteCounter from '../components/SvelteCounter.svelte';
|
|||
<html>
|
||||
<head><title>Dynamic pages</title></head>
|
||||
<body>
|
||||
<Counter:load />
|
||||
<Counter client:load />
|
||||
|
||||
<!-- Including the original hydration syntax to test backwards compatibility -->
|
||||
<SvelteCounter:load />
|
||||
</body>
|
||||
</html>
|
|
@ -11,6 +11,6 @@ let title = 'My Page'
|
|||
<body>
|
||||
<h1>{title}</h1>
|
||||
|
||||
<Client:load />
|
||||
<Client client:load />
|
||||
</body>
|
||||
</html>
|
|
@ -7,6 +7,6 @@ import Tour from '../components/Tour.jsx';
|
|||
</head>
|
||||
<body>
|
||||
<div>Hello world</div>
|
||||
<Tour:load />
|
||||
<Tour client:load />
|
||||
</body>
|
||||
</html>
|
|
@ -10,6 +10,6 @@ import Tour from '../components/Tour.jsx';
|
|||
</head>
|
||||
<body>
|
||||
<div>Hello world</div>
|
||||
<Tour:load />
|
||||
<Tour client:load />
|
||||
</body>
|
||||
</html>
|
|
@ -14,7 +14,7 @@ const description = 'This is a post about some stuff.';
|
|||
## Interesting Topic
|
||||
|
||||
<Hello name={`world`} />
|
||||
<Counter:load />
|
||||
<Counter client:load />
|
||||
|
||||
</Layout>
|
||||
</Markdown>
|
||||
|
|
|
@ -10,6 +10,6 @@ import '../components/my-element.js';
|
|||
<body>
|
||||
<h1>{title}</h1>
|
||||
|
||||
<my-element:load></my-element:load>
|
||||
<my-element client:load></my-element>
|
||||
</body>
|
||||
</html>
|
|
@ -7,4 +7,4 @@
|
|||
}
|
||||
</style>
|
||||
<div>Something here</div>
|
||||
<Something:idle />
|
||||
<Something client:idle />
|
|
@ -10,5 +10,5 @@ import Child from '../components/Child.astro';
|
|||
</style>
|
||||
<h1>Title of this Blog</h1>
|
||||
|
||||
<Something:load />
|
||||
<Something client:load />
|
||||
<Child />
|
Loading…
Reference in a new issue