Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Experimental partial hydration #170

Open
wants to merge 2 commits into
base: experimental-ssr-1
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions demo/plugins/ssr/ssr-environment.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,10 +103,10 @@ function finish({ url, res }, result) {
}
res.setHeader('Link', scripts.map(script => `<${script.url}>;rel=preload;as=script;crossorigin`).join(', '));

for (const script of scripts) {
// head += `<link rel="preload" as="script" href="${script.url}" crossorigin>`;
body += `<script type="module" src="${script.url}"></script>`;
}
// for (const script of scripts) {
// // head += `<link rel="preload" as="script" href="${script.url}" crossorigin>`;
// body += `<script type="module" src="${script.url}"></script>`;
// }

if (/<\/head>/i.test(result)) result = result.replace(/(<\/head>)/i, head + '$1');
else result = head + result;
Expand Down
File renamed without changes.
58 changes: 58 additions & 0 deletions demo/public/document.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { App } from './app';
import { Hydratable } from './hydrateable';
import { HydrationContextProvider, useHydrationRegistrations } from './with-hydration';

function importAlias(componentId: string): string {
return `Component_${componentId}`;
}

function HydrationScripts() {
const components = useHydrationRegistrations();

if (!components || components.length === 0) {
return null;
}

return (
<script
type="module"
dangerouslySetInnerHTML={{
__html: `
import { hydrate } from '/hydrate.tsx';${components
.map(
({ specifier, script, componentId }, i) => `
import ${
specifier === 'default'
? importAlias(componentId)
: `{ ${specifier} as ${importAlias(componentId)} } from '${script}';`
}`
)
.join('')}

hydrate({${components
.map(
({ specifier, script, componentId }, i) => `
'${componentId}': ${importAlias(componentId)},`
)
.join('')}
});
`
}}
/>
);
}

export function Document({ req }) {
return (
<HydrationContextProvider req={req}>
<html>
<head></head>
<body>
<App />
<Hydratable foo="bar" baz="bal" />
<HydrationScripts />
</body>
</html>
</HydrationContextProvider>
);
}
30 changes: 30 additions & 0 deletions demo/public/hydrate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { h, hydrate as preactHydrate } from 'preact';

export function hydrate(components: Record<string, any>) {
Array.from(document.querySelectorAll('script[type="application/hydrate"]')).forEach((startEl: HTMLElement) => {
const props = JSON.parse(startEl.innerHTML);
const endEl = document.querySelector(
'script[type="application/hydrate-end"][data-hydration-instance-id="' + startEl.dataset.hydrationInstanceId + '"]'
);

if (!endEl) {
return;
}

const childNodes: ChildNode[] = [];
let currentNode = startEl.nextSibling;
while (currentNode != null && currentNode !== endEl) {
childNodes.push(currentNode);
currentNode = currentNode.nextSibling;
}

preactHydrate(h(components[startEl.dataset.hydrationComponentId!], props), {
// @ts-expect-error
childNodes,
// @ts-expect-error
appendChild: function (c) {
startEl.parentNode?.insertBefore?.(c, endEl);
}
});
});
}
25 changes: 25 additions & 0 deletions demo/public/hydrateable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { h } from 'preact';
import { useState } from 'preact/hooks';
import { withHydration } from './with-hydration';

export const Hydratable = withHydration({ specifier: 'Hydratable', importUrl: import.meta.url })(
function HydratableComp(props) {
const [count, setCount] = useState(0);
return (
<div>
<p>Hydrated Counter:</p>
<div style={{ display: 'flex' }}>
<button type="button" onClick={() => setCount(count - 1)}>
-
</button>
Count: {count}
<button type="button" onClick={() => setCount(count + 1)}>
+
</button>
</div>
<p>SSR: {typeof document === 'undefined' ? 'yes' : 'no'}</p>
<p>Props in component: {JSON.stringify(props, null, 2)} </p>
</div>
);
}
);
25 changes: 25 additions & 0 deletions demo/public/hydrateable2.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { h } from 'preact';
import { useState } from 'preact/hooks';
import { withHydration } from './with-hydration';

