Skip to content

Commit

Permalink
feat(core): generalize elements (#1199)
Browse files Browse the repository at this point in the history
elements are actually used beside react nodes. For now, we hack with an
array. But, this should support it better, even though it loses some
type checks.
  • Loading branch information
dai-shi authored Jan 29, 2025
1 parent 8b6d148 commit b3fe435
Show file tree
Hide file tree
Showing 10 changed files with 70 additions and 64 deletions.
3 changes: 1 addition & 2 deletions e2e/fixtures/define-router/src/entries.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import type { ReactNode } from 'react';
import { unstable_defineRouter as defineRouter } from 'waku/router/server';
import { Slot, Children } from 'waku/minimal/client';

Expand All @@ -8,7 +7,7 @@ import FooPage from './routes/foo/page.js';
import { readFile } from 'node:fs/promises';

const STATIC_PATHS = ['/', '/foo'];
const PATH_PAGE: Record<string, ReactNode> = {
const PATH_PAGE: Record<string, unknown> = {
'/': <Page />,
'/foo': <FooPage />,
};
Expand Down
3 changes: 1 addition & 2 deletions examples/35_nesting/src/entries.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import type { ReactNode } from 'react';
import { unstable_defineEntries as defineEntries } from 'waku/minimal/server';
import { Slot } from 'waku/minimal/client';
import { unstable_createAsyncIterable as createAsyncIterable } from 'waku/server';
Expand All @@ -13,7 +12,7 @@ export default defineEntries({
const params = new URLSearchParams(
input.rscPath || 'App=Waku&InnerApp=0',
);
const result: Record<string, ReactNode> = {};
const result: Record<string, unknown> = {};
if (params.has('App')) {
result.App = <App name={params.get('App')!} />;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/waku/src/lib/builder/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -571,7 +571,7 @@ const emitStaticFiles = async (
options?.moduleIdCallback,
),
renderHtml: async (
elements: Record<string, ReactNode>,
elements: Record<string, unknown>,
html: ReactNode,
options: { rscPath: string; htmlHead?: string },
) => {
Expand Down
2 changes: 1 addition & 1 deletion packages/waku/src/lib/middleware/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ export const handler: Middleware = (options) => {
renderRsc: (elements: Record<string, unknown>) =>
renderRsc(config, ctx, elements),
renderHtml: async (
elements: Record<string, ReactNode>,
elements: Record<string, unknown>,
html: ReactNode,
options: { rscPath: string; actionResult?: unknown },
) => {
Expand Down
4 changes: 2 additions & 2 deletions packages/waku/src/lib/renderers/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { renderRsc, renderRscElement, getExtractFormState } from './rsc.js';
// TODO move types somewhere
import type { HandlerContext } from '../middleware/types.js';

type Elements = Record<string, ReactNode>;
type Elements = Record<string, unknown>;

const fakeFetchCode = `
Promise.resolve(new Response(new ReadableStream({
Expand Down Expand Up @@ -229,7 +229,7 @@ export async function renderHtml(
ServerRoot as FunctionComponent<
Omit<ComponentProps<typeof ServerRoot>, 'children'>
>,
{ elements: elementsPromise },
{ elementsPromise },
htmlNode as any,
),
{
Expand Down
2 changes: 1 addition & 1 deletion packages/waku/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { ReactNode } from 'react';
import type { Config } from '../config.js';
import type { PathSpec } from '../lib/utils/path.js';

type Elements = Record<string, ReactNode>;
type Elements = Record<string, unknown>;

type RenderRsc<Opts = unknown> = (
elements: Record<string, unknown>,
Expand Down
106 changes: 57 additions & 49 deletions packages/waku/src/minimal/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,43 +49,44 @@ const checkStatus = async (
return response;
};

type Elements = Promise<Record<string, ReactNode>>;
type Elements = Record<string, unknown>;

const getCached = <T>(c: () => T, m: WeakMap<object, T>, k: object): T =>
(m.has(k) ? m : m.set(k, c())).get(k) as T;
const cache1 = new WeakMap();
const mergeElements = (a: Elements, b: Elements): Elements => {
const getResult = () => {
const promise: Elements = new Promise((resolve, reject) => {
Promise.all([a, b])
.then(([a, b]) => {
const nextElements = { ...a, ...b };
delete nextElements._value;
resolve(nextElements);
})
.catch((e) => reject(e));
const mergeElementsPromise = (
a: Promise<Elements>,
b: Promise<Elements>,
): Promise<Elements> => {
const getResult = () =>
Promise.all([a, b]).then(([a, b]) => {
const nextElements = { ...a, ...b };
delete nextElements._value;
return nextElements;
});
return promise;
};
const cache2 = getCached(() => new WeakMap(), cache1, a);
return getCached(getResult, cache2, b);
};

type SetElements = (updater: (prev: Elements) => Elements) => void;
type SetElements = (
updater: (prev: Promise<Elements>) => Promise<Elements>,
) => void;
type EnhanceFetch = (fetchFn: typeof fetch) => typeof fetch;
type EnhanceCreateData = (
createData: (
responsePromise: Promise<Response>,
) => Promise<Record<string, ReactNode>>,
) => (responsePromise: Promise<Response>) => Promise<Record<string, ReactNode>>;
createData: (responsePromise: Promise<Response>) => Promise<Elements>,
) => (responsePromise: Promise<Response>) => Promise<Elements>;

const ENTRY = 'e';
const SET_ELEMENTS = 's';
const ENHANCE_FETCH = 'f';
const ENHANCE_CREATE_DATA = 'd';

type FetchCache = {
[ENTRY]?: [rscPath: string, rscParams: unknown, elements: Elements];
[ENTRY]?: [
rscPath: string,
rscParams: unknown,
elementsPromise: Promise<Elements>,
];
[SET_ELEMENTS]?: SetElements;
[ENHANCE_FETCH]?: EnhanceFetch | undefined;
[ENHANCE_CREATE_DATA]?: EnhanceCreateData | undefined;
Expand All @@ -105,7 +106,7 @@ export const callServerRsc = async (
const enhanceFetch = fetchCache[ENHANCE_FETCH] || ((f) => f);
const enhanceCreateData = fetchCache[ENHANCE_CREATE_DATA] || ((d) => d);
const createData = (responsePromise: Promise<Response>) =>
createFromFetch<Awaited<Elements>>(checkStatus(responsePromise), {
createFromFetch<Elements>(checkStatus(responsePromise), {
callServer: (funcId: string, args: unknown[]) =>
callServerRsc(funcId, args, fetchCache),
});
Expand All @@ -119,7 +120,7 @@ export const callServerRsc = async (
const data = enhanceCreateData(createData)(responsePromise);
const value = (await data)._value;
// FIXME this causes rerenders even if data is empty
fetchCache[SET_ELEMENTS]?.((prev) => mergeElements(prev, data));
fetchCache[SET_ELEMENTS]?.((prev) => mergeElementsPromise(prev, data));
return value;
};

Expand All @@ -144,14 +145,14 @@ export const fetchRsc = (
rscPath: string,
rscParams?: unknown,
fetchCache = defaultFetchCache,
): Elements => {
): Promise<Elements> => {
const entry = fetchCache[ENTRY];
if (entry && entry[0] === rscPath && entry[1] === rscParams) {
return entry[2];
}
const enhanceCreateData = fetchCache[ENHANCE_CREATE_DATA] || ((d) => d);
const createData = (responsePromise: Promise<Response>) =>
createFromFetch<Awaited<Elements>>(checkStatus(responsePromise), {
createFromFetch<Elements>(checkStatus(responsePromise), {
callServer: (funcId: string, args: unknown[]) =>
callServerRsc(funcId, args, fetchCache),
});
Expand Down Expand Up @@ -190,7 +191,7 @@ const RefetchContext = createContext<
>(() => {
throw new Error('Missing Root component');
});
const ElementsContext = createContext<Elements | null>(null);
const ElementsContext = createContext<Promise<Elements> | null>(null);

export const Root = ({
initialRscPath,
Expand Down Expand Up @@ -220,7 +221,7 @@ export const Root = ({
// clear cache entry before fetching
delete fetchCache[ENTRY];
const data = fetchRsc(rscPath, rscParams, fetchCache);
setElements((prev) => mergeElements(prev, data));
setElements((prev) => mergeElementsPromise(prev, data));
},
[fetchCache],
);
Expand All @@ -241,34 +242,49 @@ export const useRefetch = () => use(RefetchContext);
const ChildrenContext = createContext<ReactNode>(undefined);
const ChildrenContextProvider = memo(ChildrenContext.Provider);

export const useElement = (id: string) => {
const elementsPromise = use(ElementsContext);
if (!elementsPromise) {
throw new Error('Missing Root component');
}
const elements = use(elementsPromise);
if (id in elements && elements[id] == undefined) {
throw new Error('Element cannot be undefined, use null instead: ' + id);
}
return elements[id];
};

const InnerSlot = ({
id,
elementsPromise,
children,
setFallback,
unstable_fallback,
}: {
id: string;
elementsPromise: Elements;
children?: ReactNode;
setFallback?: (fallback: ReactNode) => void;
unstable_fallback?: ReactNode;
}) => {
const elements = use(elementsPromise);
const hasElement = id in elements;
const element = elements[id];
const element = useElement(id);
const isValidElement = element !== undefined;
useEffect(() => {
if (hasElement && setFallback) {
setFallback(element);
if (isValidElement && setFallback) {
// FIXME is there `isReactNode` type checker?
setFallback(element as ReactNode);
}
}, [hasElement, element, setFallback]);
if (!hasElement) {
}, [isValidElement, element, setFallback]);
if (!isValidElement) {
if (unstable_fallback) {
return unstable_fallback;
}
throw new Error('No such element: ' + id);
throw new Error('Invalid element: ' + id);
}
return createElement(ChildrenContextProvider, { value: children }, element);
return createElement(
ChildrenContextProvider,
{ value: children },
// FIXME is there `isReactNode` type checker?
element as ReactNode,
);
};

const ThrowError = ({ error }: { error: unknown }) => {
Expand Down Expand Up @@ -327,22 +343,14 @@ export const Slot = ({
unstable_fallback?: ReactNode;
}) => {
const [fallback, setFallback] = useState<ReactNode>();
const elementsPromise = use(ElementsContext);
if (!elementsPromise) {
throw new Error('Missing Root component');
}
if (unstable_fallbackToPrev) {
return createElement(
Fallback,
{ fallback } as never,
createElement(InnerSlot, { id, elementsPromise, setFallback }, children),
createElement(InnerSlot, { id, setFallback }, children),
);
}
return createElement(
InnerSlot,
{ id, elementsPromise, unstable_fallback },
children,
);
return createElement(InnerSlot, { id, unstable_fallback }, children);
};

export const Children = () => use(ChildrenContext);
Expand All @@ -352,15 +360,15 @@ export const Children = () => use(ChildrenContext);
* This is not a public API.
*/
export const ServerRootInternal = ({
elements,
elementsPromise,
children,
}: {
elements: Elements;
elementsPromise: Promise<Elements>;
children: ReactNode;
}) =>
createElement(
ElementsContext.Provider,
{ value: elements },
{ value: elementsPromise },
...DEFAULT_HTML_HEAD,
children,
);
4 changes: 2 additions & 2 deletions packages/waku/src/router/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,7 +443,6 @@ export function Router({
const locationListeners = (routerData[0] ||= new Set());
const staticPathSet = (routerData[1] ||= new Set());
const cachedIdSet = (routerData[2] ||= new Set());
const has404 = (routerData[3] ||= false);
const unstable_enhanceFetch =
(fetchFn: typeof fetch) =>
(input: RequestInfo | URL, init: RequestInit = {}) => {
Expand All @@ -460,9 +459,10 @@ export function Router({
(
createData: (
responsePromise: Promise<Response>,
) => Promise<Record<string, ReactNode>>,
) => Promise<Record<string, unknown>>,
) =>
async (responsePromise: Promise<Response>) => {
const has404 = (routerData[3] ||= false);
const response = await responsePromise;
if (response.status === 404 && has404) {
// HACK this is still an experimental logic. It's very fragile.
Expand Down
2 changes: 1 addition & 1 deletion packages/waku/src/router/create-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -587,7 +587,7 @@ export const createPages = <

const pathSpec = parsePathWithSlug(routePath);
const mapping = getPathMapping(pathSpec, path);
const result: Record<string, ReactNode> = {
const result: Record<string, unknown> = {
[`page:${routePath}`]: createElement(
pageComponent,
{ ...mapping, ...(query ? { query } : {}), path },
Expand Down
6 changes: 3 additions & 3 deletions packages/waku/src/router/define-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ export function unstable_defineRouter(fns: {
) => Promise<{
rootElement: ReactNode;
routeElement: ReactNode;
elements: Record<SlotId, ReactNode>;
elements: Record<SlotId, unknown>;
}>;
getApiConfig?: () => Promise<
Iterable<{
Expand Down Expand Up @@ -265,7 +265,7 @@ export function unstable_defineRouter(fns: {
return renderRsc(entries);
}
if (input.type === 'function') {
let elementsPromise: Promise<Record<string, ReactNode>> = Promise.resolve(
let elementsPromise: Promise<Record<string, unknown>> = Promise.resolve(
{},
);
let rendered = false;
Expand Down Expand Up @@ -357,7 +357,7 @@ export function unstable_defineRouter(fns: {
const path2moduleIds: Record<string, string[]> = {};
const moduleIdsForPrefetch = new WeakMap<PathSpec, Set<string>>();
// FIXME this approach keeps all entries in memory during the loop
const entriesCache = new Map<string, Record<string, ReactNode>>();
const entriesCache = new Map<string, Record<string, unknown>>();
await Promise.all(
pathConfig.map(async ({ pathSpec, pathname, pattern, specs }) => {
if (specs.isApi) {
Expand Down

0 comments on commit b3fe435

Please sign in to comment.