diff --git a/README.md b/README.md index f615c5856..98287d00e 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/examples/snowpack/astro/pages/guides.astro b/examples/snowpack/astro/pages/guides.astro index f3d8d7179..f3571d231 100644 --- a/examples/snowpack/astro/pages/guides.astro +++ b/examples/snowpack/astro/pages/guides.astro @@ -72,10 +72,7 @@ let communityGuides;
- {communityGuides.map((post) => { - return - ; - })} + {communityGuides.map((post) => )} - {news.reverse().map((item: any) => - )} + {news.reverse().map((item: any) => )}
@@ -77,4 +76,4 @@ const description = 'Snowpack community news and companies that use Snowpack.'; - \ No newline at end of file + diff --git a/examples/snowpack/astro/pages/plugins.astro b/examples/snowpack/astro/pages/plugins.astro index 24a5d1bde..faae4a26d 100644 --- a/examples/snowpack/astro/pages/plugins.astro +++ b/examples/snowpack/astro/pages/plugins.astro @@ -65,7 +65,9 @@ let description = 'Snowpack plugins allow for configuration-minimal tooling inte Creating your own plugin is easy!

- +
+ + diff --git a/src/compiler/codegen.ts b/src/compiler/codegen.ts index c0d963703..f42968b1a 100644 --- a/src/compiler/codegen.ts +++ b/src/compiler/codegen.ts @@ -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`); diff --git a/src/frontend/render/preact.ts b/src/frontend/render/preact.ts index 50bb9344e..359202372 100644 --- a/src/frontend/render/preact.ts +++ b/src/frontend/render/preact.ts @@ -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, ...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, ...children: any) => { - return `
`; - }; -} +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; diff --git a/src/frontend/render/react.ts b/src/frontend/render/react.ts index cd037c35f..8b127cf96 100644 --- a/src/frontend/render/react.ts +++ b/src/frontend/render/react.ts @@ -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, ...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, ...children: any) => { - return `
`; - }; -} +export const __react_static = renderer.static; +export const __react_load = renderer.load; +export const __react_idle = renderer.idle; +export const __react_visible = renderer.visible; diff --git a/src/frontend/render/renderer.ts b/src/frontend/render/renderer.ts new file mode 100644 index 000000000..ceb460e40 --- /dev/null +++ b/src/frontend/render/renderer.ts @@ -0,0 +1,63 @@ +interface DynamicRenderContext { + componentUrl: string; + componentExport: string; + frameworkUrls: string; +} + +export interface Renderer { + renderStatic(Component: any): (props: Record, ...children: any[]) => string; + render(context: { root: string; Component: string; props: string; [key: string]: string }): string; + imports?: Record; +} + +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) => 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) => string), + wrapperEnd: string | ((context: ReturnType) => string) + ) => (Component: any, renderContext: DynamicRenderContext) => { + const innerContext = createContext(); + return (props: Record, ...children: any[]) => { + let value: string; + try { + value = _static(Component)(props, ...children); + } catch (e) { + value = ''; + } + value = `
${value}
`; + + return `${value}\n`; + }; + }; + + 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})` + ), + }; +} diff --git a/src/frontend/render/svelte.ts b/src/frontend/render/svelte.ts index 51cb778db..15676e8c6 100644 --- a/src/frontend/render/svelte.ts +++ b/src/frontend/render/svelte.ts @@ -1,24 +1,23 @@ -import { SvelteComponent as Component } from 'svelte'; +import { Renderer, createRenderer } from './renderer'; -export function __svelte_static(SvelteComponent: Component) { - return (attrs: Record, ...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, ...children: any) => { - return `
`; - }; -} +export const __svelte_static = renderer.static; +export const __svelte_load = renderer.load; +export const __svelte_idle = renderer.idle; +export const __svelte_visible = renderer.visible; diff --git a/src/frontend/render/vue.ts b/src/frontend/render/vue.ts index 6b89aa11e..bcf6b70bd 100644 --- a/src/frontend/render/vue.ts +++ b/src/frontend/render/vue.ts @@ -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, ...children: any): Promise => { - 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, ...children: any) => { - return `
`; - }; -} +export const __vue_static = renderer.static; +export const __vue_load = renderer.load; +export const __vue_idle = renderer.idle; +export const __vue_visible = renderer.visible;