export const Hydratable = withHydration({ specifier: 'Hydratable', importUrl: import.meta.url })(
function HydratableComp(props) {
const [count, setCount] = useState(0);
return (
<div>
<p>Another Hydratable only on About:</p>
<div style={{ display: 'flex' }}>
<button type="button" onClick={() => setCount(count - 1)}>
-
</button>
Count: {count}
<button type="button" onClick={() => setCount(count + 1)}>
+
</button>
</div>
<p>SSR: {typeof document === 'undefined' ? 'yes' : 'no'}</p>
<p>Props in component: {JSON.stringify(props, null, 2)} </p>
</div>
);
}
);
22 changes: 0 additions & 22 deletions demo/public/index.html

This file was deleted.

2 changes: 2 additions & 0 deletions demo/public/pages/about/index.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { Hydratable } from '../../hydrateable2';
import styles from './style.module.css';

const About = ({ query }) => (
<section class={styles.about}>
<h1>About</h1>
<p>My name is Jason.</p>
<pre>{JSON.stringify(query)}</pre>
<Hydratable query={query} />
</section>
);

Expand Down
34 changes: 6 additions & 28 deletions demo/public/ssr.js
Original file line number Diff line number Diff line change
@@ -1,31 +1,9 @@
import { promises as fs } from 'fs';
import renderToString from 'preact-render-to-string';
import prepass from 'preact-ssr-prepass';

async function prepass(vnode, maxDepth = 20, maxTime = 5000) {
let attempts = 0;
const start = Date.now();
while (++attempts < maxDepth && Date.now() - start < maxTime) {
try {
return renderToString(vnode);
} catch (e) {
if (e && e.then) {
await e;
continue;
}
throw e;
}
}
}

export async function ssr({ url }) {
const { App } = await import('./index.tsx');
let body = await prepass(<App />, 20, 5000);
const html = await fs.readFile('./public/index.html', 'utf-8');
// body = html.replace(/(<div id="app_root">)<\/div>/, '$1' + body + '</div>');
if (/<body(?:\s[^>]*?)?>/.test(html)) {
body = html.replace(/(<body(?:\s[^>]*?)?)>/, '$1 ssr>' + body);
} else {
body = html + body;
}
return body;
export async function ssr(req) {
const { Document } = await import('./document.tsx');
const vnode = <Document req={req} />;
await prepass(vnode);
return renderToString(vnode, {}, { pretty: true });
}
82 changes: 82 additions & 0 deletions demo/public/with-hydration.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { h, createContext } from 'preact';
import { useContext, useMemo } from 'preact/hooks';

let id = 0;
function uuid() {
return String(id++);
}

interface ComponentRegistration {
script: string;
specifier: string;
componentId: string;
}

let hydrationComponentIdCounter = 0;

interface HydrationContext {
registerComponent(component: ComponentRegistration): void;
getComponents(): ComponentRegistration[];
}

export function HydrationContextProvider({ req, children }) {
const hydrationContextValue = useMemo(() => {
const components: ComponentRegistration[] = [];

return {
registerComponent(comp: ComponentRegistration) {
if (components.find(c => c.componentId === comp.componentId)) {
return;
}

components.push(comp);
},
getComponents() {
return components;
}
};
}, [req]);

return <hydrationContext.Provider value={hydrationContextValue}>{children}</hydrationContext.Provider>;
}

const hydrationContext = createContext<HydrationContext | null>(null);

export function useHydrationRegistrations() {
return useContext(hydrationContext)?.getComponents();
}

export function withHydration({ specifier, importUrl }: { specifier: string; importUrl: string }) {
const componentId = String(hydrationComponentIdCounter++);

return function (Component) {
return function HydrateableComponent(props) {
if (typeof document !== 'undefined') {
return <Component {...props} />;
}

const ctx = useContext(hydrationContext);

ctx?.registerComponent?.({
script: importUrl.replace('http://0.0.0.0:8080', ''),
specifier,
componentId
});

const instanceId = uuid();

return (
<>
<script
type="application/hydrate"
data-hydration-component-id={componentId}
data-hydration-instance-id={instanceId}
dangerouslySetInnerHTML={{ __html: JSON.stringify(props) }}
/>
<Component {...props} />
<script type="application/hydrate-end" data-hydration-instance-id={instanceId} />
</>
);
};
};
}