Skip to content

Commit 55ac332

Browse files
authored
feat: lexically scoped React Server Components (#355)
# Lexically Scoped React Server Components ## Summary This PR introduces **inline `"use client"` and `"use server"` directives** at the function level, eliminating the requirement that these directives apply to entire modules. Developers can now define client components and server functions directly inside any function body — at arbitrary nesting depths — within a single file. No other React framework currently supports this capability. ## Motivation In the standard RSC model, `"use client"` and `"use server"` are module-level declarations. This forces developers to split tightly-coupled server and client logic across separate files, leading to: - **File proliferation** — a server component with one client button requires at least two files. - **Artificial indirection** — the logical unit ("this server page contains this interactive widget") is scattered across the filesystem. - **Impossible compositions** — a server function that dynamically constructs and returns different client components cannot exist, since they must live in separate modules. ## What's New ### Inline `"use client"` inside server components ```jsx import { useState } from "react"; function Counter() { "use client"; const [count, setCount] = useState(0); return <button onClick={() => setCount(c => c + 1)}>{count}</button>; } export default function Page() { return <Counter />; } ``` ### Inline `"use server"` inside client components ```jsx "use client"; import { useState } from "react"; export default function LikeButton() { async function like() { "use server"; await db.likes.increment(); } const [liked, setLiked] = useState(false); return <button onClick={() => { like(); setLiked(true); }}>{liked ? "❤️" : "🤍"}</button>; } ``` ### Arbitrary nesting (server → client → server → …) Directives can alternate at any depth. A server component can define an inline client component, which itself defines an inline server function, and so on. ### Lexical scope capture Variables from parent scopes are automatically forwarded to extracted modules: - **Client components**: captured variables become destructured props, passed via `createElement` wrappers at the call site. - **Server functions**: captured variables become `Function.prototype.bind` arguments prepended to the function parameters. ### Module state sharing Top-level declarations (constants, mutable variables, class instances) are **not** duplicated into extracted modules. Instead, extracted virtual modules import them from the original module, preserving identity and mutation semantics. ## Architecture The implementation consists of three components: | Component | Role | |---|---| | `use-directive-inline.mjs` | Core Vite plugin. Performs AST analysis, outermost-first multi-pass extraction, virtual module serving, and source transformation. Generic over directive type. | | `use-client-inline.mjs` | Configuration for `"use client"`. Defines how captured variables become destructured props and how call sites become `createElement` wrappers. | | `use-server-inline.mjs` | Configuration for `"use server"`. Defines how captured variables become `.bind()` arguments and how call sites become direct references. | The plugin is instantiated once with both configs and registered as a single Vite plugin (`react-server:use-directive-inline`). ### Extraction algorithm 1. **Parse** the source file and find all functions containing a directive in their body. 2. **Outermost-first**: only extract the outermost directive functions across all directive types. Inner directives are handled in subsequent passes when the extracted virtual module is itself transformed. 3. For each outermost directive function: - Detect **captured scope variables** (not imports, not top-level declarations, not the function's own locals). - Generate a **virtual module** (`file.jsx?use-client-inline=FnName` or `?use-server-inline=FnName`) containing the extracted function with its directive, necessary imports, and captured variable injection. - **Replace** the original function in the source with an import from the virtual module (or a `createElement` wrapper / `.bind()` call if captured variables are present). 4. **Remove unused imports** from the original module. 5. **Export top-level declarations** used by extracted functions so virtual modules can import them. 6. Cache extracted modules for consistent resolution across `resolveId` / `load` / `transform` lifecycle. ### Build integration - The `use-client.mjs` plugin's transform filter was updated to match query-parameterized module IDs (`/\.m?[jt]sx?(\?.*)?$/`). - `manifest.mjs` now handles virtual modules with query parameters, correctly resolving file paths by stripping queries before `realpathSync` and re-appending them for consistent key generation. - A second pass in `manifest.mjs` processes `buildClientManifest` entries that only appear as dynamic imports in the SSR build (inline `"use client"` modules). - Both `client.mjs` and `server.mjs` build configurations register the unified `useDirectiveInline` plugin.
1 parent 6e03b14 commit 55ac332

38 files changed

Lines changed: 3658 additions & 332 deletions

docs/messages/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"category_router": "Router",
77
"category_deploy": "Deploy",
88
"category_tutorials": "Tutorials",
9+
"category_advanced": "Advanced",
910
"category_team": "Team",
1011
"algolia_placeholder": "Search docs",
1112
"algolia_buttonText": "Search",

docs/messages/ja.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"category_router": "ルーター",
77
"category_deploy": "デプロイ",
88
"category_tutorials": "チュートリアル",
9+
"category_advanced": "高度な",
910
"category_team": "チーム",
1011
"algolia_placeholder": "ドキュメントを検索",
1112
"algolia_buttonText": "検索",

docs/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"start": "react-server start"
1111
},
1212
"dependencies": {
13+
"@docsearch/css": "^3.6.0",
1314
"@docsearch/react": "3",
1415
"@lazarv/react-server": "workspace:^",
1516
"@opentelemetry/api": "^1.9.0",
@@ -24,10 +25,13 @@
2425
"@uidotdev/usehooks": "^2.4.1",
2526
"algoliasearch": "^4.24.0",
2627
"highlight.js": "^11.9.0",
28+
"katex": "^0.16.38",
2729
"lucide-react": "^0.408.0",
2830
"rehype-highlight": "^7.0.0",
31+
"rehype-katex": "^7.0.1",
2932
"rehype-mdx-code-props": "^3.0.1",
3033
"remark-gfm": "^4.0.0",
34+
"remark-math": "^6.0.0",
3135
"three": "^0.183.1",
3236
"vite-plugin-svgr": "^4.5.0"
3337
},

