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
|
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:
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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`);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>`;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
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';
|
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>`;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
|
@ -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>`;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
Loading…
Reference in a new issue