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 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 ## 🧞 Development
Add a `dev` npm script to your `/package.json` file: Add a `dev` npm script to your `/package.json` file:

View file

@ -72,10 +72,7 @@ let communityGuides;
</h3> </h3>
<div class="card-grid card-grid-4"> <div class="card-grid card-grid-4">
{communityGuides.map((post) => { {communityGuides.map((post) => <Card item={post} />)}
return
<Card item={post} />;
})}
<Card item={{ <Card item={{
url: 'https://www.snowpack.dev/posts/2021-01-13-snowpack-3-0', url: 'https://www.snowpack.dev/posts/2021-01-13-snowpack-3-0',
img: 'https://www.snowpack.dev/img/social-snowpackv3.jpg', 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> working on!</div>
</article> </article>
{news.reverse().map((item: any) => {news.reverse().map((item: any) => <Card:idle item={item} />)}
<Card:dynamic item={item} />)}
</div> </div>
<div class="content"> <div class="content">

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> <a href="/reference/plugins">Creating your own plugin is easy!</a>
</p> </p>
<PluginSearchPage:dynamic /> <div style="margin-top:100vh;"></div>
<PluginSearchPage:load />
</MainLayout> </MainLayout>
</body> </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}`); 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) { switch (plugin) {
case 'astro': { case 'astro': {
if (kind) { if (kind) {
@ -131,65 +133,79 @@ function getComponentWrapper(_name: string, { type, plugin, url }: ComponentInfo
}; };
} }
case 'preact': { case 'preact': {
if (kind === 'dynamic') { if (['load', 'idle', 'visible'].includes(kind)) {
return { return {
wrapper: `__preact_dynamic(${name}, new URL(${JSON.stringify(url.replace(/\.[^.]+$/, '.js'))}, \`http://TEST\${import.meta.url}\`).pathname, '${dynamicImports.get( wrapper: `__preact_${kind}(${name}, ${JSON.stringify({
'preact' componentUrl: getComponentUrl(),
)!}')`, componentExport: 'default',
wrapperImport: `import {__preact_dynamic} from '${internalImport('render/preact.js')}';`, frameworkUrls: {
preact: dynamicImports.get('preact'),
},
})})`,
wrapperImport: `import {__preact_${kind}} from '${internalImport('render/preact.js')}';`,
}; };
} else { }
return { return {
wrapper: `__preact_static(${name})`, wrapper: `__preact_static(${name})`,
wrapperImport: `import {__preact_static} from '${internalImport('render/preact.js')}';`, wrapperImport: `import {__preact_static} from '${internalImport('render/preact.js')}';`,
}; };
} }
}
case 'react': { case 'react': {
if (kind === 'dynamic') { if (['load', 'idle', 'visible'].includes(kind)) {
return { return {
wrapper: `__react_dynamic(${name}, new URL(${JSON.stringify(url.replace(/\.[^.]+$/, '.js'))}, \`http://TEST\${import.meta.url}\`).pathname, '${dynamicImports.get( wrapper: `__preact_${kind}(${name}, ${JSON.stringify({
'react' componentUrl: getComponentUrl(),
)!}', '${dynamicImports.get('react-dom')!}')`, componentExport: 'default',
wrapperImport: `import {__react_dynamic} from '${internalImport('render/react.js')}';`, frameworkUrls: {
react: dynamicImports.get('react'),
'react-dom': dynamicImports.get('react-dom'),
},
})})`,
wrapperImport: `import {__preact_${kind}} from '${internalImport('render/preact.js')}';`,
}; };
} else { }
return { return {
wrapper: `__react_static(${name})`, wrapper: `__react_static(${name})`,
wrapperImport: `import {__react_static} from '${internalImport('render/react.js')}';`, wrapperImport: `import {__react_static} from '${internalImport('render/react.js')}';`,
}; };
} }
}
case 'svelte': { case 'svelte': {
if (kind === 'dynamic') { if (['load', 'idle', 'visible'].includes(kind)) {
return { return {
wrapper: `__svelte_dynamic(${name}, new URL(${JSON.stringify(url.replace(/\.[^.]+$/, '.svelte.js'))}, \`http://TEST\${import.meta.url}\`).pathname)`, wrapper: `__svelte_${kind}(${name}, ${JSON.stringify({
wrapperImport: `import {__svelte_dynamic} from '${internalImport('render/svelte.js')}';`, componentUrl: getComponentUrl('.svelte.js'),
componentExport: 'default',
})})`,
wrapperImport: `import {__svelte_${kind}} from '${internalImport('render/svelte.js')}';`,
}; };
} else { }
return { return {
wrapper: `__svelte_static(${name})`, wrapper: `__svelte_static(${name})`,
wrapperImport: `import {__svelte_static} from '${internalImport('render/svelte.js')}';`, wrapperImport: `import {__svelte_static} from '${internalImport('render/svelte.js')}';`,
}; };
} }
}
case 'vue': { case 'vue': {
if (kind === 'dynamic') { if (['load', 'idle', 'visible'].includes(kind)) {
return { return {
wrapper: `__vue_dynamic(${name}, new URL(${JSON.stringify(url.replace(/\.[^.]+$/, '.vue.js'))}, \`http://TEST\${import.meta.url}\`).pathname, '${dynamicImports.get( wrapper: `__vue_${kind}(${name}, ${JSON.stringify({
'vue' componentUrl: getComponentUrl('.vue.js'),
)!}')`, componentExport: 'default',
wrapperImport: `import {__vue_dynamic} from '${internalImport('render/vue.js')}';`, frameworkUrls: {
vue: dynamicImports.get('vue'),
},
})})`,
wrapperImport: `import {__vue_${kind}} from '${internalImport('render/vue.js')}';`,
}; };
} else { }
return { return {
wrapper: `__vue_static(${name})`, wrapper: `__vue_static(${name})`,
wrapperImport: ` wrapperImport: `import {__vue_static} from '${internalImport('render/vue.js')}';`,
import {__vue_static} from '${internalImport('render/vue.js')}';
`,
}; };
} }
}
default: { default: {
throw new Error(`Unknown component type`); 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 { h, render } from 'preact';
import type { Component } from 'preact'; import { renderToString } from 'preact-render-to-string';
// This prevents tree-shaking of render. // This prevents tree-shaking of render.
Function.prototype(render); Function.prototype(render);
export function __preact_static(PreactComponent: Component) { const Preact: Renderer = {
return (attrs: Record<string, any>, ...children: any): string => { renderStatic(Component) {
let html = renderToString( return (props, ...children) => renderToString(h(Component, props, ...children));
h( },
PreactComponent as any, // Preact's types seem wrong... imports: {
attrs, preact: ['render', 'h'],
children },
) render({ Component, root, props }) {
); return `render(h(${Component}, ${props}), ${root})`;
return html; },
}; };
}
export function __preact_dynamic(PreactComponent: Component, importUrl: string, preactUrl: string) { const renderer = createRenderer(Preact);
const placeholderId = `placeholder_${String(Math.random())}`;
return (attrs: Record<string, string>, ...children: any) => { export const __preact_static = renderer.static;
return `<div id="${placeholderId}"></div><script type="module"> export const __preact_load = renderer.load;
import {h, render} from '${preactUrl}'; export const __preact_idle = renderer.idle;
import Component from '${importUrl}'; export const __preact_visible = renderer.visible;
render(h(Component, ${JSON.stringify(attrs)}), document.getElementById('${placeholderId}'));
</script>`;
};
}

View file

@ -1,22 +1,23 @@
import { Renderer, createRenderer } from './renderer';
import React from 'react'; import React from 'react';
import ReactDOMServer from 'react-dom/server'; import ReactDOMServer from 'react-dom/server';
export function __react_static(ReactComponent: any) { const ReactRenderer: Renderer = {
return (attrs: Record<string, any>, ...children: any): string => { renderStatic(Component) {
let html = ReactDOMServer.renderToString(React.createElement(ReactComponent, attrs, children)); return (props, ...children) => ReactDOMServer.renderToString(React.createElement(Component, props, children));
return html; },
}; 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 renderer = createRenderer(ReactRenderer);
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}';
ReactDOM.render(React.createElement(Component, ${JSON.stringify(attrs)}), document.getElementById('${placeholderId}')); export const __react_static = renderer.static;
</script>`; 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; return html;
}; };
} },
render({ Component, root, props }) {
return `new ${Component}({
target: ${root},
props: ${props}
})`;
},
};
export function __svelte_dynamic(SvelteComponent: Component, importUrl: string) { const renderer = createRenderer(SvelteRenderer);
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({ export const __svelte_static = renderer.static;
target: document.getElementById('${placeholderId}'), export const __svelte_load = renderer.load;
props: ${JSON.stringify(attrs)} export const __svelte_idle = renderer.idle;
}); export const __svelte_visible = renderer.visible;
</script>`;
};
}

View file

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