Skip to content

Commit

Permalink
experimental: waku islands (#1166)
Browse files Browse the repository at this point in the history
- This is an idea for #816.
- It's still work in progress.
- It's based on minimal API + something
- There's no public API (not the goal of this PR)
  • Loading branch information
dai-shi authored Jan 21, 2025
1 parent 86915d7 commit 99ad068
Show file tree
Hide file tree
Showing 10 changed files with 246 additions and 11 deletions.
22 changes: 22 additions & 0 deletions examples/53_islands/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"name": "53_islands",
"version": "0.1.0",
"type": "module",
"private": true,
"scripts": {
"dev": "waku dev",
"build": "waku build",
"start": "waku start"
},
"dependencies": {
"react": "19.0.0",
"react-dom": "19.0.0",
"react-server-dom-webpack": "19.0.0",
"waku": "0.21.16"
},
"devDependencies": {
"@types/react": "19.0.7",
"@types/react-dom": "19.0.3",
"typescript": "5.7.3"
}
}
34 changes: 34 additions & 0 deletions examples/53_islands/src/components/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Slot } from 'waku/minimal/client';

import { Counter } from './Counter';

const App = ({ name }: { name: string }) => {
return (
<html>
<head>
<title>Waku</title>
</head>
<body>
<div
style={{ border: '3px red dashed', margin: '1em', padding: '1em' }}
>
<h1>Hello {name}!!</h1>
<h3>This is a static server component.</h3>
<Slot id="Dynamic" unstable_fallback={<p>Loading...</p>}>
<MyCounter />
</Slot>
<div>{new Date().toISOString()}</div>
</div>
</body>
</html>
);
};

const MyCounter = () => (
<>
<h4>Counter</h4>
<Counter />
</>
);

export default App;
14 changes: 14 additions & 0 deletions examples/53_islands/src/components/Counter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
'use client';

import { useState } from 'react';

export const Counter = () => {
const [count, setCount] = useState(0);
return (
<div style={{ border: '3px blue dashed', margin: '1em', padding: '1em' }}>
<p>Count: {count}</p>
<button onClick={() => setCount((c) => c + 1)}>Increment</button>
<h3>This is a client component.</h3>
</div>
);
};
14 changes: 14 additions & 0 deletions examples/53_islands/src/components/Dynamic.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { ReactNode } from 'react';

const Dynamic = async ({ children }: { children: ReactNode }) => {
await new Promise((resolve) => setTimeout(resolve, 1000));
return (
<div style={{ border: '3px orange dashed', margin: '1em', padding: '1em' }}>
<h3>This is a dynamic server component.</h3>
{children}
<div>{new Date().toISOString()}</div>
</div>
);
};

export default Dynamic;
66 changes: 66 additions & 0 deletions examples/53_islands/src/entries.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { unstable_defineEntries as defineEntries } from 'waku/minimal/server';
import { Children, Slot } from 'waku/minimal/client';
import { unstable_createAsyncIterable as createAsyncIterable } from 'waku/server';

import App from './components/App';
import Dynamic from './components/Dynamic';

export default defineEntries({
handleRequest: async (input, { renderRsc, renderHtml }) => {
if (input.type === 'component') {
if (input.rscPath === '') {
return renderRsc({
App: <App name={input.rscPath || 'Waku'} />,
});
}
if (input.rscPath === 'dynamic') {
return renderRsc({
Dynamic: (
<Dynamic>
<Children />
</Dynamic>
),
});
}
throw new Error('Unexpected rscPath: ' + input.rscPath);
}
if (input.type === 'custom' && input.pathname === '/') {
return renderHtml({ App: <App name="Waku" /> }, <Slot id="App" />, {
rscPath: '',
});
}
},
handleBuild: ({
renderRsc,
renderHtml,
rscPath2pathname,
unstable_generatePrefetchCode,
}) =>
createAsyncIterable(async () => {
const moduleIds = new Set<string>();
const generateHtmlHead = () =>
`<script type="module" async>${unstable_generatePrefetchCode(
['dynamic'],
moduleIds,
)}</script>`;
const tasks = [
async () => ({
type: 'file' as const,
pathname: rscPath2pathname(''),
body: renderRsc(
{ App: <App name="Waku" /> },
{ moduleIdCallback: (id) => moduleIds.add(id) },
),
}),
async () => ({
type: 'file' as const,
pathname: '/',
body: renderHtml({ App: <App name="Waku" /> }, <Slot id="App" />, {
rscPath: '',
htmlHead: generateHtmlHead(),
}).then(({ body }) => body),
}),
];
return tasks;
}),
});
26 changes: 26 additions & 0 deletions examples/53_islands/src/main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { StrictMode, useEffect } from 'react';
import { createRoot, hydrateRoot } from 'react-dom/client';
import { Root, Slot, useRefetch } from 'waku/minimal/client';

