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:
Tony Sullivan 2021-07-08 20:07:56 +02:00 committed by GitHub
parent ea5afcd633
commit 0a7b6deaec
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 117 additions and 59 deletions

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -7,6 +7,6 @@ import Tour from '../components/Tour.jsx';
<title>Stuff</title>
</head>
<body>
<Tour:load />
<Tour client:load />
</body>
</html>

View file

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

View file

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

View file

@ -11,6 +11,6 @@ let title = 'My Page'
<body>
<h1>{title}</h1>
<Client:load />
<Client client:load />
</body>
</html>

View file

@ -7,6 +7,6 @@ import Tour from '../components/Tour.jsx';
</head>
<body>
<div>Hello world</div>
<Tour:load />
<Tour client:load />
</body>
</html>

View file

@ -10,6 +10,6 @@ import Tour from '../components/Tour.jsx';
</head>
<body>
<div>Hello world</div>
<Tour:load />
<Tour client:load />
</body>
</html>

View file

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

View file

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

View file

@ -7,4 +7,4 @@
}
</style>
<div>Something here</div>
<Something:idle />
<Something client:idle />

View file

@ -10,5 +10,5 @@ import Child from '../components/Child.astro';
</style>
<h1>Title of this Blog</h1>
<Something:load />
<Something client:load />
<Child />