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

feat: loading and template file convention #456

Merged
merged 17 commits into from
Jun 30, 2024
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