Skip to content

Commit

Permalink
feat: loading and template file convention (#456)
Browse files Browse the repository at this point in the history
  • Loading branch information
hi-ogawa authored Jun 30, 2024
1 parent 4e34785 commit d7f6874
Show file tree
Hide file tree
Showing 26 changed files with 244 additions and 37 deletions.
4 changes: 3 additions & 1 deletion packages/react-server-next/src/compat/navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
routerRevalidate,
useParams as useParams_,
useRouter as useRouter_,
useSelectedParamEntries,
useSelectedParams,
} from "@hiogawa/react-server/client";
import React from "react";
Expand Down Expand Up @@ -32,7 +33,8 @@ export function useSelectedLayoutSegments(_todo?: string): string[] {
}

export function useSelectedLayoutSegment(_todo?: string): string | null {
return useSelectedLayoutSegments()[0] ?? null;
const [next] = useSelectedParamEntries();
return next?.[1] ?? null;
}

export function useRouter() {
Expand Down
10 changes: 1 addition & 9 deletions packages/react-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,18 +32,10 @@ pnpm build
pnpm preview
```

## Conventions

- `src/entry-client.tsx`
- `src/entry-react-server.tsx`
- `src/routes/**/(page|layout|error|not-found).tsx`
- `"use client"`
- `"use server"`

## Development

```sh
# NO_DTS=1
# NO_DTS=1 to skip type error
pnpm -C packages/react-server dev

# DEBUG=react-server:*
Expand Down
42 changes: 42 additions & 0 deletions packages/react-server/examples/basic/e2e/basic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1205,3 +1205,45 @@ async function testMetadata(page: Page) {
await page.getByRole("link", { name: "/test/metadata" }).click();
await expect(page).toHaveTitle("test-metadata");
}

test("loading @js", async ({ page }) => {
await page.goto("/test/loading");
await waitForHydration(page);
await page.getByRole("link", { name: "• /test/loading/1" }).click();
await expect(page.getByTestId("/test/loading")).toBeVisible();
await page.getByText('params {"id":"1"}').click();
await page.getByRole("link", { name: "• /test/loading/2" }).click();
await expect(page.getByTestId("/test/loading")).toBeVisible();
await page.getByText('params {"id":"2"}').click();

// ssr
await page.goto("/test/loading/1", { waitUntil: "commit" });
await expect(page.getByTestId("/test/loading")).toBeVisible();
await page.getByText('params {"id":"1"}').click();
});

test("template @js", async ({ page }) => {
await page.goto("/test/template");
await page.getByText("template.tsx [mount: 1]").click();
await waitForHydration(page);

await page
.getByRole("link", { name: "• /test/template/x", exact: true })
.click();
await page.getByText("template.tsx [mount: 2]").click();
await page.getByText("[p1]/template.tsx [mount: 1]").click();

await page.getByRole("link", { name: "• /test/template/x/a" }).click();
await page.getByText("template.tsx [mount: 2]", { exact: true }).click();
await page.getByText("[p1]/template.tsx [mount: 2]").click();

await page.getByRole("link", { name: "• /test/template/x/b" }).click();
await page.getByText("template.tsx [mount: 2]", { exact: true }).click();
await page.getByText("[p1]/template.tsx [mount: 3]").click();

await page
.getByRole("link", { name: "• /test/template/y", exact: true })
.click();
await page.getByText("template.tsx [mount: 3]").click();
await page.getByText("[p1]/template.tsx [mount: 4]").click();
});
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export default async function Layout(props: LayoutProps) {
"/test/loading",
"/test/cache",
"/test/metadata",
"/test/template",
]}
/>
<div className="flex items-center gap-2 text-sm">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,7 @@
import type { PageProps } from "@hiogawa/react-server/server";
import { sleep } from "@hiogawa/utils";
import React from "react";

// TODO: userland `loading` implementation?
export default function PageWithLoading(props: PageProps) {
return (
<React.Suspense fallback={<Loading />}>
<Page {...props} />
</React.Suspense>
);
}

function Loading() {
return <div className="antd-spin size-10" />;
}

