New hydration methods (#29)

* WIP: new hydration methods

* refactor: genericize load/idle/visible renderers

* fix: do not pass "data-astro-id" to component

* docs: add hydration section to README

* docs: update README

Co-authored-by: Nate Moore <nate@skypack.dev>
This commit is contained in:
Nate Moore 2021-03-26 17:09:28 -05:00 committed by GitHub
parent 202973291f
commit 9ab1f52a1c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 219 additions and 144 deletions

View file

@ -10,6 +10,14 @@ npm install astro
TODO: astro boilerplate
### 💧 Partial Hydration
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:load` will render `MyComponent` on page load
- `MyComponent:idle` will use `requestIdleCallback` to render `MyComponent` as soon as main thread is free
- `MyComponent:visible` will use an `IntersectionObserver` to render `MyComponent` when the element enters the viewport
## 🧞 Development
Add a `dev` npm script to your `/package.json` file:

View file

@ -72,10 +72,7 @@ let communityGuides;
</h3>
<div class="card-grid card-grid-4">
{communityGuides.map((post) => {
return
<Card item={post} />;
})}
{communityGuides.map((post) => <Card item={post} />)}
<Card item={{
url: 'https://www.snowpack.dev/posts/2021-01-13-snowpack-3-0',
img: 'https://www.snowpack.dev/img/social-snowpackv3.jpg',

View file

@ -47,8 +47,7 @@ const description = 'Snowpack community news and companies that use Snowpack.';
working on!</div>
</article>
{news.reverse().map((item: any) =>
<Card:dynamic item={item} />)}
{news.reverse().map((item: any) => <Card:idle item={item} />)}
</div>
<div class="content">
@ -77,4 +76,4 @@ const description = 'Snowpack community news and companies that use Snowpack.';
</MainLayout>
</body>
</html>
</html>

View file

@ -65,7 +65,9 @@ let description = 'Snowpack plugins allow for configuration-minimal tooling inte
<a href="/reference/plugins">Creating your own plugin is easy!</a>
</p>
<PluginSearchPage:dynamic />
<div style="margin-top:100vh;"></div>
<PluginSearchPage:load />
</MainLayout>
</body>

View file

@ -120,6 +120,8 @@ function getComponentWrapper(_name: string, { type, plugin, url }: ComponentInfo
throw new Error(`No supported plugin found for extension ${type}`);
}
const getComponentUrl = (ext = '.js') => `new URL(${JSON.stringify(url.replace(/\.[^.]+$/, ext))}, \`http://TEST\${import.meta.url\}\`).pathname.replace(/^\\/\\//, '/_astro/')`;
switch (plugin) {
case 'astro': {
if (kind) {
@ -131,64 +133,78 @@ function getComponentWrapper(_name: string, { type, plugin, url }: ComponentInfo
};
}
case 'preact': {
if (kind === 'dynamic') {
if (['load', 'idle', 'visible'].includes(kind)) {
return {
wrapper: `__preact_dynamic(${name}, new URL(${JSON.stringify(url.replace(/\.[^.]+$/, '.js'))}, \`http://TEST\${import.meta.url}\`).pathname, '${dynamicImports.get(
'preact'
)!}')`,
wrapperImport: `import {__preact_dynamic} from '${internalImport('render/preact.js')}';`,
};
} else {
return {
wrapper: `__preact_static(${name})`,
wrapperImport: `import {__preact_static} from '${internalImport('render/preact.js')}';`,
wrapper: `__preact_${kind}(${name}, ${JSON.stringify({
componentUrl: getComponentUrl(),
componentExport: 'default',
frameworkUrls: {
preact: dynamicImports.get('preact'),
},
})})`,
wrapperImport: `import {__preact_${kind}} from '${internalImport('render/preact.js')}';`,
};
}
return {
wrapper: `__preact_static(${name})`,
wrapperImport: `import {__preact_static} from '${internalImport('render/preact.js')}';`,
};
}
case 'react': {
if (kind === 'dynamic') {
if (['load', 'idle', 'visible'].includes(kind)) {
return {
wrapper: `__react_dynamic(${name}, new URL(${JSON.stringify(url.replace(/\.[^.]+$/, '.js'))}, \`http://TEST\${import.meta.url}\`).pathname, '${dynamicImports.get(
'react'
)!}', '${dynamicImports.get('react-dom')!}')`,
wrapperImport: `import {__react_dynamic} from '${internalImport('render/react.js')}';`,
};
} else {
return {
wrapper: `__react_static(${name})`,
wrapperImport: `import {__react_static} from '${internalImport('render/react.js')}';`,
wrapper: `__preact_${kind}(${name}, ${JSON.stringify({
componentUrl: getComponentUrl(),
componentExport: 'default',
frameworkUrls: {
react: dynamicImports.get('react'),
'react-dom': dynamicImports.get('react-dom'),
},
})})`,
wrapperImport: `import {__preact_${kind}} from '${internalImport('render/preact.js')}';`,
};
}
return {
wrapper: `__react_static(${name})`,
wrapperImport: `import {__react_static} from '${internalImport('render/react.js')}';`,
};
}
case 'svelte': {
if (kind === 'dynamic') {
if (['load', 'idle', 'visible'].includes(kind)) {
return {
wrapper: `__svelte_dynamic(${name}, new URL(${JSON.stringify(url.replace(/\.[^.]+$/, '.svelte.js'))}, \`http://TEST\${import.meta.url}\`).pathname)`,
wrapperImport: `import {__svelte_dynamic} from '${internalImport('render/svelte.js')}';`,
};
} else {
return {
wrapper: `__svelte_static(${name})`,
wrapperImport: `import {__svelte_static} from '${internalImport('render/svelte.js')}';`,
wrapper: `__svelte_${kind}(${name}, ${JSON.stringify({
componentUrl: getComponentUrl('.svelte.js'),
componentExport: 'default',
})})`,
wrapperImport: `import {__svelte_${kind}} from '${internalImport('render/svelte.js')}';`,
};
}
return {
wrapper: `__svelte_static(${name})`,
wrapperImport: `import {__svelte_static} from '${internalImport('render/svelte.js')}';`,
};
}
case 'vue': {
if (kind === 'dynamic') {
if (['load', 'idle', 'visible'].includes(kind)) {
return {
wrapper: `__vue_dynamic(${name}, new URL(${JSON.stringify(url.replace(/\.[^.]+$/, '.vue.js'))}, \`http://TEST\${import.meta.url}\`).pathname, '${dynamicImports.get(
'vue'
)!}')`,
wrapperImport: `import {__vue_dynamic} from '${internalImport('render/vue.js')}';`,
};
} else {
return {
wrapper: `__vue_static(${name})`,
wrapperImport: `
import {__vue_static} from '${internalImport('render/vue.js')}';
`,
wrapper: `__vue_${kind}(${name}, ${JSON.stringify({
componentUrl: getComponentUrl('.vue.js'),
componentExport: 'default',
frameworkUrls: {
vue: dynamicImports.get('vue'),
},
})})`,
wrapperImport: `import {__vue_${kind}} from '${internalImport('render/vue.js')}';`,
};
}
return {
wrapper: `__vue_static(${name})`,
wrapperImport: `import {__vue_static} from '${internalImport('render/vue.js')}';`,
};
}
default: {
throw new Error(`Unknown component type`);

View file

@ -1,30 +1,25 @@
import renderToString from 'preact-render-to-string';
import { Renderer, createRenderer } from './renderer';
import { h, render } from 'preact';
import type { Component } from 'preact';
import { renderToString } from 'preact-render-to-string';
// This prevents tree-shaking of render.
Function.prototype(render);
export function __preact_static(PreactComponent: Component) {
return (attrs: Record<string, any>, ...children: any): string => {
let html = renderToString(
h(
PreactComponent as any, // Preact's types seem wrong...
attrs,
children
)
);
return html;
};
}
const Preact: Renderer = {
renderStatic(Component) {
return (props, ...children) => renderToString(h(Component, props, ...children));
},
imports: {
preact: ['render', 'h'],
},
render({ Component, root, props }) {
return `render(h(${Component}, ${props}), ${root})`;
},
};
export function __preact_dynamic(PreactComponent: Component, importUrl: string, preactUrl: string) {
const placeholderId = `placeholder_${String(Math.random())}`;
return (attrs: Record<string, string>, ...children: any) => {
return `<div id="${placeholderId}"></div><script type="module">
import {h, render} from '${preactUrl}';
import Component from '${importUrl}';
render(h(Component, ${JSON.stringify(attrs)}), document.getElementById('${placeholderId}'));
</script>`;
};
}
const renderer = createRenderer(Preact);
export const __preact_static = renderer.static;
export const __preact_load = renderer.load;
export const __preact_idle = renderer.idle;
export const __preact_visible = renderer.visible;

View file

@ -1,22 +1,23 @@
import { Renderer, createRenderer } from './renderer';
import React from 'react';
import ReactDOMServer from 'react-dom/server';
export function __react_static(ReactComponent: any) {
return (attrs: Record<string, any>, ...children: any): string => {
let html = ReactDOMServer.renderToString(React.createElement(ReactComponent, attrs, children));
return html;
};
}
const ReactRenderer: Renderer = {
renderStatic(Component) {
return (props, ...children) => ReactDOMServer.renderToString(React.createElement(Component, props, children));
},
imports: {
react: ['default as React'],
'react-dom': ['default as ReactDOM'],
},
render({ Component, root, props }) {
return `ReactDOM.render(React.createElement(${Component}, ${props}), ${root})`;
},
};
export function __react_dynamic(ReactComponent: any, importUrl: string, reactUrl: string, reactDomUrl: string) {
const placeholderId = `placeholder_${String(Math.random())}`;
return (attrs: Record<string, string>, ...children: any) => {
return `<div id="${placeholderId}"></div><script type="module">
import React from '${reactUrl}';
import ReactDOM from '${reactDomUrl}';
import Component from '${importUrl}';
const renderer = createRenderer(ReactRenderer);
ReactDOM.render(React.createElement(Component, ${JSON.stringify(attrs)}), document.getElementById('${placeholderId}'));
</script>`;
};
}
export const __react_static = renderer.static;
export const __react_load = renderer.load;
export const __react_idle = renderer.idle;
export const __react_visible = renderer.visible;

View file

@ -0,0 +1,63 @@
interface DynamicRenderContext {
componentUrl: string;
componentExport: string;
frameworkUrls: string;
}
export interface Renderer {
renderStatic(Component: any): (props: Record<string, string>, ...children: any[]) => string;
render(context: { root: string; Component: string; props: string; [key: string]: string }): string;
imports?: Record<string, string[]>;
}
export function createRenderer(renderer: Renderer) {
const _static: Renderer['renderStatic'] = (Component: any) => renderer.renderStatic(Component);
const _imports = (context: DynamicRenderContext) => {
const values = Object.values(renderer.imports ?? {})
.reduce((acc, values) => {
return [...acc, `{ ${values.join(', ')} }`];
}, [])
.join(', ');
const libs = Object.keys(renderer.imports ?? {})
.reduce((acc: string[], lib: string) => {
return [...acc, `import("${context.frameworkUrls[lib as any]}")`];
}, [])
.join(',');
return `const [{${context.componentExport}: Component}, ${values}] = await Promise.all([import(${context.componentUrl})${renderer.imports ? ', ' + libs : ''}]);`;
};
const serializeProps = (props: Record<string, any>) => JSON.stringify(props);
const createContext = () => {
const astroId = `${Math.floor(Math.random() * 1e16)}`;
return { ['data-astro-id']: astroId, root: `document.querySelector('[data-astro-id="${astroId}"]')`, Component: 'Component' };
};
const createDynamicRender = (
wrapperStart: string | ((context: ReturnType<typeof createContext>) => string),
wrapperEnd: string | ((context: ReturnType<typeof createContext>) => string)
) => (Component: any, renderContext: DynamicRenderContext) => {
const innerContext = createContext();
return (props: Record<string, any>, ...children: any[]) => {
let value: string;
try {
value = _static(Component)(props, ...children);
} catch (e) {
value = '';
}
value = `<div style="display:contents;" data-astro-id="${innerContext['data-astro-id']}">${value}</div>`;
return `${value}\n<script type="module">${typeof wrapperStart === 'function' ? wrapperStart(innerContext) : wrapperStart}\n${_imports(renderContext)}\n${renderer.render({
...innerContext,
props: serializeProps(props),
})}\n${typeof wrapperEnd === 'function' ? wrapperEnd(innerContext) : wrapperEnd}</script>`;
};
};
return {
static: _static,
load: createDynamicRender('(async () => {', '})()'),
idle: createDynamicRender('requestIdleCallback(async () => {', '})'),
visible: createDynamicRender(
'const o = new IntersectionObserver(async ([entry]) => { if (!entry.isIntersection) { return; } o.disconnect();',
({ root }) => `}); o.observe(${root})`
),
};
}

View file

@ -1,24 +1,23 @@
import { SvelteComponent as Component } from 'svelte';
import { Renderer, createRenderer } from './renderer';
export function __svelte_static(SvelteComponent: Component) {
return (attrs: Record<string, any>, ...children: any): string => {
// TODO include head and css stuff too...
const { html } = SvelteComponent.render(attrs);
const SvelteRenderer: Renderer = {
renderStatic(Component) {
return (props, ...children) => {
const { html } = Component.render(props);
return html;
};
},
render({ Component, root, props }) {
return `new ${Component}({
target: ${root},
props: ${props}
})`;
},
};
return html;
};
}
const renderer = createRenderer(SvelteRenderer);
export function __svelte_dynamic(SvelteComponent: Component, importUrl: string) {
const placeholderId = `placeholder_${String(Math.random())}`;
return (attrs: Record<string, string>, ...children: any) => {
return `<div id="${placeholderId}"></div><script type="module">
import Component from '${importUrl}';
new Component({
target: document.getElementById('${placeholderId}'),
props: ${JSON.stringify(attrs)}
});
</script>`;
};
}
export const __svelte_static = renderer.static;
export const __svelte_load = renderer.load;
export const __svelte_idle = renderer.idle;
export const __svelte_visible = renderer.visible;

View file

@ -1,39 +1,34 @@
import type { Component } from 'vue';
import { renderToString } from '@vue/server-renderer';
import { createSSRApp, h as createElement } from 'vue';
import { Renderer, createRenderer } from './renderer';
export function __vue_static(VueComponent: Component) {
return async (attrs: Record<string, any>, ...children: any): Promise<string> => {
const app = createSSRApp({
components: {
VueComponent,
},
render() {
return createElement(VueComponent as any, attrs);
},
});
const Vue: Renderer = {
renderStatic(Component) {
return (props, ...children) => {
const app = createSSRApp({
components: {
Component,
},
render() {
return createElement(Component as any, props);
},
});
// Uh oh, Vue's `renderToString` is async... Does that mean everything needs to be?
return renderToString(app) as any;
};
},
imports: {
vue: ['createApp', 'h as createElement'],
},
render({ Component, root, props }) {
return `const App = { render() { return createElement(${Component}, ${props} )} };
createApp(App).mount(${root})`;
},
};
const html = await renderToString(app);
const renderer = createRenderer(Vue);
return html;
};
}
export function __vue_dynamic(VueComponent: Component, importUrl: string, vueUrl: string) {
const placeholderId = `placeholder_${String(Math.random())}`;
return (attrs: Record<string, string>, ...children: any) => {
return `<div id="${placeholderId}"></div><script type="module">
import Component from '${importUrl}';
import {createApp, h as createElement} from '${vueUrl}';
const App = {
render() {
return createElement(Component, ${JSON.stringify(attrs)});
}
};
createApp(App).mount(document.getElementById('${placeholderId}'));
</script>`;
};
}
export const __vue_static = renderer.static;
export const __vue_load = renderer.load;
export const __vue_idle = renderer.idle;
export const __vue_visible = renderer.visible;