const DynamicFether = () => {
const refetch = useRefetch();
useEffect(() => {
refetch('dynamic');
}, [refetch]);
return null;
};

const rootElement = (
<StrictMode>
<Root>
<DynamicFether />
<Slot id="App" />
</Root>
</StrictMode>
);

if ((globalThis as any).__WAKU_HYDRATE__) {
hydrateRoot(document, rootElement);
} else {
createRoot(document as any).render(rootElement);
}
15 changes: 15 additions & 0 deletions examples/53_islands/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"strict": true,
"target": "esnext",
"downlevelIteration": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"skipLibCheck": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"types": ["react/experimental"],
"jsx": "react-jsx"
}
}
7 changes: 7 additions & 0 deletions packages/waku/src/lib/renderers/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,16 @@ const injectHtmlHead = (
/(.*<script[^>]*>\nglobalThis\.__WAKU_PREFETCHED__ = {\n)(.*?)(\n};.*)/s,
);
if (matchPrefetched) {
// HACK This is very brittle
// TODO(daishi) find a better way
const removed = matchPrefetched[2]!.replace(
new RegExp(` '${urlForFakeFetch}': .*?,`),
'',
);
head =
matchPrefetched[1] +
` '${urlForFakeFetch}': ${fakeFetchCode},` +
removed +
matchPrefetched[3];
}
let code = `
Expand Down
26 changes: 19 additions & 7 deletions packages/waku/src/minimal/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ export const Root = ({
);
useEffect(() => {
fetchCache[SET_ELEMENTS] = setElements;
}, [fetchCache, setElements]);
}, [fetchCache]);
const refetch = useCallback(
(rscPath: string, rscParams?: unknown) => {
// clear cache entry before fetching
Expand Down Expand Up @@ -246,22 +246,28 @@ const InnerSlot = ({
elementsPromise,
children,
setFallback,
unstable_fallback,
}: {
id: string;
elementsPromise: Elements;
children?: ReactNode;
setFallback?: (fallback: ReactNode) => void;
unstable_fallback?: ReactNode;
}) => {
const elements = use(elementsPromise);
if (!(id in elements)) {
throw new Error('No such element: ' + id);
}
const hasElement = id in elements;
const element = elements[id];
useEffect(() => {
if (setFallback) {
if (hasElement && setFallback) {
setFallback(element);
}
}, [element, setFallback]);
}, [hasElement, element, setFallback]);
if (!hasElement) {
if (unstable_fallback) {
return unstable_fallback;
}
throw new Error('No such element: ' + id);
}
return createElement(ChildrenContextProvider, { value: children }, element);
};

Expand Down Expand Up @@ -313,10 +319,12 @@ export const Slot = ({
id,
children,
unstable_fallbackToPrev,
unstable_fallback,
}: {
id: string;
children?: ReactNode;
unstable_fallbackToPrev?: boolean;
unstable_fallback?: ReactNode;
}) => {
const [fallback, setFallback] = useState<ReactNode>();
const elementsPromise = use(ElementsContext);
Expand All @@ -330,7 +338,11 @@ export const Slot = ({
createElement(InnerSlot, { id, elementsPromise, setFallback }, children),
);
}
return createElement(InnerSlot, { id, elementsPromise }, children);
return createElement(
InnerSlot,
{ id, elementsPromise, unstable_fallback },
children,
);
};

export const Children = () => use(ChildrenContext);
Expand Down
33 changes: 29 additions & 4 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 99ad068

Please sign in to comment.