async function Page(props: PageProps) {
export default async function Page(props: PageProps) {
await sleep(1000);
return (
<div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function LoadingPage() {
return <div className="antd-spin size-10" data-testid="/test/loading" />;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type React from "react";

export default function Layout(props: React.PropsWithChildren) {
return props.children;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Page() {
return <div>/template/[p1]/[p2]/page.tsx</div>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { MountCount } from "../../_client";

export default function Template(props: React.PropsWithChildren) {
return (
<div className="border p-2">
<MountCount name="[p1]/[p2]/template.tsx" />
{props.children}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type React from "react";

export default function Layout(props: React.PropsWithChildren) {
return props.children;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Page() {
return <div>/template/[p1]/page.tsx</div>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { MountCount } from "../_client";

export default function Template(props: React.PropsWithChildren) {
return (
<div className="border p-2">
<MountCount name="[p1]/template.tsx" />
{props.children}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"use client";

import { defaultDict, once, tinyassert } from "@hiogawa/utils";
import React from "react";

const countMap = defaultDict(() => 0);

export function MountCount(props: { name: string }) {
const elRef = React.useRef<HTMLElement>(null);

React.useEffect(
once(() => {
tinyassert(elRef.current);
elRef.current.textContent = String(++countMap[props.name]);
}),
[],
);

return (
<div>
{props.name} [mount: <span ref={elRef} />]
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type React from "react";
import { NavMenu } from "../../../components/nav-menu";

export default function Layout(props: React.PropsWithChildren) {
return (
<div className="flex flex-col gap-2 p-2">
<h3 className="font-bold">Test Template</h3>
<NavMenu
className="grid grid-cols-1 gap-1"
links={[
"/test/template",
"/test/template/x",
"/test/template/x/a",
"/test/template/x/b",
"/test/template/y",
"/test/template/y/a",
"/test/template/y/b",
]}
/>
{props.children}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Page() {
return <div>/template/page.tsx</div>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { MountCount } from "./_client";

export default function Template(props: React.PropsWithChildren) {
return (
<div className="border p-2">
<MountCount name="template.tsx" />
{props.children}
</div>
);
}
11 changes: 11 additions & 0 deletions packages/react-server/examples/basic/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,17 @@ export default defineConfig({
entry: process.env["SSR_ENTRY"] || "/src/adapters/node.ts",
preview: path.resolve("./dist/server/index.js"),
}),
{
// disable compressions as it breaks html streaming
// https://github.com/vitejs/vite/blob/9f5c59f07aefb1756a37bcb1c0aff24d54288950/packages/vite/src/node/preview.ts#L178
name: "no-compression",
configurePreviewServer(server) {
server.middlewares.use((req, _res, next) => {
delete req.headers["accept-encoding"];
next();
});
},
},
testVitePluginVirtual(),
],
ssr: {
Expand Down
1 change: 1 addition & 0 deletions packages/react-server/src/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ export {
routerRevalidate,
useParams,
useSelectedParams,
useSelectedParamEntries,
} from "./features/router/client";
6 changes: 5 additions & 1 deletion packages/react-server/src/entry/server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ import { $__global } from "../lib/global";
import { ENTRY_REACT_SERVER_WRAPPER, invalidateModule } from "../plugin/utils";
import { escpaeScriptString } from "../utils/escape";
import { jsonStringifyTransform } from "../utils/stream";
import { injectStreamScript } from "../utils/stream-script";
import {
createBufferedTransformStream,
injectStreamScript,
} from "../utils/stream-script";
import type { ReactServerHandlerStreamResult } from "./react-server";

const debug = createDebug("react-server:ssr");
Expand Down Expand Up @@ -199,6 +202,7 @@ export async function renderHtml(

const htmlStream = ssrStream
.pipeThrough(new TextDecoderStream())
.pipeThrough(createBufferedTransformStream())
.pipeThrough(injectToHead(head))
.pipeThrough(
injectStreamScript(
Expand Down
17 changes: 12 additions & 5 deletions packages/react-server/src/features/router/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,20 @@ export function LayoutMatchProvider(
return <LayoutMatchContext.Provider {...props} />;
}

export function useSelectedParams() {
export function useSelectedParamEntries() {
const all = useParamEntries();
const prefix = React.useContext(LayoutMatchContext).params;
return React.useMemo(
() => toMatchParamsObject(all.slice(prefix.length)),
[all, prefix],
);
return React.useMemo(() => all.slice(prefix.length), [all, prefix]);
}

export function useSelectedParams() {
const entries = useSelectedParamEntries();
return React.useMemo(() => toMatchParamsObject(entries), [entries]);
}

export function RemountRoute(props: React.PropsWithChildren) {
const [next] = useSelectedParamEntries();
return <React.Fragment key={next?.[1]}>{props.children}</React.Fragment>;
}

export const ROUTER_REVALIDATE_KEY = "__REVALIDATE";
Expand Down
2 changes: 1 addition & 1 deletion packages/react-server/src/features/router/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export function routeManifestPluginServer({
const routeFiles = await FastGlob(
path.posix.join(
routeDir,
"**/(page|layout|error|not-found).(js|jsx|ts|tsx)",
"**/(page|layout|error|not-found|loading|template).(js|jsx|ts|tsx)",
),
);
for (const routeFile of routeFiles) {
Expand Down
28 changes: 28 additions & 0 deletions packages/react-server/src/features/router/server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ interface RouteEntry {
"not-found"?: {
default: React.FC;
};
loading?: {
default: React.FC;
};
template?: {
default: React.FC<{ children?: React.ReactNode }>;
};
}

type RouteModuleNode = TreeNode<RouteEntry>;
Expand Down Expand Up @@ -54,6 +60,7 @@ async function renderLayout(
ErrorBoundary,
RedirectBoundary,
NotFoundBoundary,
RemountRoute,
LayoutContent,
LayoutMatchProvider,
} = await importRuntimeClient();
Expand All @@ -67,10 +74,30 @@ async function renderLayout(
<NotFoundBoundary fallback={<NotFoundPage />}>{acc}</NotFoundBoundary>
);
}

const ErrorPage = node.value?.error?.default;
if (ErrorPage) {
acc = <ErrorBoundary errorComponent={ErrorPage}>{acc}</ErrorBoundary>;
}

const LoadingPage = node.value?.loading?.default;
if (LoadingPage) {
acc = (
<RemountRoute>
<React.Suspense fallback={<LoadingPage />}>{acc}</React.Suspense>
</RemountRoute>
);
}

const TemplatePage = node.value?.template?.default;
if (TemplatePage) {
acc = (
<RemountRoute>
<TemplatePage>{acc}</TemplatePage>
</RemountRoute>
);
}

const Layout = node.value?.layout?.default;
if (Layout) {
acc = (
Expand All @@ -81,6 +108,7 @@ async function renderLayout(
} else {
acc = <React.Fragment key={prefix}>{acc}</React.Fragment>;
}

acc = <LayoutMatchProvider value={{ params }}>{acc}</LayoutMatchProvider>;
return acc;
}
Expand Down
6 changes: 4 additions & 2 deletions packages/react-server/src/features/router/tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ export interface BaseRouteEntry<T> {
}

// generate tree from glob entries such as generated by
// import.meta.glob("/**/(page|layout|error|not-found).(js|jsx|ts|tsx)")
// import.meta.glob("/**/(page|layout|...).(js|jsx|ts|tsx)")
export function createFsRouteTree<T>(
globEntries: Record<string, T>,
): TreeNode<BaseRouteEntry<T>> {
const entries: Record<string, BaseRouteEntry<T>> = {};
for (const [k, v] of Object.entries(globEntries)) {
const m = k.match(/^(.*)\/(page|layout|error|not-found)\.\w*$/);
const m = k.match(
/^(.*)\/(page|layout|error|not-found|loading|template)\.\w*$/,
);
tinyassert(m && 1 in m && 2 in m);
((entries[m[1]] ??= {}) as any)[m[2]] = v;
}
Expand Down
4 changes: 2 additions & 2 deletions packages/react-server/src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ export function vitePluginReactServer(options?: {
createVirtualPlugin("server-routes", () => {
return `
const glob = import.meta.glob(
"/${routeDir}/**/(page|layout|error|not-found).(js|jsx|ts|tsx)",
"/${routeDir}/**/(page|layout|error|not-found|loading|template).(js|jsx|ts|tsx)",
{ eager: true },
);
export default Object.fromEntries(
Expand Down Expand Up @@ -238,7 +238,7 @@ export function vitePluginReactServer(options?: {
entries: [
path.posix.join(
routeDir,
`**/(page|layout|error|not-found).(js|jsx|ts|tsx)`,
`**/(page|layout|error|not-found|loading|template).(js|jsx|ts|tsx)`,
),
],
exclude: ["@hiogawa/react-server"],
Expand Down
Loading

0 comments on commit d7f6874

Please sign in to comment.