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:
parent
202973291f
commit
9ab1f52a1c
10 changed files with 219 additions and 144 deletions
|
@ -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:
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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,65 +133,79 @@ 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')}';`,
|
||||
wrapper: `__preact_${kind}(${name}, ${JSON.stringify({
|
||||
componentUrl: getComponentUrl(),
|
||||
componentExport: 'default',
|
||||
frameworkUrls: {
|
||||
preact: dynamicImports.get('preact'),
|
||||
},
|
||||
})})`,
|
||||
wrapperImport: `import {__preact_${kind}} from '${internalImport('render/preact.js')}';`,
|
||||
};
|
||||
} else {
|
||||
}
|
||||
|
||||
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')}';`,
|
||||
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')}';`,
|
||||
};
|
||||
} else {
|
||||
}
|
||||
|
||||
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')}';`,
|
||||
wrapper: `__svelte_${kind}(${name}, ${JSON.stringify({
|
||||
componentUrl: getComponentUrl('.svelte.js'),
|
||||
componentExport: 'default',
|
||||
})})`,
|
||||
wrapperImport: `import {__svelte_${kind}} from '${internalImport('render/svelte.js')}';`,
|
||||
};
|
||||
} else {
|
||||
}
|
||||
|
||||
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')}';`,
|
||||
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')}';`,
|
||||
};
|
||||
} else {
|
||||
}
|
||||
|
||||
return {
|
||||
wrapper: `__vue_static(${name})`,
|
||||
wrapperImport: `
|
||||
import {__vue_static} from '${internalImport('render/vue.js')}';
|
||||
`,
|
||||
wrapperImport: `import {__vue_static} from '${internalImport('render/vue.js')}';`,
|
||||
};
|
||||
}
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Unknown component type`);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
63
src/frontend/render/renderer.ts
Normal file
63
src/frontend/render/renderer.ts
Normal 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})`
|
||||
),
|
||||
};
|
||||
}
|
|
@ -1,24 +1,23 @@
|
|||
import { SvelteComponent as Component } from 'svelte';
|
||||
|
||||
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);
|
||||
import { Renderer, createRenderer } from './renderer';
|
||||
|
||||
const SvelteRenderer: Renderer = {
|
||||
renderStatic(Component) {
|
||||
return (props, ...children) => {
|
||||
const { html } = Component.render(props);
|
||||
return html;
|
||||
};
|
||||
}
|
||||
|
||||
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>`;
|
||||
},
|
||||
render({ Component, root, props }) {
|
||||
return `new ${Component}({
|
||||
target: ${root},
|
||||
props: ${props}
|
||||
})`;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const renderer = createRenderer(SvelteRenderer);
|
||||
|
||||
export const __svelte_static = renderer.static;
|
||||
export const __svelte_load = renderer.load;
|
||||
export const __svelte_idle = renderer.idle;
|
||||
export const __svelte_visible = renderer.visible;
|
||||
|
|
|
@ -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 Vue: Renderer = {
|
||||
renderStatic(Component) {
|
||||
return (props, ...children) => {
|
||||
const app = createSSRApp({
|
||||
components: {
|
||||
VueComponent,
|
||||
Component,
|
||||
},
|
||||
render() {
|
||||
return createElement(VueComponent as any, attrs);
|
||||
return createElement(Component as any, props);
|
||||
},
|
||||
});
|
||||
|
||||
const html = await renderToString(app);
|
||||
|
||||
return html;
|
||||
// Uh oh, Vue's `renderToString` is async... Does that mean everything needs to be?
|
||||
return renderToString(app) as any;
|
||||
};
|
||||
}
|
||||
|
||||
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)});
|
||||
}
|
||||
},
|
||||
imports: {
|
||||
vue: ['createApp', 'h as createElement'],
|
||||
},
|
||||
render({ Component, root, props }) {
|
||||
return `const App = { render() { return createElement(${Component}, ${props} )} };
|
||||
createApp(App).mount(${root})`;
|
||||
},
|
||||
};
|
||||
|
||||
createApp(App).mount(document.getElementById('${placeholderId}'));
|
||||
</script>`;
|
||||
};
|
||||
}
|
||||
const renderer = createRenderer(Vue);
|
||||
|
||||
export const __vue_static = renderer.static;
|
||||
export const __vue_load = renderer.load;
|
||||
export const __vue_idle = renderer.idle;
|
||||
export const __vue_visible = renderer.visible;
|
||||
|
|
Loading…
Reference in a new issue