docs/react-server.config.mjs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import rehypeHighlight from "rehype-highlight";
2+
import rehypeKatex from "rehype-katex";
23
import rehypeMdxCodeProps from "rehype-mdx-code-props";
34
import remarkGfm from "remark-gfm";
5+
import remarkMath from "remark-math";
46

57
export default {
68
root: "src/pages",
@@ -12,8 +14,12 @@ export default {
1214
},
1315
],
1416
mdx: {
15-
remarkPlugins: [remarkGfm],
16-
rehypePlugins: [[rehypeHighlight, { detect: true }], rehypeMdxCodeProps],
17+
remarkPlugins: [remarkGfm, remarkMath],
18+
rehypePlugins: [
19+
[rehypeHighlight, { detect: true }],
20+
rehypeMdxCodeProps,
21+
rehypeKatex,
22+
],
1723
components: "./src/mdx-components.jsx",
1824
},
1925
prerender: false,
@@ -25,6 +31,7 @@ export default {
2531
return [
2632
...paths.map(({ path }) => ({
2733
path: path.replace(/^\/en/, ""),
34+
filename: path === "/" ? "index.html" : `${path.slice(1)}.html`,
2835
rsc: false,
2936
})),
3037
// Markdown versions of all docs pages for AI usage

docs/src/components/PageMeta.jsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
export default function PageMeta({ date, author, github, lang }) {
2+
if (!date && !author && !github) return null;
3+
4+
return (
5+
<div className="flex flex-col items-end self-end gap-1 text-sm text-gray-500 dark:text-gray-400 mt-2 mb-6">
6+
{github ? (
7+
<a
8+
href={`https://github.com/${github}`}
9+
target="_blank"
10+
rel="noopener noreferrer"
11+
className="flex items-center gap-2 hover:text-gray-700 dark:hover:text-gray-300"
12+
>
13+
<img
14+
src={`https://github.com/${github}.png?size=48`}
15+
alt={author || github}
16+
className="w-6 h-6 rounded-full"
17+
/>
18+
{author || github}
19+
</a>
20+
) : author ? (
21+
<span>{author}</span>
22+
) : null}
23+
{date ? (
24+
<time dateTime={date}>
25+
{new Date(date).toLocaleDateString(lang, {
26+
year: "numeric",
27+
month: "long",
28+
day: "numeric",
29+
})}
30+
</time>
31+
) : null}
32+
</div>
33+
);
34+
}

docs/src/components/Sidebar.module.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ article ~ .root {
152152
background: transparent;
153153

154154
& a {
155-
white-space: nowrap;
155+
white-space: normal;
156156
float: left;
157157
clear: left;
158158
margin-right: auto;

docs/src/components/Subtitle.jsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export default function Subtitle({ children }) {
2+
return (
3+
<p className="text-lg text-gray-500 dark:text-gray-400 -mt-4 mb-6">
4+
{children}
5+
</p>
6+
);
7+
}

docs/src/pages.mjs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const frontmatterLoaders = import.meta.glob(
77
{ import: "frontmatter" }
88
);
99
const loaders = import.meta.glob("./pages/*/\\(pages\\)/**/*.{md,mdx}");
10+
const indexPages = import.meta.glob("./pages/*/*.\\(index\\).{md,mdx}");
1011
export const pages = await Promise.all(
1112
Object.entries(frontmatterLoaders).map(async ([key, load]) => [
1213
key,
@@ -21,6 +22,7 @@ export const categories = [
2122
"Router",
2223
"Deploy",
2324
"Tutorials",
25+
"Advanced",
2426
"Team",
2527
];
2628

@@ -112,3 +114,25 @@ export function getPages(pathname, lang) {
112114
export function hasCategory(category) {
113115
return categories?.find((c) => c.toLowerCase() === category?.toLowerCase());
114116
}
117+
118+
export function hasCategoryIndex(category, lang) {
119+
return (
120+
Object.keys(indexPages).some(
121+
(key) =>
122+
key === `./pages/${lang}/${category.toLowerCase()}.(index).md` ||
123+
key === `./pages/${lang}/${category.toLowerCase()}.(index).mdx`
124+
) ||
125+
pages.some(
126+
([, { frontmatter }]) => frontmatter?.slug === category.toLowerCase()
127+
)
128+
);
129+
}
130+
131+
export function getPageFrontmatter(pathname, lang) {
132+
const allPages = getPages(pathname, lang);
133+
for (const { pages: categoryPages } of allPages) {
134+
const page = categoryPages.find((p) => p.isActive);
135+
if (page) return page.frontmatter;
136+
}
137+
return null;
138+
}

docs/src/pages/(root).layout.jsx

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
1+
import "@docsearch/css";
12
import "highlight.js/styles/github-dark-dimmed.css";
3+
import "katex/dist/katex.min.css";
24
import "./global.css";
35

46
import { cookie, usePathname } from "@lazarv/react-server";
57
import { useMatch } from "@lazarv/react-server/router";
68

79
import EditPage from "../components/EditPage.jsx";
10+
import PageMeta from "../components/PageMeta.jsx";
811
import ViewMarkdown from "../components/ViewMarkdown.jsx";
912
import { useLanguage, m } from "../i18n.mjs";
1013
import { defaultLanguage, defaultLanguageRE, languages } from "../const.mjs";
11-
import { categories } from "../pages.mjs";
14+
import { categories, getPageFrontmatter } from "../pages.mjs";
1215

1316
const lowerCaseCategories = categories.map((category) =>
1417
category.trim().toLowerCase()
@@ -36,6 +39,7 @@ export default function Layout({
3639
new RegExp(`^/(${defaultLanguage}|${lang})`),
3740
""
3841
);
42+
const frontmatter = getPageFrontmatter(pathname, lang);
3943

4044
return (
4145
<html
@@ -77,10 +81,7 @@ export default function Layout({
7781
<meta name="description" content="Run React anywhere" />
7882
<meta property="og:title" content="@lazarv/react-server" />
7983
<meta property="og:description" content="Run React anywhere" />
80-
<link
81-
rel="stylesheet"
82-
href="https://cdn.jsdelivr.net/npm/@docsearch/css@3"
83-
/>
84+
8485
<link
8586
rel="preconnect"
8687
href="https://OVQLOZDOSH-dsn.algolia.net"
@@ -114,6 +115,12 @@ export default function Layout({
114115
<EditPage pathname={pathname} />
115116
<ViewMarkdown pathname={pathname} />
116117
{children}
118+
<PageMeta
119+
date={frontmatter?.date}
120+
author={frontmatter?.author}
121+
github={frontmatter?.github}
122+
lang={lang}
123+
/>
117124
{navigation}
118125
</article>
119126
{contents}

docs/src/pages/@sidebar/[[lang]]/(sidebar).[...slug].page.jsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { usePathname } from "@lazarv/react-server";
44

55
import Sidebar from "../../../components/Sidebar.jsx";
66
import { defaultLanguage, defaultLanguageRE } from "../../../const.mjs";
7-
import { hasCategory, getPages } from "../../../pages.mjs";
7+
import { hasCategory, hasCategoryIndex, getPages } from "../../../pages.mjs";
88
import { m } from "../../../i18n.mjs";
99

1010
export default function PageSidebar({ lang, slug: [category] }) {
@@ -23,7 +23,8 @@ export default function PageSidebar({ lang, slug: [category] }) {
2323
<div
2424
className={`text-md font-semibold mb-2${i > 0 ? " pt-4 dark:border-gray-800" : ""}`}
2525
>
26-
{!pages.some(
26+
{hasCategoryIndex(category, lang) &&
27+
!pages.some(
2728
({ frontmatter }) => frontmatter?.slug === category.toLowerCase()
2829
) ? (
2930
<a
@@ -45,7 +46,7 @@ export default function PageSidebar({ lang, slug: [category] }) {
4546
<a
4647
key={src}
4748
href={langHref.replace(defaultLanguageRE, "")}
48-
className={`block pb-1 last:pb-0 after:mb-1 last:after:mb-0 text-sm pl-3 border-l border-gray-300 dark:border-gray-600${isActive ? " text-indigo-500 dark:text-yellow-600 active" : ""}`}
49+
className={`block pb-1 w-max max-w-[185px] line-clamp-2 last:pb-0 after:mb-1 last:after:mb-0 text-sm pl-6 -indent-3 border-l border-gray-300 dark:border-gray-600${isActive ? " text-indigo-500 dark:text-yellow-600 active" : ""}`}
4950
>
5051
{frontmatter?.title ?? basename(src).replace(/\.mdx?$/, "")}
5152
</a>

0 commit comments

Comments
 (0)