diff --git a/docs/messages/en.json b/docs/messages/en.json index 3c4b91e6..50856bee 100644 --- a/docs/messages/en.json +++ b/docs/messages/en.json @@ -6,6 +6,7 @@ "category_router": "Router", "category_deploy": "Deploy", "category_tutorials": "Tutorials", + "category_advanced": "Advanced", "category_team": "Team", "algolia_placeholder": "Search docs", "algolia_buttonText": "Search", diff --git a/docs/messages/ja.json b/docs/messages/ja.json index 6252f48a..6819b1c2 100644 --- a/docs/messages/ja.json +++ b/docs/messages/ja.json @@ -6,6 +6,7 @@ "category_router": "ルーター", "category_deploy": "デプロイ", "category_tutorials": "チュートリアル", + "category_advanced": "高度な", "category_team": "チーム", "algolia_placeholder": "ドキュメントを検索", "algolia_buttonText": "検索", diff --git a/docs/package.json b/docs/package.json index 6793424d..2a01c4bd 100644 --- a/docs/package.json +++ b/docs/package.json @@ -10,6 +10,7 @@ "start": "react-server start" }, "dependencies": { + "@docsearch/css": "^3.6.0", "@docsearch/react": "3", "@lazarv/react-server": "workspace:^", "@opentelemetry/api": "^1.9.0", @@ -24,10 +25,13 @@ "@uidotdev/usehooks": "^2.4.1", "algoliasearch": "^4.24.0", "highlight.js": "^11.9.0", + "katex": "^0.16.38", "lucide-react": "^0.408.0", "rehype-highlight": "^7.0.0", + "rehype-katex": "^7.0.1", "rehype-mdx-code-props": "^3.0.1", "remark-gfm": "^4.0.0", + "remark-math": "^6.0.0", "three": "^0.183.1", "vite-plugin-svgr": "^4.5.0" }, diff --git a/docs/react-server.config.mjs b/docs/react-server.config.mjs index 2f655b29..d0ded934 100644 --- a/docs/react-server.config.mjs +++ b/docs/react-server.config.mjs @@ -1,6 +1,8 @@ import rehypeHighlight from "rehype-highlight"; +import rehypeKatex from "rehype-katex"; import rehypeMdxCodeProps from "rehype-mdx-code-props"; import remarkGfm from "remark-gfm"; +import remarkMath from "remark-math"; export default { root: "src/pages", @@ -12,8 +14,12 @@ export default { }, ], mdx: { - remarkPlugins: [remarkGfm], - rehypePlugins: [[rehypeHighlight, { detect: true }], rehypeMdxCodeProps], + remarkPlugins: [remarkGfm, remarkMath], + rehypePlugins: [ + [rehypeHighlight, { detect: true }], + rehypeMdxCodeProps, + rehypeKatex, + ], components: "./src/mdx-components.jsx", }, prerender: false, @@ -25,6 +31,7 @@ export default { return [ ...paths.map(({ path }) => ({ path: path.replace(/^\/en/, ""), + filename: path === "/" ? "index.html" : `${path.slice(1)}.html`, rsc: false, })), // Markdown versions of all docs pages for AI usage diff --git a/docs/src/components/PageMeta.jsx b/docs/src/components/PageMeta.jsx new file mode 100644 index 00000000..33a0891f --- /dev/null +++ b/docs/src/components/PageMeta.jsx @@ -0,0 +1,34 @@ +export default function PageMeta({ date, author, github, lang }) { + if (!date && !author && !github) return null; + + return ( +
+ {github ? ( + + {author + {author || github} + + ) : author ? ( + {author} + ) : null} + {date ? ( + + ) : null} +
+ ); +} diff --git a/docs/src/components/Sidebar.module.css b/docs/src/components/Sidebar.module.css index aa316621..b598a214 100644 --- a/docs/src/components/Sidebar.module.css +++ b/docs/src/components/Sidebar.module.css @@ -152,7 +152,7 @@ article ~ .root { background: transparent; & a { - white-space: nowrap; + white-space: normal; float: left; clear: left; margin-right: auto; diff --git a/docs/src/components/Subtitle.jsx b/docs/src/components/Subtitle.jsx new file mode 100644 index 00000000..2937337f --- /dev/null +++ b/docs/src/components/Subtitle.jsx @@ -0,0 +1,7 @@ +export default function Subtitle({ children }) { + return ( +

+ {children} +

+ ); +} diff --git a/docs/src/pages.mjs b/docs/src/pages.mjs index 86fc074b..a50fe13b 100644 --- a/docs/src/pages.mjs +++ b/docs/src/pages.mjs @@ -7,6 +7,7 @@ const frontmatterLoaders = import.meta.glob( { import: "frontmatter" } ); const loaders = import.meta.glob("./pages/*/\\(pages\\)/**/*.{md,mdx}"); +const indexPages = import.meta.glob("./pages/*/*.\\(index\\).{md,mdx}"); export const pages = await Promise.all( Object.entries(frontmatterLoaders).map(async ([key, load]) => [ key, @@ -21,6 +22,7 @@ export const categories = [ "Router", "Deploy", "Tutorials", + "Advanced", "Team", ]; @@ -112,3 +114,25 @@ export function getPages(pathname, lang) { export function hasCategory(category) { return categories?.find((c) => c.toLowerCase() === category?.toLowerCase()); } + +export function hasCategoryIndex(category, lang) { + return ( + Object.keys(indexPages).some( + (key) => + key === `./pages/${lang}/${category.toLowerCase()}.(index).md` || + key === `./pages/${lang}/${category.toLowerCase()}.(index).mdx` + ) || + pages.some( + ([, { frontmatter }]) => frontmatter?.slug === category.toLowerCase() + ) + ); +} + +export function getPageFrontmatter(pathname, lang) { + const allPages = getPages(pathname, lang); + for (const { pages: categoryPages } of allPages) { + const page = categoryPages.find((p) => p.isActive); + if (page) return page.frontmatter; + } + return null; +} diff --git a/docs/src/pages/(root).layout.jsx b/docs/src/pages/(root).layout.jsx index 21372086..81f479d1 100644 --- a/docs/src/pages/(root).layout.jsx +++ b/docs/src/pages/(root).layout.jsx @@ -1,14 +1,17 @@ +import "@docsearch/css"; import "highlight.js/styles/github-dark-dimmed.css"; +import "katex/dist/katex.min.css"; import "./global.css"; import { cookie, usePathname } from "@lazarv/react-server"; import { useMatch } from "@lazarv/react-server/router"; import EditPage from "../components/EditPage.jsx"; +import PageMeta from "../components/PageMeta.jsx"; import ViewMarkdown from "../components/ViewMarkdown.jsx"; import { useLanguage, m } from "../i18n.mjs"; import { defaultLanguage, defaultLanguageRE, languages } from "../const.mjs"; -import { categories } from "../pages.mjs"; +import { categories, getPageFrontmatter } from "../pages.mjs"; const lowerCaseCategories = categories.map((category) => category.trim().toLowerCase() @@ -36,6 +39,7 @@ export default function Layout({ new RegExp(`^/(${defaultLanguage}|${lang})`), "" ); + const frontmatter = getPageFrontmatter(pathname, lang); return ( - + {children} + {navigation} {contents} diff --git a/docs/src/pages/@sidebar/[[lang]]/(sidebar).[...slug].page.jsx b/docs/src/pages/@sidebar/[[lang]]/(sidebar).[...slug].page.jsx index 277a863d..464eb8bb 100644 --- a/docs/src/pages/@sidebar/[[lang]]/(sidebar).[...slug].page.jsx +++ b/docs/src/pages/@sidebar/[[lang]]/(sidebar).[...slug].page.jsx @@ -4,7 +4,7 @@ import { usePathname } from "@lazarv/react-server"; import Sidebar from "../../../components/Sidebar.jsx"; import { defaultLanguage, defaultLanguageRE } from "../../../const.mjs"; -import { hasCategory, getPages } from "../../../pages.mjs"; +import { hasCategory, hasCategoryIndex, getPages } from "../../../pages.mjs"; import { m } from "../../../i18n.mjs"; export default function PageSidebar({ lang, slug: [category] }) { @@ -23,7 +23,8 @@ export default function PageSidebar({ lang, slug: [category] }) {
0 ? " pt-4 dark:border-gray-800" : ""}`} > - {!pages.some( + {hasCategoryIndex(category, lang) && + !pages.some( ({ frontmatter }) => frontmatter?.slug === category.toLowerCase() ) ? ( {frontmatter?.title ?? basename(src).replace(/\.mdx?$/, "")} diff --git a/docs/src/pages/en/(pages)/advanced/lexically-scoped-react-server-components.mdx b/docs/src/pages/en/(pages)/advanced/lexically-scoped-react-server-components.mdx new file mode 100644 index 00000000..da08d4ac --- /dev/null +++ b/docs/src/pages/en/(pages)/advanced/lexically-scoped-react-server-components.mdx @@ -0,0 +1,715 @@ +--- +title: Lexically Scoped React Server Components +date: 2026-03-10 +author: Viktor Lázár +github: lazarv +category: Advanced +order: 0 +--- + +import Link from "../../../../components/Link.jsx"; +import Subtitle from "../../../../components/Subtitle.jsx"; + +# Lexically Scoped React Server Components + +Deep-Nesting Client and Server Islands in a Single File + +*A technical deep-dive into how react-server extracts inline `"use client"` and `"use server"` directives from arbitrary nesting depths, enabling true server/client island composition without file boundaries.* + + +## Abstract + + +React Server Components (RSC) introduced a module-level boundary between server and client code via the `"use client"` and `"use server"` directives. By specification, these directives apply to entire modules — a file is either a client component, a server function module, or a server component. This file-level granularity forces developers to split tightly-coupled server/client logic across multiple files, creating indirection that obscures intent. + +This paper presents a compile-time extraction algorithm implemented in `@lazarv/react-server` that eliminates the module boundary restriction of React Server Components, allowing `"use client"` and `"use server"` directives to appear inside individual function bodies at any nesting depth. A single Vite plugin performs multi-pass outermost-first extraction, generating virtual modules connected by query-parameterized import chains. The algorithm handles lexical scope capture, module-state sharing, deeply nested directive alternation (server → client → server → …), and transparent integration with the existing RSC bundler protocol — all while working across both development and production builds. + +No other React framework currently supports this capability. + + +## The Problem: File Boundaries as Architectural Constraints + + +In the standard RSC model, directives are module-level declarations: + +```jsx +// counter.jsx — client module +"use client"; +import { useState } from "react"; +export function Counter() { + const [count, setCount] = useState(0); + return ; +} +``` + +```jsx +// page.jsx — server component +import { Counter } from "./counter"; +export default function Page() { + return ; +} +``` + +This separation has three structural costs: + +1. **File proliferation.** A server component that renders one client button requires two files. A page with N distinct client islands requires N+1 files minimum. +2. **Artificial indirection.** The logical unit — "this server page contains this interactive widget" — is scattered across the filesystem. Code navigation and comprehension suffer. +3. **Impossible compositions.** A server function that dynamically constructs and returns different client components (e.g., a factory pattern) cannot exist — the server function and the client component must live in separate modules, so the server function cannot lexically define the client component it returns. + +The third point is the most significant. RSC's serialization protocol (`react-server-dom-webpack`) already supports returning client component references from server functions. The limitation is entirely in the bundler — no existing tool can extract a `"use client"` component defined inside a `"use server"` function body. + + +## Design Goals + + +The inline directive system was designed with the following invariants: + +1. **Arbitrary nesting.** `"use server"` inside `"use client"` inside `"use server"` (and deeper) must work. Each alternation creates a new island boundary. +2. **Lexical scope capture.** Variables from parent function scopes must be forwarded to extracted modules — as props for client components, as bound arguments for server functions. +3. **Module state sharing.** Top-level declarations (constants, mutable variables, class instances) must NOT be duplicated into extracted modules. Extracted modules must import them from the original module to preserve identity and mutation semantics. +4. **Transparent RSC protocol integration.** Extracted modules must be indistinguishable from hand-written separate files to the RSC serialization layer. Client references, server references, and the manifest must all work identically. +5. **Single-file development.** The programmer writes one file. The compiler produces the correct module graph. No code generation artifacts appear in the source tree. +6. **Idempotent re-extraction.** When an extracted virtual module itself contains directives for the opposite boundary (the nested case), the same plugin re-processes it, producing a deeper virtual module. This must converge. + + +## Architecture Overview + + +The system consists of three components: + +| Component | Role | +|---|---| +| `use-directive-inline.mjs` | Single Vite plugin. Performs AST analysis, extraction, virtual module serving, and code transformation. Generic over directive type. | +| `use-client-inline.mjs` | Configuration object for `"use client"`. Defines how captured variables become destructured props and how call sites become `createElement` wrappers. | +| `use-server-inline.mjs` | Configuration object for `"use server"`. Defines how captured variables become `Function.prototype.bind` arguments and how call sites become direct references. | + +The plugin is instantiated once with both configs: + +```javascript +import useDirectiveInline from "./use-directive-inline.mjs"; +import { useClientInlineConfig } from "./use-client-inline.mjs"; +import { useServerInlineConfig } from "./use-server-inline.mjs"; + +useDirectiveInline([useClientInlineConfig, useServerInlineConfig]); +``` + +This single plugin handles all directive types in a unified pass. The two configs differ only in the strategy for scope capture injection and call-site replacement. + + +## The Extraction Algorithm + + +### Phase 1: Outermost-First Discovery + +Given a source file, the plugin finds all functions whose body begins with a directive string literal (`"use client"` or `"use server"`). It then filters to only **outermost** directive functions — those not contained within any other directive function: + +``` +findOutermostDirectiveFunctions(ast, ["use client", "use server"]) +``` + +This is critical for correctness. Consider: + +```jsx +function Outer() { + "use client"; + async function Inner() { + "use server"; + // ... + } + // ... +} +``` + +Both `Outer` and `Inner` contain directives, but only `Outer` is outermost. The plugin extracts `Outer` first. When the extracted module for `Outer` is later processed by the same plugin (it will be — see Phase 3), `Inner` will be discovered as outermost *within that module* and extracted in turn. + +The outermost-first invariant ensures that: +- No function is extracted twice +- Nested directives are handled by recursive application, not special-cased logic +- The algorithm converges for any finite nesting depth + +### Phase 2: Scope Analysis + +For each outermost directive function, the plugin performs lexical scope analysis to determine **captured variables** — identifiers that are: +- Referenced inside the function body +- Declared in an intermediate function scope (not module-level, not the function's own locals) +- Not import bindings (those are included directly in the extracted module) +- Not top-level declarations (those are shared via import from the original module) + +The algorithm walks the AST from the root, maintaining a stack of scope frames. Each frame records the variables declared by function parameters and local `let`/`const`/`var`/`function`/`class` declarations. When the target function is reached, the union of all intermediate scopes is intersected with the identifiers used inside the target function. + +``` +scopeStack: [App's locals] → [Component's locals] → target function +captured = (App.locals ∪ Component.locals) ∩ target.usedIdentifiers + − importBindings − topLevelDeclarations +``` + +This three-way partitioning is essential: + +| Category | Treatment in extracted module | +|---|---| +| **Import bindings** | Copied verbatim as import statements | +| **Top-level declarations** | Imported from the original file (`import { x } from "./original"`) | +| **Captured scope variables** | Injected into the function signature | + +### Phase 3: Virtual Module Generation + +Each extracted function produces a virtual module identified by a query-parameterized URL: + +``` +original.jsx?use-client-inline=Counter +original.jsx?use-server-inline=increment +``` + +For nested extraction (a `"use server"` inside an already-extracted `"use client"` module), query parameters are chained with `&`: + +``` +original.jsx?use-client-inline=Counter&use-server-inline=increment +``` + +The virtual module contains: +1. The directive string (`"use client";` or `"use server";`) +2. All import statements used by the extracted function +3. An `import { ... } from "./original"` for any referenced top-level declarations +4. The function body itself, with captured variables injected into the parameter list +5. A `default` export of the function + +The plugin's `load` hook serves this generated code for any ID matching the query pattern. The `resolveId` hook ensures the virtual module IDs are treated as resolved, and also handles relative imports from within virtual modules by stripping query parameters from the importer path before re-resolving. + +### Phase 4: Call-Site Rewriting + +The original file is transformed: each extracted function is replaced with a reference to the virtual module. The replacement strategy differs by directive type. + +**For `"use server"` functions:** + +Without captured variables — direct import: +```javascript +// Before: +async function action(data) { "use server"; /* ... */ } +// After: +import __action from "./file?use-server-inline=action"; +const action = __action; +``` + +With captured variables — `bind`: +```javascript +// Before: +async function action(data) { "use server"; /* ... */ } +// After: +import __action from "./file?use-server-inline=action"; +const action = __action.bind(null, capturedVar1, capturedVar2); +``` + +The extracted module's function signature is rewritten to prepend the captured variables: +```javascript +// Extracted module: +"use server"; +export default async function action(capturedVar1, capturedVar2, data) { /* ... */ } +``` + +At runtime, `.bind(null, capturedVar1, capturedVar2)` creates a function where the first two arguments are pre-filled, so the call site's `action(data)` becomes `action(capturedVar1, capturedVar2, data)` on the server. + +**For `"use client"` components:** + +Without captured variables — direct import (handled by default Vite resolution): +```javascript +// Before: +function Counter() { "use client"; /* ... */ } +// After: +import Counter from "./file?use-client-inline=Counter"; +``` + +With captured variables — `createElement` wrapper with prop injection: +```javascript +// Before: +function Counter() { "use client"; /* ... */ } +// After: +import { createElement as __useClientCreateElement } from "react"; +import __Counter from "./file?use-client-inline=Counter"; +const Counter = (__props) => __useClientCreateElement(__Counter, { ...__props, label }); +``` + +The extracted module's function signature is rewritten to accept captured variables as destructured props: +```javascript +// Extracted module: +"use client"; +export default function Counter({ label }) { /* ... */ } +``` + +This ensures that captured variables flow through the RSC serialization boundary as ordinary props — no runtime protocol extensions are needed. + +### Phase 5: Module State Export + +When extracted functions reference top-level declarations from the source file, those declarations must be importable. The plugin appends a synthetic `export { ... }` statement to the original module for any top-level declarations that are used by extracted functions but not already exported. + +This preserves **shared module state**: if a top-level variable is mutated by the original module and read by the extracted module (or vice versa), both see the same binding because they share the same module instance via ES module import semantics. + + +## Recursive Extraction and the Deep Nesting Problem + + +The most novel aspect of this system is its handling of arbitrarily deep nesting. Consider: + +```jsx +import { useState, useTransition } from "react"; + +async function getGreeting(name) { + "use server"; + + function GreetingCard({ message }) { + "use client"; + const [liked, setLiked] = useState(false); + return ( +
+

{message}

+ +
+ ); + } + + return ; +} +``` + +Here `getGreeting` is a server function that defines `GreetingCard` as a client component and returns it rendered. This is a two-level nesting: server → client. + +**Step 1:** The plugin processes the source file. `getGreeting` is the outermost directive function. It is extracted to: + +``` +file.jsx?use-server-inline=getGreeting +``` + +This virtual module contains: +```jsx +"use server"; +import { useState } from "react"; +export default async function getGreeting(name) { + function GreetingCard({ message }) { + "use client"; + const [liked, setLiked] = useState(false); + // ... + } + return ; +} +``` + +**Step 2:** Vite processes this virtual module through the same plugin. Now `GreetingCard` is discovered as an outermost `"use client"` function within this module. It is extracted to: + +``` +file.jsx?use-server-inline=getGreeting&use-client-inline=GreetingCard +``` + +The `getGreeting` module is rewritten to import `GreetingCard`: +```jsx +"use server"; +import GreetingCard from "file.jsx?use-server-inline=getGreeting&use-client-inline=GreetingCard"; +export default async function getGreeting(name) { + return ; +} +``` + +**Step 3:** The `GreetingCard` virtual module is a `"use client"` module containing no further directives. Extraction terminates. + +The resulting module graph: + +``` +file.jsx (server component — original) + └─ ?use-server-inline=getGreeting (server function — virtual) + └─ ?…&use-client-inline=GreetingCard (client component — virtual) +``` + +Each edge in this graph represents a boundary crossing: server ↔ client. The RSC protocol handles each crossing through its existing serialization mechanism — the extraction simply produces the module graph that makes it possible. + +### Convergence Proof + +The algorithm terminates because: + +1. Each extraction pass strictly reduces the number of directive functions in the source by at least one (the outermost ones are removed). +2. The extracted module contains strictly fewer directive functions than the combined set before extraction (only the nested ones remain). +3. The total number of directive functions across the original source is finite. + +Therefore the recursive extraction process has at most $O(d)$ passes where $d$ is the maximum nesting depth. Extraction cost is linear in the number of directive functions. + +### The `skipIfModuleDirective` Guard + +A subtlety: when a file has a top-level `"use client"` directive, we must NOT re-extract inline `"use client"` functions from it — the entire file is already a client module. But we MUST still extract `"use server"` functions from it. + +Each config declares a `skipIfModuleDirective` list: + +```javascript +// use-client-inline: skip "use client" extraction from files that are already "use client" +{ skipIfModuleDirective: ["use client"] } + +// use-server-inline: never skip — "use server" inside "use client" must work +{ skipIfModuleDirective: null } +``` + +This allows `"use server"` functions inside `"use client"` files (top-level or inline) while preventing nonsensical double-extraction of client components. + + +## Scope Capture Mechanics + + +### The Three-Tier Binding Classification + +Every identifier referenced by an extracted function falls into exactly one of three categories: + +``` +┌─────────────────────────────────────────┐ +│ Module scope │ +│ ┌─────────────────────────────────┐ │ +│ │ Import bindings: │ │ +│ │ import { useState } from "…" │ │ → Copied to extracted module +│ └─────────────────────────────────┘ │ +│ ┌─────────────────────────────────┐ │ +│ │ Top-level declarations: │ │ +│ │ const PREFIX = "Hello"; │ │ → Imported from original module +│ └─────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────┐ │ +│ │ Function scope (parent) │ │ +│ │ ┌───────────────────────┐ │ │ +│ │ │ Captured variables: │ │ │ +│ │ │ const factor = 5; │ │ │ → Injected as params/props +│ │ └───────────────────────┘ │ │ +│ │ ┌───────────────────────┐ │ │ +│ │ │ Target function │ │ │ +│ │ │ "use server" / etc. │ │ │ +│ │ └───────────────────────┘ │ │ +│ └─────────────────────────────────┘ │ +└─────────────────────────────────────────┘ +``` + +Import bindings are copied because they are stateless references to external modules. Top-level declarations are imported rather than copied to preserve **module state identity** — if the original module mutates a variable, the extracted module sees the mutation. Captured scope variables cannot be imported (they are not module-level exports) and must cross the boundary at runtime. + +### Capture Injection Strategies + +The injection strategy must differ by directive because client components and server functions cross the network boundary differently: + +**Server functions** use `Function.prototype.bind`: + +```javascript +// Call site: +const action = __imported_action.bind(null, capturedA, capturedB); + +// Extracted function signature: +export default async function action(capturedA, capturedB, userArg) { ... } +``` + +`bind` prepends the captured values to every invocation. When the RSC protocol calls the server function, the bound arguments arrive first, followed by the caller's arguments. This is transparent to the caller. + +**Client components** use prop injection: + +```javascript +// Call site: +const MyComp = (props) => createElement(__imported_MyComp, { ...props, capturedA, capturedB }); + +// Extracted component signature: +export default function MyComp({ capturedA, capturedB, ...rest }) { ... } +``` + +Props are the natural data channel for React components. The wrapper spreads any incoming props and adds the captured variables as additional props. This preserves the component's public API while silently forwarding scope captures. + +### Destructuring Pattern Handling + +The injection must handle existing parameter patterns: + +| Original signature | After injection (client) | After injection (server) | +|---|---|---| +| `()` | `({ x, y })` | `(x, y)` | +| `(props)` | `({ x, y, ...props })` | `(x, y, props)` | +| `({ a, b })` | `({ x, y, a, b })` | `(x, y, { a, b })` | + +This is implemented via string manipulation on the function source, using the AST node positions to locate the parameter list boundaries. + + +## Lambda Lifting Interpretation + + +The extraction algorithm is a specialization of **lambda lifting** (also called **closure conversion**), a classical compiler transformation studied since Johnsson (1985). In the general formulation, lambda lifting converts a nested function (closure) into a top-level function by making all free variables into explicit parameters: + +$$ +\text{lift}: \quad \lambda_{\text{nested}}.\, \text{body}[v_1, \dots, v_n] \;\;\longrightarrow\;\; \Lambda_{\text{top}}(v_1, \dots, v_n).\, \text{body} +$$ + +Where $v_1, \dots, v_n$ are the free variables of the nested function — variables referenced in its body but defined in an enclosing scope. After lifting, every call site is rewritten to pass the captured variables explicitly: + +$$ +f(\text{args}) \;\;\longrightarrow\;\; F(v_1, \dots, v_n, \text{args}) +$$ + +Traditional lambda lifting operates within a single compilation unit and a single runtime. What makes the RSC case novel is that **the lifted function and its call site execute in different runtimes** — the server and the client — connected only by the RSC serialization protocol. This imposes two constraints that standard lambda lifting does not face: + +### Constraint 1: The Parameter Channel Must Match the Boundary Type + +In classical lambda lifting, captured variables are always prepended as function parameters. In our system, the transport mechanism depends on the directive: + +| Boundary | Classical lifting | RSC lifting | +|---|---|---| +| `"use server"` | $F(v_1, \dots, v_n, \text{args})$ | `F.bind(null, v₁, …, vₙ)` — partial application creates a server reference whose bound arguments are serialized into the RSC wire format | +| `"use client"` | $F(v_1, \dots, v_n, \text{args})$ | `createElement(F, { v₁, …, vₙ, ...props })` — captured values become React props, which are serialized as part of the RSC element tree | + +Both are semantically equivalent to parameter passing, but they use different runtime mechanisms because server functions are invoked via RPC while client components are instantiated via `createElement`. + +This can be formalized as: + +$$ +\text{lift}_{\text{server}}(f) = \lambda(\text{args}).\; F.\text{bind}(\text{null},\, \vec{v})(\text{args}) +$$ + +$$ +\text{lift}_{\text{client}}(C) = \lambda(\text{props}).\; \text{createElement}(C,\, \{ \vec{v},\, \dots\text{props} \}) +$$ + +### Constraint 2: Not All Free Variables Can Be Lifted + +Classical lambda lifting lifts *all* free variables indiscriminately. In the RSC context, only a subset — **intermediate scope captures** — needs parameter injection. The other free variables have alternative transport paths: + +$$ +\text{FreeVars}(f) = \underbrace{V_{\text{import}}}_{\text{static import}} \;\cup\; \underbrace{V_{\text{top}}}_{\text{module re-import}} \;\cup\; \underbrace{V_{\text{captured}}}_{\text{parameter injection}} +$$ + +- $V_{\text{import}}$ (import bindings): Available in any module via static `import` statements — replicated in the extracted module. Zero runtime cost. +- $V_{\text{top}}$ (top-level declarations): Shared via ES module re-import from the original file. Preserves reference identity and mutation visibility. Zero serialization cost. +- $V_{\text{captured}}$ (scope captures): Must cross the runtime boundary. These are the *only* variables that undergo parameter injection. + +This three-way partitioning minimizes the serialization surface. Only $V_{\text{captured}}$ — variables that exist ephemerally in a parent function's stack frame — require runtime data transfer across the network boundary. + +### Recursive Lifting as Iterated Closure Conversion + +When directives nest (server → client → server → …), each extraction pass performs one round of lambda lifting. A function nested $d$ levels deep undergoes $d$ successive lifts, each one eliminating one layer of closure: + +$$ +f^{(0)} \xrightarrow{\text{lift}_1} f^{(1)} \xrightarrow{\text{lift}_2} \cdots \xrightarrow{\text{lift}_d} f^{(d)} +$$ + +At each step, the captured variables from the immediately enclosing scope are converted to parameters. The process terminates when no closures remain — when every extracted module's free variables are resolvable via static imports or module re-imports alone, i.e., $V_{\text{captured}} = \emptyset$. + +This is precisely the classical fixed-point characterization of lambda lifting: iterate until all nested functions have been promoted to the top level of their respective modules. + + +## Virtual Module Resolution + + +### Query Parameter Addressing + +Virtual module IDs use URL query parameters to encode the extraction chain: + +``` +file.jsx?use-client-inline=Counter +file.jsx?use-client-inline=Counter&use-server-inline=increment +``` + +The `?` introduces the first parameter; `&` separates additional parameters. This follows standard URL query syntax and avoids the ambiguity of multiple `?` characters, which would break naive `split("?")` parsing in downstream tools. + +### The `matchQueryKey` Function + +The plugin must determine which config (client vs. server) a virtual module ID belongs to. For chained IDs like `file.jsx?use-client-inline=Counter&use-server-inline=increment`, the **last** parameter determines the module's identity — it is a `"use server"` module, not a `"use client"` module. + +```javascript +function matchQueryKey(id) { + let lastMatch = null; + let lastPos = -1; + for (const cfg of configs) { + for (const sep of ["?", "&"]) { + const marker = `${sep}${cfg.queryKey}=`; + const pos = id.indexOf(marker); + if (pos !== -1 && pos > lastPos) { + lastPos = pos; + lastMatch = { cfg, marker }; + } + } + } + return lastMatch; +} +``` + +### Relative Import Resolution + +When an extracted virtual module contains relative imports (copied from the original source), Vite's resolver cannot determine the correct directory from the query-parameterized ID. The plugin's `resolveId` hook intercepts these cases: + +```javascript +async resolveId(source, importer) { + if (matchQueryKey(source)) return source; + + if (importer && matchQueryKey(importer) && + (source.startsWith("./") || source.startsWith("../"))) { + const cleanImporter = importer.slice(0, importer.indexOf("?")); + return this.resolve(source, cleanImporter, { skipSelf: true }); + } +} +``` + +This strips the query parameters from the importer to recover the real filesystem path, then delegates to Vite's normal resolution. The `{ skipSelf: true }` flag prevents infinite recursion. + + +## Production Build Integration + + +### Manifest Generation + +The RSC protocol requires a **client manifest** mapping module IDs to their bundled output files. For inline-extracted modules, the manifest must include entries keyed by the full query-parameterized ID: + +```json +{ + "fixtures/app.jsx?use-server-inline=getGreeting&use-client-inline=GreetingCard#default": { + "id": "/client/app_use-server-inline_getGreeting_use-client-inline_GreetingCard.abc123.mjs", + "chunks": [], + "name": "default", + "async": true + } +} +``` + +The manifest generator must split the `buildEntry.id` on only the first `?` to separate the filesystem path from the full query string, using `indexOf("?")` rather than `split("?")` to avoid truncating chained parameters. + +### Server Reference Registration + +Server functions extracted with the `:inline:` marker in their virtual reference ID use relative specifiers for manifest registration, ensuring the lookup key matches the entry ID. The `isInlineExtracted` detection uses `[?&]use-(?:server|client|cache)-inline=` to match both first-position and chained query parameters. + +### SSR and Edge Builds + +The extracted virtual modules participate in all build targets (server, client, SSR, edge) identically. The Vite plugin's `resolveId`/`load`/`transform` hooks fire in every build phase, and the virtual module cache is shared. Client components get bundled into the client output; server functions get bundled into the server output; the RSC serialization bridge connects them. + + +## Comparison with Prior Art + + +| Feature | Next.js / Standard RSC | Astro Islands | Qwik | react-server (this work) | +|---|---|---|---|---| +| Module-level directives | ✓ | N/A | N/A | ✓ | +| Inline `"use client"` | ✗ | N/A | N/A | ✓ | +| Inline `"use server"` in server components | ✓ | N/A | N/A | ✓ | +| Inline `"use server"` in `"use client"` modules | ✗ | N/A | N/A | ✓ | +| Inline `"use client"` in `"use server"` functions | ✗ | N/A | N/A | ✓ | +| Arbitrary server↔client nesting | ✗ | Different model (compile-time islands) | Partial | ✓ | +| Captured scope forwarding | N/A | N/A | ✓ (resumability) | ✓ (bind/props) | +| Single-file island composition | ✗ | ✗ | ✓ (different model) | ✓ | + +Next.js supports `"use server"` inside server component function bodies but does not support `"use client"` inside any function body, nor `"use server"` inside `"use client"` modules. The module boundary is absolute. + +Qwik's `$` sigil (`component$`, `server$`) provides function-level extraction with automatic scope capture via its resumability model. However, Qwik uses a fundamentally different architecture (fine-grained lazy loading, no RSC protocol). + +This work is the first to implement arbitrary nesting of standard RSC directives within the React ecosystem. + +In contrast to frameworks that treat `"use client"` as a module boundary, react-server treats directives as lexically scoped boundaries, allowing the compiler to synthesize the module graph from the component structure. + + +## Pattern Catalog + + +### Server Action Beside Its Client UI + +```jsx +import { useState, useTransition } from "react"; + +async function subscribe(email) { + "use server"; + await db.subscriptions.insert({ email }); + return { success: true }; +} + +function NewsletterForm() { + "use client"; + const [status, setStatus] = useState(null); + const [, startTransition] = useTransition(); + + return ( +
{ + e.preventDefault(); + const email = new FormData(e.target).get("email"); + startTransition(async () => setStatus(await subscribe(email))); + }}> + + + {status?.success &&

Subscribed!

} +
+ ); +} + +export default function Page() { + return ; +} +``` + +One file. Zero indirection. The server action and the client form are extracted into separate modules at build time. + +### Server Factory Returning Client Components + +```jsx +import { useState } from "react"; + +async function createWidget(config) { + "use server"; + + const data = await fetchWidgetData(config); + + function Widget({ items }) { + "use client"; + const [selected, setSelected] = useState(null); + return ( +
    + {items.map(item => ( +
  • setSelected(item.id)}> + {item.name} {selected === item.id && "✓"} +
  • + ))} +
+ ); + } + + return ; +} +``` + +The server function fetches data, defines a client component, and returns it rendered with the data as props. The client component hydrates and becomes fully interactive. This pattern is impossible with module-level directives. + +### Top-Level `"use client"` File with Inline Server Actions + +```jsx +"use client"; + +import { useState, useTransition } from "react"; + +export default function TodoApp() { + const [items, setItems] = useState([]); + const [, startTransition] = useTransition(); + + async function addItem(text) { + "use server"; + return { id: Date.now(), text }; + } + + return ( +
+ +
    + {items.map(item =>
  • {item.text}
  • )} +
+
+ ); +} +``` + +The file-level `"use client"` makes the entire module a client component, but the inline `"use server"` function is extracted into a server module. The `skipIfModuleDirective` configuration ensures `"use server"` extraction is never skipped, regardless of the file's own directive. + + +## Conclusion + + +The inline directive extraction system transforms React Server Components from a *module-level* architecture into a *function-level* one. Developers write colocated, lexically scoped code; the compiler generates the correct module graph. The key insights are: + +1. **Outermost-first extraction** with recursive re-processing naturally handles arbitrary nesting without special-casing depth. +2. **Three-tier binding classification** (imports → copy, top-level declarations → import, scope captures → inject) correctly handles all variable access patterns while preserving module state identity. +3. **Query-parameterized virtual modules** provide a stable, composable addressing scheme for extracted functions that integrates seamlessly with Vite's module graph and the RSC manifest protocol. +4. **Directive-specific injection strategies** (`bind` for server functions, prop injection for client components) use the natural data channels of each boundary type, requiring no protocol extensions. + +The result is a system where the programmer's file structure reflects logical intent, not compiler requirements. A server function and its client UI live together. A factory function defines and returns the components it creates. Islands nest inside islands. The boundaries are explicit — the directives are still there — but the granularity is the function, not the file. \ No newline at end of file diff --git a/docs/src/pages/en/(pages)/guide/client-components.mdx b/docs/src/pages/en/(pages)/guide/client-components.mdx index 991f35c3..8626a59e 100644 --- a/docs/src/pages/en/(pages)/guide/client-components.mdx +++ b/docs/src/pages/en/(pages)/guide/client-components.mdx @@ -99,6 +99,141 @@ export default async function MyServerComponent() { } ``` + +## Inline client components + + +Instead of creating a separate file for each client component, you can use the `"use client"` directive inside a function body. This allows you to define client components inline, right next to the server component that uses them. + +```jsx +import { useState } from "react"; + +function Counter() { + "use client"; + const [count, setCount] = useState(0); + return ( +
+

Count: {count}

+ +
+ ); +} + +export default function App() { + return ( +
+

My App

+ +
+ ); +} +``` + +The `Counter` function above will be automatically extracted into a separate client component module. The server component `App` will render a client reference to `Counter` instead of the function itself. + +You can also use inline client components inside other functions. The framework will automatically detect any variables captured from the parent scope and pass them as props to the extracted client component. + +```jsx +import { useState } from "react"; + +export default function App() { + const label = "clicks"; + const Counter = () => { + "use client"; + const [count, setCount] = useState(0); + return ( +
+

{label}: {count}

+ +
+ ); + }; + + return ( +
+

My App

+ +
+ ); +} +``` + +In this example, the `label` variable is captured from `App`'s scope and automatically forwarded as a prop to the client component. Module-level imports and top-level declarations used by the inline component are included in the extracted module directly. + +> **Note:** All captured variables must be serializable, just like regular client component props. Functions, class instances, and other non-serializable values cannot be captured. + + +## Inline server functions in client components + + +You can define `"use server"` functions directly inside an inline `"use client"` component. This lets you keep the server logic and the client UI that calls it in a single file, without creating separate modules for either. + +```jsx +import { useState, useTransition } from "react"; + +export default function App() { + function Counter() { + "use client"; + + const [count, setCount] = useState(0); + const [, startTransition] = useTransition(); + + async function increment(n) { + "use server"; + return n + 1; + } + + return ( +
+

Count: {count}

+ +
+ ); + } + + return ; +} +``` + +The framework extracts each directive into its own module automatically. The `Counter` component becomes a client module, and `increment` becomes a server function — no extra files needed. + + +## Inline client components in "use server" files + + +You can define inline `"use client"` components inside a file that already has a top-level `"use server"` directive. This lets a server function define and return interactive client components without needing a separate file. + +```jsx +"use server"; + +import { useState } from "react"; + +export async function createBadge(label) { + function Badge({ text }) { + "use client"; + + const [clicked, setClicked] = useState(false); + return ( + setClicked(true)} style={{ cursor: "pointer" }}> + {clicked ? `clicked:${text}` : text} + + ); + } + + return ; +} +``` + +The file is treated as a server function module because of the top-level `"use server"` directive, but the `Badge` component is extracted into a separate client module. When `createBadge` is called, it renders the `Badge` with the given props and returns it through the RSC protocol. The client component is fully interactive after hydration — `useState`, event handlers, and other client-side features all work as expected. + ## Client-only components diff --git a/docs/src/pages/en/(pages)/guide/server-functions.mdx b/docs/src/pages/en/(pages)/guide/server-functions.mdx index 798bf628..74ac24d6 100644 --- a/docs/src/pages/en/(pages)/guide/server-functions.mdx +++ b/docs/src/pages/en/(pages)/guide/server-functions.mdx @@ -201,3 +201,143 @@ export default function App() { ); } ``` + + +## Inline server functions with inline client components + + +You can combine inline `"use server"` functions and inline `"use client"` components in the same file. This is useful when a server function and the client UI that consumes it are closely related. + +```jsx +import { useState, useTransition } from "react"; + +const greeting = "Hello"; + +async function greet(name) { + "use server"; + return `${greeting}, ${name}!`; +} + +function Greeter() { + "use client"; + + const [message, setMessage] = useState(""); + const [, startTransition] = useTransition(); + + return ( +
+ + {message &&

{message}

} +
+ ); +} + +export default function App() { + return ; +} +``` + +Both the server function and the client component are extracted into separate modules automatically. The server function can capture variables from the module scope, and the client component can call the server function directly. + + +## Returning client components from server functions + + +A server function can define an inline `"use client"` component and return it as rendered JSX. The client component will be serialized through the RSC protocol and hydrated on the client, making it fully interactive. + +```jsx +import { useState, useTransition } from "react"; + +async function createCounter(initialCount) { + "use server"; + + function Counter({ start }) { + "use client"; + const [count, setCount] = useState(start); + return ( +
+

Count: {count}

+ +
+ ); + } + + return ; +} + +function Shell() { + "use client"; + + const [content, setContent] = useState(null); + const [, startTransition] = useTransition(); + + return ( +
+ + {content} +
+ ); +} + +export default function App() { + return ; +} +``` + +The `createCounter` server function defines a `Counter` client component, renders it with the given props, and returns the element. The framework extracts the nested directives into separate modules through a chain of virtual modules, so everything works from a single file. + + +## Inline server functions in "use client" files + + +You can define inline `"use server"` functions inside a file that already has a top-level `"use client"` directive. The server functions are automatically extracted from the client module, so you can keep related server logic and client UI together in a single file. + +```jsx +"use client"; + +import { useState, useTransition } from "react"; + +export default function TodoApp() { + const [items, setItems] = useState([]); + const [, startTransition] = useTransition(); + + async function addItem(text) { + "use server"; + return { id: Date.now(), text }; + } + + return ( +
+ +
    + {items.map((item) => ( +
  • {item.text}
  • + ))} +
+
+ ); +} +``` + +The file is treated as a client component because of the top-level `"use client"` directive, but the `addItem` function is extracted into a separate server module. This works the same way as defining the server function in a standalone `"use server"` file — the framework handles the extraction automatically. diff --git a/docs/src/pages/ja/(pages)/guide/client-components.mdx b/docs/src/pages/ja/(pages)/guide/client-components.mdx index 4de25618..206a1d7f 100644 --- a/docs/src/pages/ja/(pages)/guide/client-components.mdx +++ b/docs/src/pages/ja/(pages)/guide/client-components.mdx @@ -99,6 +99,69 @@ export default async function MyServerComponent() { } ``` + +## インラインクライアントコンポーネント + + +クライアントコンポーネントごとに別ファイルを作成する代わりに、関数本体の中で`"use client"`ディレクティブを使用できます。これにより、使用するサーバーコンポーネントのすぐ隣にクライアントコンポーネントをインラインで定義できます。 + +```jsx +import { useState } from "react"; + +function Counter() { + "use client"; + const [count, setCount] = useState(0); + return ( +
+

Count: {count}

+ +
+ ); +} + +export default function App() { + return ( +
+

My App

+ +
+ ); +} +``` + +上記の`Counter`関数は自動的に別のクライアントコンポーネントモジュールとして抽出されます。サーバーコンポーネント`App`は関数そのものではなく、`Counter`へのクライアント参照をレンダリングします。 + +他の関数内でもインラインクライアントコンポーネントを使用できます。フレームワークは親スコープからキャプチャされた変数を自動的に検出し、抽出されたクライアントコンポーネントにpropsとして渡します。 + +```jsx +import { useState } from "react"; + +export default function App() { + const label = "clicks"; + const Counter = () => { + "use client"; + const [count, setCount] = useState(0); + return ( +
+

{label}: {count}

+ +
+ ); + }; + + return ( +
+

My App

+ +
+ ); +} +``` + +この例では、`label`変数が`App`のスコープからキャプチャされ、クライアントコンポーネントにpropsとして自動的に転送されます。インラインコンポーネントが使用するモジュールレベルのインポートやトップレベルの宣言は、抽出されたモジュールに直接含まれます。 + +> **注意:** キャプチャされた変数はすべて、通常のクライアントコンポーネントのpropsと同様にシリアル化可能でなければなりません。関数、クラスインスタンス、その他のシリアル化できない値はキャプチャできません。 + ## クライアント専用コンポーネント diff --git a/packages/react-server/lib/build/client.mjs b/packages/react-server/lib/build/client.mjs index f51215ed..d0777e97 100644 --- a/packages/react-server/lib/build/client.mjs +++ b/packages/react-server/lib/build/client.mjs @@ -11,6 +11,9 @@ import fixEsbuildOptionsPlugin from "../plugins/fix-esbuildoptions.mjs"; import { generateClientComponentChunkGroups } from "../plugins/optimize-deps.mjs"; import resolveWorkspace from "../plugins/resolve-workspace.mjs"; import rolldownUseClient from "../plugins/use-client.mjs"; +import { useClientInlineConfig } from "../plugins/use-client-inline.mjs"; +import { useServerInlineConfig } from "../plugins/use-server-inline.mjs"; +import rolldownUseDirectiveInline from "../plugins/use-directive-inline.mjs"; import rolldownUseServer from "../plugins/use-server.mjs"; import rolldownUseCacheInline from "../plugins/use-cache-inline.mjs"; import rollupUseWorker, { @@ -361,6 +364,10 @@ export default async function clientBuild( options.dev ? "development" : "production" ), }), + rolldownUseDirectiveInline([ + useServerInlineConfig, + useClientInlineConfig, + ]), rolldownUseClient("client", undefined, "pre", clientManifestBus), rolldownUseClient("client"), rolldownUseServer("client"), diff --git a/packages/react-server/lib/build/manifest.mjs b/packages/react-server/lib/build/manifest.mjs index def4036c..22f6a588 100644 --- a/packages/react-server/lib/build/manifest.mjs +++ b/packages/react-server/lib/build/manifest.mjs @@ -99,18 +99,31 @@ export default async function manifest( (entry) => entry.isEntry ); const clientReferenceMap = {}; + const processedBuildEntries = new Set(); for (let i = 0; i < clientManifestEntries.length; i++) { const entry = clientManifestEntries[i]; const id = entry.name; const buildEntry = buildClientManifest.get(id); if (!buildEntry) continue; - const path = sys.normalizePath(relative(cwd, realpathSync(buildEntry.id))); + processedBuildEntries.add(id); + const qIdx = buildEntry.id.indexOf("?"); + const buildEntryPath = + qIdx === -1 ? buildEntry.id : buildEntry.id.slice(0, qIdx); + const buildEntryQuery = qIdx === -1 ? "" : buildEntry.id.slice(qIdx + 1); + const path = + sys.normalizePath(relative(cwd, realpathSync(buildEntryPath))) + + (buildEntryQuery ? `?${buildEntryQuery}` : ""); // Use the file path as the key - const key = `${path + const pathBase = path.split("?")[0]; + const pathQuery = path.includes("?") ? path.slice(path.indexOf("?")) : ""; + const key = `${pathBase .replace(/^(?:\.\.\/)+/, (match) => match.replace(/\.\.\//g, "__/")) - .replace(new RegExp(`${extname(path)}$`, "g"), "")}${extname(path)}`; + .replace( + new RegExp(`${extname(pathBase)}$`, "g"), + "" + )}${extname(pathBase)}${pathQuery}`; for (const name of buildEntry?.exports || []) { clientReferenceMap[`${key}#${name}`] = { @@ -132,6 +145,56 @@ export default async function manifest( if (i % 50 === 0) await yieldToEventLoop(); } + // Process buildClientManifest entries that were not found via the SSR + // output manifest (e.g. inline "use client" modules with query params + // that only appear as dynamic imports in the SSR build) + let extraIdx = 0; + for (const [id, buildEntry] of buildClientManifest) { + if (processedBuildEntries.has(id)) continue; + const qIdx = buildEntry.id.indexOf("?"); + const buildEntryPath = + qIdx === -1 ? buildEntry.id : buildEntry.id.slice(0, qIdx); + const buildEntryQuery = qIdx === -1 ? "" : buildEntry.id.slice(qIdx + 1); + let resolvedPath; + try { + resolvedPath = realpathSync(buildEntryPath); + } catch { + continue; + } + const path = + sys.normalizePath(relative(cwd, resolvedPath)) + + (buildEntryQuery ? `?${buildEntryQuery}` : ""); + + const pathBase = path.split("?")[0]; + const pathQuery = path.includes("?") ? path.slice(path.indexOf("?")) : ""; + const key = `${pathBase + .replace(/^(?:\.\.\/)+/, (match) => match.replace(/\.\.\//g, "__/")) + .replace( + new RegExp(`${extname(pathBase)}$`, "g"), + "" + )}${extname(pathBase)}${pathQuery}`; + + const browserEntry = browserManifestBySrc[path]; + if (!browserEntry) continue; + + for (const name of buildEntry?.exports || []) { + clientReferenceMap[`${key}#${name}`] = { + id: `/${browserEntry.file}`.replace(/\/+/, "/"), + chunks: [], + name, + async: true, + }; + clientReferenceMap[`/${browserEntry.file}`] = { + id: `/${browserEntry.file}`.replace(/\/+/, "/"), + chunks: [], + name, + async: true, + }; + } + + if (++extraIdx % 50 === 0) await yieldToEventLoop(); + } + const clientReferenceMapCode = `const map = ${JSON.stringify( clientReferenceMap, null, diff --git a/packages/react-server/lib/build/server.mjs b/packages/react-server/lib/build/server.mjs index f558c6ff..cb0a3705 100644 --- a/packages/react-server/lib/build/server.mjs +++ b/packages/react-server/lib/build/server.mjs @@ -20,7 +20,9 @@ import resolveWorkspace from "../plugins/resolve-workspace.mjs"; import reactServerLive from "../plugins/live.mjs"; import rootModule from "../plugins/root-module.mjs"; import rolldownUseClient from "../plugins/use-client.mjs"; -import rolldownUseServerInline from "../plugins/use-server-inline.mjs"; +import { useClientInlineConfig } from "../plugins/use-client-inline.mjs"; +import { useServerInlineConfig } from "../plugins/use-server-inline.mjs"; +import rolldownUseDirectiveInline from "../plugins/use-directive-inline.mjs"; import rolldownUseServer from "../plugins/use-server.mjs"; import rolldownUseCacheInline from "../plugins/use-cache-inline.mjs"; import rolldownUseDynamic from "../plugins/use-dynamic.mjs"; @@ -752,10 +754,13 @@ export default async function serverBuild(root, options, clientManifestBus) { } : {}), }), + rolldownUseDirectiveInline([ + useServerInlineConfig, + useClientInlineConfig, + ]), rolldownUseClient("rsc", clientManifest, "pre", clientManifestBus), rolldownUseClient("rsc", clientManifest, undefined), rolldownUseServer("rsc", serverManifest), - rolldownUseServerInline(serverManifest), rolldownUseCacheInline( config.cache?.profiles, config.cache?.providers, @@ -1043,7 +1048,11 @@ export default async function serverBuild(root, options, clientManifestBus) { } : {}), }), - rolldownUseClient("ssr", undefined, "pre", clientManifestBus), + rolldownUseDirectiveInline([ + useServerInlineConfig, + useClientInlineConfig, + ]), + rolldownUseClient("ssr", clientManifest, "pre", clientManifestBus), rolldownUseClient("ssr"), rolldownUseServer("ssr"), rolldownUseCacheInline( diff --git a/packages/react-server/lib/dev/create-server.mjs b/packages/react-server/lib/dev/create-server.mjs index ba4959b9..a89cee0d 100644 --- a/packages/react-server/lib/dev/create-server.mjs +++ b/packages/react-server/lib/dev/create-server.mjs @@ -50,9 +50,11 @@ import reactServerRuntime from "../plugins/react-server-runtime.mjs"; import resolveWorkspace from "../plugins/resolve-workspace.mjs"; import useCacheInline from "../plugins/use-cache-inline.mjs"; import useClient from "../plugins/use-client.mjs"; +import { useClientInlineConfig } from "../plugins/use-client-inline.mjs"; +import { useServerInlineConfig } from "../plugins/use-server-inline.mjs"; +import useDirectiveInline from "../plugins/use-directive-inline.mjs"; import useDynamic from "../plugins/use-dynamic.mjs"; import useServer from "../plugins/use-server.mjs"; -import useServerInline from "../plugins/use-server-inline.mjs"; import useWorker from "../plugins/use-worker.mjs"; import * as sys from "../sys.mjs"; import { makeResolveAlias } from "../utils/config.mjs"; @@ -258,10 +260,10 @@ export default async function createServer(root, options) { reactServerEval(options), reactServerRuntime(), ...userOrBuiltInVitePluginReact(config.plugins), + useDirectiveInline([useServerInlineConfig, useClientInlineConfig]), useClient(null, null, "pre"), useClient(), useServer(), - useServerInline(), useCacheInline(config.cache?.profiles, config.cache?.providers), useDynamic(), useWorker(), diff --git a/packages/react-server/lib/plugins/use-client-inline.mjs b/packages/react-server/lib/plugins/use-client-inline.mjs new file mode 100644 index 00000000..2f116a44 --- /dev/null +++ b/packages/react-server/lib/plugins/use-client-inline.mjs @@ -0,0 +1,63 @@ +// Inject captured scope variables as destructured props for client components. +function injectCapturedParams(fnSource, targetFn, capturedVars) { + const capturedList = capturedVars.join(", "); + + if (targetFn.params.length === 0) { + // () → ({ x, y }) + const openParen = fnSource.indexOf("("); + const closeParen = fnSource.indexOf(")", openParen); + if (openParen !== -1 && closeParen !== -1) { + fnSource = + fnSource.slice(0, openParen + 1) + + "{ " + + capturedList + + " }" + + fnSource.slice(closeParen); + } + } else if (targetFn.params.length === 1) { + const param = targetFn.params[0]; + const relStart = param.start - targetFn.start; + const relEnd = param.end - targetFn.start; + if (param.type === "Identifier") { + // (props) → ({ x, ...props }) + fnSource = + fnSource.slice(0, relStart) + + "{ " + + capturedList + + ", ..." + + param.name + + " }" + + fnSource.slice(relEnd); + } else if (param.type === "ObjectPattern") { + // ({ a }) → ({ x, a }) + fnSource = + fnSource.slice(0, relStart + 1) + + " " + + capturedList + + "," + + fnSource.slice(relStart + 1); + } + } + + return fnSource; +} + +export const useClientInlineConfig = { + directive: "use client", + queryKey: "use-client-inline", + skipIfModuleDirective: ["use client"], + injectCapturedParams, + buildCallSiteReplacement(importName, inlineId, capturedVars) { + if (capturedVars.length === 0) return null; // use default (inline import) + const capturedProps = capturedVars.join(", "); + return { + replacement: `(__props) => __useClientCreateElement(${importName}, { ...__props, ${capturedProps} })`, + prependImport: `import ${importName} from "${inlineId}";`, + }; + }, + getPrependImports() { + return [ + 'import { createElement as __useClientCreateElement } from "react";', + ]; + }, +}; diff --git a/packages/react-server/lib/plugins/use-client.mjs b/packages/react-server/lib/plugins/use-client.mjs index 0de26fbc..ee58c232 100644 --- a/packages/react-server/lib/plugins/use-client.mjs +++ b/packages/react-server/lib/plugins/use-client.mjs @@ -135,7 +135,7 @@ export default function useClient(type, manifest, enforce, clientComponentBus) { }, transform: { filter: { - id: /\.m?[jt]sx?$/, + id: /\.m?[jt]sx?(\?.*)?$/, }, async handler(code, id) { const viteEnv = this.environment.name; @@ -169,7 +169,9 @@ export default function useClient(type, manifest, enforce, clientComponentBus) { // Get real path - this is the canonical path after resolving symlinks // pnpm uses symlinks, so the same file can be accessed via multiple paths // Normalize to forward slashes so generated import() paths work on Windows - const realId = sys.normalizePath(await realpath(id)); + const filePath = id.split("?")[0]; + const query = id.includes("?") ? id.slice(id.indexOf("?")) : ""; + const realId = sys.normalizePath(await realpath(filePath)) + query; // DEDUPLICATION: If we've already processed this real path, return cached result // This prevents duplicate module graphs when Rolldown calls transform @@ -197,8 +199,9 @@ export default function useClient(type, manifest, enforce, clientComponentBus) { // Use realId (canonical path after symlink resolution) for consistent naming const specifier = sys.normalizePath(relative(cwd, realId)); + const specifierBase = specifier.split("?")[0]; const name = workspacePath(specifier) - .replace(extname(specifier), "") + .replace(extname(specifierBase), "") .replace(/[^@/\-a-zA-Z0-9]/g, "_") .replace( sys.normalizePath(relative(cwd, sys.rootDir)), @@ -221,6 +224,43 @@ export default function useClient(type, manifest, enforce, clientComponentBus) { }); } + // Populate clientManifest for both RSC and SSR so SSR's + // manifestGenerator can find the entry without racing the RSC build + if (manifest && enforce === "pre") { + const exportNames = new Set(); + if ( + ast.body.some( + (node) => + node.type === "ExportDefaultDeclaration" || + (node.type === "ExportNamedDeclaration" && + node.specifiers?.find( + ({ exported }) => exported?.name === "default" + )) + ) + ) { + exportNames.add("default"); + } + for (const node of ast.body) { + if (node.type === "ExportNamedDeclaration") { + const names = [ + ...(node.declaration?.id?.name + ? [node.declaration.id.name] + : []), + ...(node.declaration?.declarations?.map( + ({ id }) => id.name + ) || []), + ...node.specifiers.map(({ exported }) => exported.name), + ]; + names.forEach((n) => exportNames.add(n)); + } + } + manifest.set(name, { + id: realId, + name, + exports: Array.from(exportNames), + }); + } + if (type === "ssr") { return null; } diff --git a/packages/react-server/lib/plugins/use-directive-inline.mjs b/packages/react-server/lib/plugins/use-directive-inline.mjs new file mode 100644 index 00000000..2f6e3bd3 --- /dev/null +++ b/packages/react-server/lib/plugins/use-directive-inline.mjs @@ -0,0 +1,748 @@ +import { readFile } from "node:fs/promises"; +import { parse, walk } from "../utils/ast.mjs"; + +// ── AST helpers ────────────────────────────────────────────────────────────── + +// Collect all identifiers referenced inside a node +export function collectIdentifiers(node) { + const ids = new Set(); + walk(node, { + enter(n) { + if (n.type === "Identifier" || n.type === "JSXIdentifier") { + ids.add(n.name); + } + }, + }); + return ids; +} + +// Collect declared names from a pattern node (handles destructuring) +export function collectDeclaredNames(pattern, set) { + if (!pattern) return; + if (pattern.type === "Identifier") { + set.add(pattern.name); + } else if (pattern.type === "ObjectPattern") { + for (const prop of pattern.properties) { + collectDeclaredNames( + prop.type === "RestElement" ? prop.argument : prop.value, + set + ); + } + } else if (pattern.type === "ArrayPattern") { + for (const el of pattern.elements) { + if (el) collectDeclaredNames(el, set); + } + } else if (pattern.type === "RestElement") { + collectDeclaredNames(pattern.argument, set); + } else if (pattern.type === "AssignmentPattern") { + collectDeclaredNames(pattern.left, set); + } +} + +// Find variables captured from parent function scopes +// (not imports, not top-level declarations, not the function's own locals) +export function findCapturedVars(ast, targetFn) { + const importBindings = new Set(); + for (const node of ast.body) { + if (node.type === "ImportDeclaration") { + for (const s of node.specifiers) { + importBindings.add(s.local.name); + } + } + } + + const topLevelNames = new Set(); + for (const node of ast.body) { + const decl = + node.type === "ExportDefaultDeclaration" || + node.type === "ExportNamedDeclaration" + ? node.declaration + : node; + if (!decl) continue; + if (decl.type === "VariableDeclaration") { + for (const d of decl.declarations) { + if (d.id) collectDeclaredNames(d.id, topLevelNames); + } + } else if ( + (decl.type === "FunctionDeclaration" || + decl.type === "ClassDeclaration") && + decl.id?.name + ) { + topLevelNames.add(decl.id.name); + } + } + + const scopeStack = []; + let result = []; + let found = false; + + walk(ast, { + enter(node) { + if (found) return; + const isFn = + node.type === "FunctionDeclaration" || + node.type === "FunctionExpression" || + node.type === "ArrowFunctionExpression"; + if (!isFn) return; + + if (node === targetFn) { + const usedIds = collectIdentifiers(targetFn); + // Exclude the function's own declaration name — it's not a captured variable + if (targetFn.type === "FunctionDeclaration" && targetFn.id?.name) { + usedIds.delete(targetFn.id.name); + } + const scopeVars = new Set(); + for (const scope of scopeStack) { + for (const name of scope) scopeVars.add(name); + } + result = [...scopeVars].filter( + (name) => + usedIds.has(name) && + !importBindings.has(name) && + !topLevelNames.has(name) + ); + found = true; + return; + } + + const scope = new Set(); + for (const param of node.params || []) { + collectDeclaredNames(param, scope); + } + for (const stmt of node.body?.body || []) { + if (stmt.type === "VariableDeclaration") { + for (const d of stmt.declarations) { + if (d.id) collectDeclaredNames(d.id, scope); + } + } else if ( + (stmt.type === "FunctionDeclaration" || + stmt.type === "ClassDeclaration") && + stmt.id?.name + ) { + scope.add(stmt.id.name); + } + } + scopeStack.push(scope); + }, + leave(node) { + if (found) return; + const isFn = + node.type === "FunctionDeclaration" || + node.type === "FunctionExpression" || + node.type === "ArrowFunctionExpression"; + if (isFn && node !== targetFn) { + scopeStack.pop(); + } + }, + }); + + return result; +} + +// Find all functions that contain the given directive string +function findDirectiveFunctions(ast, directive) { + const results = []; + walk(ast, { + enter(node) { + const body = + node.type === "FunctionDeclaration" || + node.type === "FunctionExpression" || + node.type === "ArrowFunctionExpression" + ? node.body?.body + : null; + if (!Array.isArray(body)) return; + const hasDirective = body.some( + (n) => n.type === "ExpressionStatement" && n.directive === directive + ); + if (hasDirective) { + results.push(node); + } + }, + }); + return results; +} + +// Find only OUTERMOST directive functions across ALL directives. +// A function with "use server" nested inside a function with "use client" +// is NOT outermost — the "use client" wrapper is. +function findOutermostDirectiveFunctions(ast, directives) { + // Collect all directive functions with their directive info + const allFns = []; + for (const directive of directives) { + for (const fn of findDirectiveFunctions(ast, directive)) { + allFns.push({ fn, directive }); + } + } + + if (allFns.length === 0) return []; + + const allFnSet = new Set(allFns.map((f) => f.fn)); + + // Filter out any function that is nested inside another directive function + return allFns.filter(({ fn }) => { + for (const other of allFnSet) { + if (other === fn) continue; + // Check if `fn` is contained within `other` + if (fn.start >= other.start && fn.end <= other.end) { + return false; // fn is nested inside other — not outermost + } + } + return true; + }); +} + +// Build the extracted module source for a single directive function +function buildExtractedModule( + code, + ast, + targetFn, + directive, + capturedVars, + injectCapturedParams, + originalPath +) { + const imports = []; + for (const node of ast.body) { + if (node.type === "ImportDeclaration") { + imports.push({ + specifiers: node.specifiers.map((s) => ({ + localName: s.local.name, + })), + sourceText: code.slice(node.start, node.end), + }); + } + } + + const importBindings = new Map(); + for (const imp of imports) { + for (const spec of imp.specifiers) { + importBindings.set(spec.localName, imp); + } + } + + const topLevelDecls = new Map(); + for (const node of ast.body) { + if (node.type === "VariableDeclaration") { + for (const decl of node.declarations) { + if (decl.id?.name) { + topLevelDecls.set(decl.id.name, { + sourceText: code.slice(node.start, node.end), + }); + } + } + } else if ( + (node.type === "FunctionDeclaration" || + node.type === "ClassDeclaration") && + node.id?.name && + node !== targetFn + ) { + topLevelDecls.set(node.id.name, { + sourceText: code.slice(node.start, node.end), + }); + } + } + + const usedIdentifiers = collectIdentifiers(targetFn); + + const usedImportSet = new Set(); + for (const [name, imp] of importBindings) { + if (usedIdentifiers.has(name)) { + usedImportSet.add(imp); + } + } + + const usedDeclNames = []; + for (const [name] of topLevelDecls) { + if (usedIdentifiers.has(name)) { + usedDeclNames.push(name); + } + } + + // Get the function source, removing the directive statement + let fnSource = code.slice(targetFn.start, targetFn.end); + const body = targetFn.body?.body; + const directiveNode = body?.find( + (n) => n.type === "ExpressionStatement" && n.directive === directive + ); + if (directiveNode) { + const relStart = directiveNode.start - targetFn.start; + const relEnd = directiveNode.end - targetFn.start; + let endPos = relEnd; + while ( + endPos < fnSource.length && + (fnSource[endPos] === "\n" || fnSource[endPos] === "\r") + ) { + endPos++; + } + fnSource = fnSource.slice(0, relStart) + fnSource.slice(endPos); + } + + // Inject captured scope variables into the function signature + if (capturedVars.length > 0 && injectCapturedParams) { + fnSource = injectCapturedParams(fnSource, targetFn, capturedVars); + } + + const importStatements = Array.from(usedImportSet) + .map((imp) => imp.sourceText) + .join("\n"); + + // Import top-level declarations from the original module to preserve + // shared module state (e.g. mutable variables) instead of copying them. + const declImportStatement = + usedDeclNames.length > 0 + ? `import { ${usedDeclNames.join(", ")} } from "${originalPath}";` + : ""; + + return [ + `"${directive}";`, + "", + importStatements, + declImportStatement, + importStatements || declImportStatement ? "" : null, + `export default ${fnSource}`, + "", + ] + .filter((line) => line !== null) + .join("\n"); +} + +// ── Plugin factory ─────────────────────────────────────────────────────────── + +/** + * Create a single Vite plugin that extracts ALL inline directive functions + * ("use client", "use server", etc.) from outermost to innermost across + * multiple transform passes. + * + * @param {Array<{ + * directive: string, + * queryKey: string, + * skipIfModuleDirective?: string[], + * injectCapturedParams: function, + * buildCallSiteReplacement?: function, + * getPrependImports?: function, + * }>} configs - One config per supported directive + */ +export default function useDirectiveInline(configs) { + const moduleCache = new Map(); + let root = ""; + + // Build lookup maps + const configByDirective = new Map(); + const configByQueryKey = new Map(); + for (const cfg of configs) { + configByDirective.set(cfg.directive, cfg); + configByQueryKey.set(cfg.queryKey, cfg); + } + + const allDirectives = configs.map((c) => c.directive); + + // Test whether an id contains any of our query keys + function matchQueryKey(id) { + // Return the config whose marker appears LAST in the id. + // For nested extraction like file.jsx?use-client-inline=Counter&use-server-inline=increment + // we must return the last segment (use-server-inline), not the first. + let lastMatch = null; + let lastPos = -1; + for (const cfg of configs) { + // Match both ?key= (first param) and &key= (chained param) + for (const sep of ["?", "&"]) { + const marker = `${sep}${cfg.queryKey}=`; + const pos = id.indexOf(marker); + if (pos !== -1 && pos > lastPos) { + lastPos = pos; + lastMatch = { cfg, marker }; + } + } + } + return lastMatch; + } + + return { + name: "react-server:use-directive-inline", + enforce: "pre", + configResolved(config) { + root = config.root; + }, + + async resolveId(source, importer) { + if (matchQueryKey(source)) return source; + + // Resolve relative imports from our extracted virtual modules. + // Vite can't determine the correct directory for virtual module IDs + // with query params, so we strip the query and re-resolve. + if ( + importer && + matchQueryKey(importer) && + (source.startsWith("./") || source.startsWith("../")) + ) { + const cleanImporter = importer.slice(0, importer.indexOf("?")); + return this.resolve(source, cleanImporter, { skipSelf: true }); + } + }, + + async load(id) { + const match = matchQueryKey(id); + if (!match) return; + + const cached = moduleCache.get(id); + if (cached) return cached; + const { cfg, marker } = match; + const qIdx = id.indexOf(marker); + const rawPath = id.slice(0, qIdx); + const fnName = id.slice(qIdx + marker.length); + + // Strip ALL query params to get the real file path on disk + const basePath = rawPath.includes("?") + ? rawPath.slice(0, rawPath.indexOf("?")) + : rawPath; + const filePath = basePath.startsWith(root) ? basePath : root + basePath; + const sourceCode = await readFile(filePath, "utf-8"); + const ast = await parse(sourceCode, filePath); + if (!ast) return; + + const directiveFunctions = findDirectiveFunctions(ast, cfg.directive); + + let targetFn; + if (fnName.startsWith("anonymous_")) { + const index = parseInt(fnName.replace("anonymous_", ""), 10); + const anonymousFunctions = directiveFunctions.filter( + (fn) => !(fn.type === "FunctionDeclaration" && fn.id?.name) + ); + targetFn = anonymousFunctions[index]; + } else { + targetFn = directiveFunctions.find( + (fn) => fn.type === "FunctionDeclaration" && fn.id?.name === fnName + ); + } + + if (!targetFn) return; + + const capturedVars = findCapturedVars(ast, targetFn); + const extractedCode = buildExtractedModule( + sourceCode, + ast, + targetFn, + cfg.directive, + capturedVars, + cfg.injectCapturedParams, + rawPath + ); + moduleCache.set(id, extractedCode); + return extractedCode; + }, + + transform: { + filter: { + id: /\.m?[jt]sx?/, + }, + async handler(code, id) { + // Quick check: does the code contain ANY of the directive strings? + if (!allDirectives.some((d) => code.includes(d))) return null; + + // Strip query params so the parser can determine file type from extension + const parseId = id.includes("?") ? id.slice(0, id.indexOf("?")) : id; + const ast = await parse(code, parseId); + if (!ast) return null; + + // Collect module-level directives + const moduleDirectives = ast.body + .filter((node) => node.type === "ExpressionStatement") + .map(({ directive }) => directive); + + // If this is one of our extracted modules, determine which directive + // it was extracted for so we don't re-extract the same directive. + const ownMatch = matchQueryKey(id); + const ownDirective = ownMatch ? ownMatch.cfg.directive : null; + + // Find only outermost directive functions across ALL directives + let outermost = findOutermostDirectiveFunctions(ast, allDirectives); + + // Skip functions whose directive matches the one this module was + // extracted for (e.g. don't re-extract "use client" from a + // ?use-client-inline= or &use-client-inline= module, but DO extract "use server" from it) + if (ownDirective) { + outermost = outermost.filter( + ({ directive }) => directive !== ownDirective + ); + } + + if (outermost.length === 0) return null; + + // Filter out functions whose directive is configured to be skipped + // when the module itself has a certain directive + const toProcess = outermost.filter(({ directive }) => { + const cfg = configByDirective.get(directive); + if (cfg.skipIfModuleDirective) { + for (const skip of cfg.skipIfModuleDirective) { + if (moduleDirectives.includes(skip)) return false; + } + } + return true; + }); + + if (toProcess.length === 0) return null; + + const fnSet = new Set(toProcess.map((e) => e.fn)); + + // Collect identifiers used by remaining (non-directive, non-import) code + const usedByRemainingCode = new Set(); + let skipDepth = 0; + walk(ast, { + enter(node) { + if (fnSet.has(node) || node.type === "ImportDeclaration") { + skipDepth++; + return; + } + if ( + skipDepth === 0 && + (node.type === "Identifier" || node.type === "JSXIdentifier") + ) { + usedByRemainingCode.add(node.name); + } + }, + leave(node) { + if (fnSet.has(node) || node.type === "ImportDeclaration") { + skipDepth--; + } + }, + }); + + // Determine which imports become unused + const importsToRemove = new Set(); + for (const node of ast.body) { + if (node.type === "ImportDeclaration") { + const allUnused = node.specifiers.every( + (s) => !usedByRemainingCode.has(s.local.name) + ); + if (allUnused && node.specifiers.length > 0) { + importsToRemove.add(node); + } + } + } + + // Detect captured scope variables per function + const capturedVarsMap = new Map(); + const hasCapturedByDirective = new Map(); + for (const { fn: fnNode, directive } of toProcess) { + const captured = findCapturedVars(ast, fnNode); + if (captured.length > 0) { + capturedVarsMap.set(fnNode, captured); + hasCapturedByDirective.set(directive, true); + for (const name of captured) { + usedByRemainingCode.add(name); + } + } + } + + // Build source edits + const edits = []; + const anonymousIndexByDirective = new Map(); + + for (const { fn: fnNode, directive } of toProcess) { + const cfg = configByDirective.get(directive); + + let fnName; + if (fnNode.type === "FunctionDeclaration" && fnNode.id?.name) { + fnName = fnNode.id.name; + } else { + const idx = anonymousIndexByDirective.get(directive) || 0; + fnName = `anonymous_${idx}`; + anonymousIndexByDirective.set(directive, idx + 1); + } + + const sep = id.includes("?") ? "&" : "?"; + const inlineId = `${id}${sep}${cfg.queryKey}=${fnName}`; + const captured = capturedVarsMap.get(fnNode) || []; + + // Build and cache extracted module + const extractedCode = buildExtractedModule( + code, + ast, + fnNode, + directive, + captured, + cfg.injectCapturedParams, + id + ); + moduleCache.set(inlineId, extractedCode); + + if (fnNode.type === "FunctionDeclaration") { + const isTopLevel = ast.body.includes(fnNode); + let customResult = null; + if (cfg.buildCallSiteReplacement) { + const importName = `__useDirectiveInline_${fnName}`; + customResult = cfg.buildCallSiteReplacement( + importName, + inlineId, + captured + ); + } + + if (customResult) { + edits.push({ + start: fnNode.start, + end: fnNode.end, + replacement: `const ${fnNode.id.name} = ${customResult.replacement};`, + prependImport: customResult.prependImport, + }); + } else if (isTopLevel) { + edits.push({ + start: fnNode.start, + end: fnNode.end, + replacement: `import ${fnNode.id.name} from "${inlineId}";`, + }); + } else { + const importName = `__useDirectiveInline_${fnName}`; + edits.push({ + start: fnNode.start, + end: fnNode.end, + replacement: `const ${fnNode.id.name} = ${importName};`, + prependImport: `import ${importName} from "${inlineId}";`, + }); + } + } else { + let customResult = null; + if (cfg.buildCallSiteReplacement) { + const importName = `__useDirectiveInline_${fnName}`; + customResult = cfg.buildCallSiteReplacement( + importName, + inlineId, + captured + ); + } + + if (customResult) { + edits.push({ + start: fnNode.start, + end: fnNode.end, + replacement: customResult.replacement, + prependImport: customResult.prependImport, + }); + } else { + const importName = `__useDirectiveInline_${fnName}`; + edits.push({ + start: fnNode.start, + end: fnNode.end, + replacement: importName, + prependImport: `import ${importName} from "${inlineId}";`, + }); + } + } + } + + // Remove unused imports + for (const node of importsToRemove) { + let end = node.end; + while ( + end < code.length && + (code[end] === "\n" || code[end] === "\r") + ) { + end++; + } + edits.push({ start: node.start, end, replacement: "" }); + } + + // Sort descending so string offsets stay valid + edits.sort((a, b) => b.start - a.start); + + let modifiedCode = code; + const prependImports = []; + for (const edit of edits) { + modifiedCode = + modifiedCode.slice(0, edit.start) + + edit.replacement + + modifiedCode.slice(edit.end); + if (edit.prependImport) { + prependImports.push(edit.prependImport); + } + } + + // Add directive-specific extra imports + for (const cfg of configs) { + if ( + hasCapturedByDirective.get(cfg.directive) && + cfg.getPrependImports + ) { + prependImports.unshift(...cfg.getPrependImports()); + } + } + + if (prependImports.length > 0) { + // Insert imports AFTER any leading directive (e.g. "use client";) + // so the directive stays at the top and is detected by other plugins. + const directiveMatch = modifiedCode.match( + /^(\s*(?:"use (?:client|server)"|'use (?:client|server)');\s*\n?)/ + ); + if (directiveMatch) { + const directivePart = directiveMatch[1]; + const rest = modifiedCode.slice(directivePart.length); + modifiedCode = + directivePart + prependImports.join("\n") + "\n" + rest; + } else { + modifiedCode = prependImports.join("\n") + "\n" + modifiedCode; + } + } + + // Export top-level declarations used by extracted functions so that the + // extracted virtual modules can import them (sharing module state). + const topLevelDeclNamesForExport = new Map(); + for (const node of ast.body) { + if (node.type === "VariableDeclaration") { + for (const decl of node.declarations) { + if (decl.id?.name) + topLevelDeclNamesForExport.set(decl.id.name, true); + } + } else if ( + (node.type === "FunctionDeclaration" || + node.type === "ClassDeclaration") && + node.id?.name + ) { + topLevelDeclNamesForExport.set(node.id.name, true); + } + } + + const declsUsedByExtracted = new Set(); + for (const { fn: fnNode } of toProcess) { + const usedIds = collectIdentifiers(fnNode); + for (const name of usedIds) { + if (topLevelDeclNamesForExport.has(name)) { + declsUsedByExtracted.add(name); + } + } + } + + if (declsUsedByExtracted.size > 0) { + // Avoid duplicating existing exports + const existingExports = new Set(); + for (const node of ast.body) { + if (node.type === "ExportNamedDeclaration") { + if ( + node.declaration?.type === "FunctionDeclaration" && + node.declaration.id?.name + ) { + existingExports.add(node.declaration.id.name); + } + if (node.declaration?.type === "VariableDeclaration") { + for (const d of node.declaration.declarations) { + if (d.id?.name) existingExports.add(d.id.name); + } + } + for (const spec of node.specifiers || []) { + existingExports.add(spec.exported?.name || spec.local?.name); + } + } + } + + const toExport = [...declsUsedByExtracted].filter( + (n) => !existingExports.has(n) + ); + if (toExport.length > 0) { + modifiedCode += `\nexport { ${toExport.join(", ")} };\n`; + } + } + + return modifiedCode; + }, + }, + }; +} diff --git a/packages/react-server/lib/plugins/use-server-inline.mjs b/packages/react-server/lib/plugins/use-server-inline.mjs index 1c416389..ec57557a 100644 --- a/packages/react-server/lib/plugins/use-server-inline.mjs +++ b/packages/react-server/lib/plugins/use-server-inline.mjs @@ -1,292 +1,51 @@ -import { extname, relative } from "node:path"; - -import * as sys from "../sys.mjs"; -import { codegen, parse, walk } from "../utils/ast.mjs"; - -const cwd = sys.cwd(); - -export default function useServerInline(manifest) { - return { - name: "react-server:use-server-inline", - transform: { - filter: { - id: /\.m?[jt]sx?$/, - }, - async handler(code, id) { - if (!code.includes("use server")) return null; - - const ast = await parse(code, id); - if (!ast) return null; - - const directives = ast.body - .filter((node) => node.type === "ExpressionStatement") - .map(({ directive }) => directive); - - if (directives.includes("use client")) - throw new Error( - "Cannot use both 'use client' and 'use server' in the same module." - ); - - const actions = []; - const locals = []; - let parent = null; - let useServerNode = null; - let useServerAction = null; - - const actionKey = (node) => - `__react_server_action__line${node.loc.start.line}_col${node.loc.start.column}__`; - - walk(ast, { - enter(node) { - node.parent = parent; - - if ( - node.body?.body?.find?.( - (node) => - node.type === "ExpressionStatement" && - node.directive === "use server" - ) - ) { - useServerNode = node; - useServerAction = { - node, - parent, - name: actionKey(node), - identifier: - node.type === "FunctionDeclaration" ? node.id.name : null, - params: [], - locals: [], - }; - actions.push(useServerAction); - } - - if (useServerNode && node.type === "Identifier") { - if ( - locals.includes(node.name) && - !useServerAction.params.includes(node.name) - ) { - useServerAction.params.push(node.name); - } - } - - if (node.type === "VariableDeclarator") { - let parent = node.parent; - while (parent) { - if ( - parent.type === "FunctionDeclaration" || - parent.type === "FunctionExpression" || - parent.type === "ArrowFunctionExpression" - ) - break; - parent = parent.parent; - } - if (parent) { - if (useServerNode) { - useServerAction.locals.push(node.id.name); - } else { - locals.push(node.id.name); - } - } - } - - parent = node; - }, - leave(node) { - if (node === useServerNode) { - if (useServerAction.params.length > 0) { - useServerNode.type = "CallExpression"; - useServerNode.callee = { - type: "MemberExpression", - object: { - type: "Identifier", - name: useServerAction.name, - }, - property: { - type: "Identifier", - name: "bind", - }, - }; - useServerNode.arguments = [ - { - type: "Literal", - value: null, - }, - ...useServerAction.params.map((param) => ({ - type: "Identifier", - name: param, - })), - ]; - } else { - useServerNode.type = "Identifier"; - useServerNode.name = useServerAction.name; - } - - if ( - useServerAction.parent?.type === "BlockStatement" || - useServerAction.parent?.type === "Program" - ) { - useServerAction.parent.body = useServerAction.parent.body.map( - (n) => - n === useServerAction.node - ? { - type: "VariableDeclaration", - kind: "const", - declarations: [ - { - type: "VariableDeclarator", - id: { - type: "Identifier", - name: useServerAction.identifier, - }, - init: useServerNode, - }, - ], - } - : n - ); - } - - useServerNode = null; - useServerAction = null; - } - - parent = node.parent ?? null; - }, - }); - - if (actions.length === 0) return null; - - ast.body.unshift({ - type: "ImportDeclaration", - specifiers: [ - { - type: "ImportSpecifier", - imported: { - type: "Identifier", - name: "registerServerReference", - }, - local: { - type: "Identifier", - name: "registerServerReference", - }, - }, - ], - source: { - type: "Literal", - value: `${sys.rootDir}/server/action-register.mjs`, - }, - importKind: "value", - }); - - const exportedNames = new Set(); - for (const action of actions) { - let argsName; - if (action.params.length > 0) { - argsName = `args__${action.name}`; - action.node.body.body.unshift({ - type: "VariableDeclaration", - kind: "let", - declarations: [ - { - type: "VariableDeclarator", - id: { - type: "ArrayPattern", - elements: [ - ...action.params.map((param) => ({ - type: "VariableDeclarator", - id: { - type: "Identifier", - name: param, - }, - })), - ...action.node.params, - ], - }, - init: { - type: "Identifier", - name: argsName, - }, - }, - ], - }); - } - ast.body.push( - { - type: "ExportNamedDeclaration", - declaration: { - type: "FunctionDeclaration", - async: true, - id: { - type: "Identifier", - name: action.name, - }, - params: [ - ...(argsName - ? [ - { - type: "RestElement", - argument: { - type: "Identifier", - name: argsName, - }, - }, - ] - : action.node.params), - ], - body: action.node.body, - }, - }, - { - type: "ExpressionStatement", - expression: { - type: "CallExpression", - callee: { - type: "Identifier", - name: "registerServerReference", - }, - arguments: [ - { - type: "Identifier", - name: action.name, - }, - { - type: "Literal", - value: manifest - ? sys - .normalizePath(relative(cwd, id)) - .replace(/\.m?[jt]sx?$/, "") - : id, - }, - { - type: "Literal", - value: action.name, - }, - ], - }, - } - ); - exportedNames.add(action.name); - } - - if (manifest) { - const specifier = sys.normalizePath(relative(cwd, id)); - const name = specifier - .replace(extname(specifier), "") - .replace(/[^@/\-a-zA-Z0-9]/g, "_"); - manifest.set(name, { - id: specifier, - exports: Array.from(exportedNames), - }); - - this.emitFile({ - type: "chunk", - id: `virtual:rsc:react-server-reference:inline:${specifier}`, - name, - }); - } - - return codegen(ast, id); - }, - }, - }; +// Inject captured scope variables as prepended function parameters for server functions. +function injectCapturedParams(fnSource, targetFn, capturedVars) { + const capturedList = capturedVars.join(", "); + + if (targetFn.params.length === 0) { + // () → (x, y) + const openParen = fnSource.indexOf("("); + const closeParen = fnSource.indexOf(")", openParen); + if (openParen !== -1 && closeParen !== -1) { + fnSource = + fnSource.slice(0, openParen + 1) + + capturedList + + fnSource.slice(closeParen); + } + } else { + // (data) → (x, y, data) + const firstParam = targetFn.params[0]; + const relStart = firstParam.start - targetFn.start; + fnSource = + fnSource.slice(0, relStart) + + capturedList + + ", " + + fnSource.slice(relStart); + } + + return fnSource; } + +export const useServerInlineConfig = { + directive: "use server", + queryKey: "use-server-inline", + // Do NOT skip "use client" modules — we want "use server" inside "use client" to work + skipIfModuleDirective: null, + injectCapturedParams, + buildCallSiteReplacement(importName, inlineId, capturedVars) { + const prependImport = `import "${inlineId}";\nimport ${importName} from "${inlineId}";`; + + if (capturedVars.length === 0) { + return { + replacement: importName, + prependImport, + }; + } + + const capturedArgs = capturedVars.join(", "); + return { + replacement: `${importName}.bind(null, ${capturedArgs})`, + prependImport, + }; + }, +}; diff --git a/packages/react-server/lib/plugins/use-server.mjs b/packages/react-server/lib/plugins/use-server.mjs index 6414c6df..93373ae3 100644 --- a/packages/react-server/lib/plugins/use-server.mjs +++ b/packages/react-server/lib/plugins/use-server.mjs @@ -11,13 +11,15 @@ export default function useServer(type, manifest) { name: "react-server:use-server", transform: { filter: { - id: /\.m?[jt]sx?$/, + id: /\.m?[jt]sx?(\?.*)?$/, }, async handler(code, id, options) { const mode = this.environment.mode; if (!code.includes("use server")) return null; - const ast = await parse(code, id); + // Strip query params so the parser can determine file type from extension + const parseId = id.includes("?") ? id.slice(0, id.indexOf("?")) : id; + const ast = await parse(code, parseId); if (!ast) return null; const directives = ast.body @@ -30,9 +32,14 @@ export default function useServer(type, manifest) { "Cannot use both 'use client' and 'use server' in the same module." ); + // Strip query params for path operations + const basePath = id.includes("?") ? id.slice(0, id.indexOf("?")) : id; const actionId = mode === "build" - ? sys.normalizePath(relative(cwd, id)).replace(/\.m?[jt]sx?$/, "") + ? sys + .normalizePath(relative(cwd, basePath)) + .replace(/\.m?[jt]sx?$/, "") + + (id.includes("?") ? id.slice(id.indexOf("?")) : "") : id; const exportNames = new Set(); const defaultExport = ast.body.find( @@ -170,6 +177,18 @@ export default function useServer(type, manifest) { }, }; }), + // Re-export _default as default export when present + ...(exports.includes("_default") + ? [ + { + type: "ExportDefaultDeclaration", + declaration: { + type: "Identifier", + name: "_default", + }, + }, + ] + : []), ]; } else if (this.environment?.name === "client" || !options.ssr) { ast.body = [ @@ -299,8 +318,10 @@ export default function useServer(type, manifest) { }); } - const specifier = sys.normalizePath(relative(cwd, id)); - const name = specifier.replace(extname(specifier), ""); + const specifier = + sys.normalizePath(relative(cwd, basePath)) + + (id.includes("?") ? id.slice(id.indexOf("?")) : ""); + const name = specifier.replace(extname(basePath), ""); if (manifest) { manifest.set(name, { @@ -317,9 +338,15 @@ export default function useServer(type, manifest) { }); if (type !== "client") { + // Inline-extracted modules (with query params like ?use-server-inline=fn) + // need `:inline:` marker so manifestGenerator uses join(cwd, refId) + // for the import path, and must use the relative specifier (not + // absolute id) so the lookup against manifest entry.id succeeds. + const isInlineExtracted = + /[?&]use-(?:server|client|cache)-inline=/.test(id); this.emitFile({ type: "chunk", - id: `virtual:${type}:react-server-reference:${id}`, + id: `virtual:${type}:react-server-reference:${isInlineExtracted ? `inline:${specifier}` : id}`, name, }); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a87b38e7..922be0e1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,6 +75,9 @@ importers: docs: dependencies: + '@docsearch/css': + specifier: ^3.6.0 + version: 3.6.0 '@docsearch/react': specifier: '3' version: 3.6.0(@algolia/client-search@5.10.2)(@types/react@18.3.5)(search-insights@2.14.0) @@ -117,18 +120,27 @@ importers: highlight.js: specifier: ^11.9.0 version: 11.9.0 + katex: + specifier: ^0.16.38 + version: 0.16.38 lucide-react: specifier: ^0.408.0 version: 0.408.0 rehype-highlight: specifier: ^7.0.0 version: 7.0.0 + rehype-katex: + specifier: ^7.0.1 + version: 7.0.1 rehype-mdx-code-props: specifier: ^3.0.1 version: 3.0.1 remark-gfm: specifier: ^4.0.0 version: 4.0.0 + remark-math: + specifier: ^6.0.0 + version: 6.0.0 three: specifier: ^0.183.1 version: 0.183.1 @@ -536,7 +548,7 @@ importers: version: 6.3.4 ts-jest: specifier: ^29.1.0 - version: 29.2.5(@babel/core@7.28.5)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.5))(jest@29.7.0(@types/node@20.17.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.11.21)(@types/node@20.17.11)(typescript@5.7.2)))(typescript@5.7.2) + version: 29.2.5(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest@29.7.0(@types/node@20.17.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.11.21)(@types/node@20.17.11)(typescript@5.7.2)))(typescript@5.7.2) ts-loader: specifier: ^9.4.3 version: 9.5.1(typescript@5.7.2)(webpack@5.97.1(@swc/core@1.11.21)) @@ -5581,6 +5593,9 @@ packages: '@types/jsonwebtoken@9.0.8': resolution: {integrity: sha512-7fx54m60nLFUVYlxAB1xpe9CBWX2vSrk50Y6ogRJ1v5xxtba7qXTg5BgYDN5dq+yuQQ9HaVlHJyAAt1/mxryFg==} + '@types/katex@0.16.8': + resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==} + '@types/lodash.mergewith@4.6.7': resolution: {integrity: sha512-3m+lkO5CLRRYU0fhGRp7zbsGi6+BZj0uTVSwvcKU+nSlhjA9/QRNfuSGnD2mX6hQA7ZbmcCkzk5h4ZYGOtk14A==} @@ -6493,6 +6508,10 @@ packages: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + comment-json@4.2.5: resolution: {integrity: sha512-bKw/r35jR3HGt5PEPm1ljsQQGyCrR8sFGNiN5L+ykDHdpO8Smxkrkla9Yi6NkQyUrb8V54PGhfMs6NrIwtxtdw==} engines: {node: '>= 6'} @@ -7665,6 +7684,12 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hast-util-from-dom@5.0.1: + resolution: {integrity: sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==} + + hast-util-from-html-isomorphic@2.0.0: + resolution: {integrity: sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==} + hast-util-from-html@2.0.3: resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==} @@ -8366,6 +8391,10 @@ packages: jws@3.2.2: resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} + katex@0.16.38: + resolution: {integrity: sha512-cjHooZUmIAUmDsHBN+1n8LaZdpmbj03LtYeYPyuYB7OuloiaeaV6N4LcfjcnHVzGWjVQmKrxxTrpDcmSzEZQwQ==} + hasBin: true + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -8827,6 +8856,9 @@ packages: mdast-util-gfm@3.0.0: resolution: {integrity: sha512-dgQEX5Amaq+DuUqf26jJqSK9qgixgd6rYDHAv4aTBuA92cTknZlKpPfa86Z/s8Dj8xsAQpFfBmPUHWJBWqS4Bw==} + mdast-util-math@3.0.0: + resolution: {integrity: sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==} + mdast-util-mdx-expression@2.0.0: resolution: {integrity: sha512-fGCu8eWdKUKNu5mohVGkhBXCXGnOTLuFqOvGMvdikr+J1w7lDJgxThOKpwRWzzbyXAU2hhSwsmssOY4yTokluw==} @@ -8922,6 +8954,9 @@ packages: micromark-extension-gfm@3.0.0: resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + micromark-extension-math@3.1.0: + resolution: {integrity: sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==} + micromark-extension-mdx-expression@3.0.0: resolution: {integrity: sha512-sI0nwhUDz97xyzqJAbHQhp5TfaxEvZZZ2JDqUo+7NvyIYG6BZ5CPPqj2ogUoPJlmXHBnyZUzISg9+oUmU6tUjQ==} @@ -10224,6 +10259,9 @@ packages: resolution: {integrity: sha512-IzhP6/u/6sm49sdktuYSmeIuObWB+5yC/5eqVws8BhuGA9kY25/byz6uCy/Ravj6lXUShEd2ofHM5MyAIj86Sg==} engines: {node: '>=16'} + rehype-katex@7.0.1: + resolution: {integrity: sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==} + rehype-mdx-code-props@3.0.1: resolution: {integrity: sha512-BWWKn0N6r7/qd7lbLgv5J8of7imz1l1PyCNoY7BH0AOR9JdJlQIfA9cKqTZVEb2h2GPKh473qrBajF0i01fq3A==} @@ -10262,6 +10300,9 @@ packages: resolution: {integrity: sha512-OPNnimcKeozWN1w8KVQEuHOxgN3L4rah8geMOLhA5vN9wITqU4FWD+G26tkEsCGHiOVDbISx+Se5rGZ+D1p0Jg==} engines: {node: '>=16'} + remark-math@6.0.0: + resolution: {integrity: sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==} + remark-mdx-frontmatter@4.0.0: resolution: {integrity: sha512-PZzAiDGOEfv1Ua7exQ8S5kKxkD8CDaSb4nM+1Mprs6u8dyvQifakh+kCj6NovfGXW+bTvrhjaR3srzjS2qJHKg==} @@ -12245,21 +12286,45 @@ snapshots: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + optional: true + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + optional: true + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + optional: true + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + optional: true + '@babel/plugin-syntax-import-assertions@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 @@ -12270,16 +12335,34 @@ snapshots: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-import-attributes@7.26.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + optional: true + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + optional: true + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + optional: true + '@babel/plugin-syntax-jsx@7.24.7(@babel/core@7.26.10)': dependencies: '@babel/core': 7.26.10 @@ -12300,41 +12383,89 @@ snapshots: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + optional: true + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + optional: true + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + optional: true + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + optional: true + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + optional: true + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + optional: true + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + optional: true + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.27.1 + optional: true + '@babel/plugin-syntax-typescript@7.24.7(@babel/core@7.26.10)': dependencies: '@babel/core': 7.26.10 @@ -16884,6 +17015,8 @@ snapshots: '@types/ms': 0.7.34 '@types/node': 20.17.11 + '@types/katex@0.16.8': {} + '@types/lodash.mergewith@4.6.7': dependencies: '@types/lodash': 4.17.7 @@ -17610,6 +17743,20 @@ snapshots: transitivePeerDependencies: - supports-color + babel-jest@29.7.0(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@jest/transform': 29.7.0 + '@types/babel__core': 7.20.5 + babel-plugin-istanbul: 6.1.1 + babel-preset-jest: 29.6.3(@babel/core@7.29.0) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + optional: true + babel-plugin-istanbul@6.1.1: dependencies: '@babel/helper-plugin-utils': 7.27.1 @@ -17652,12 +17799,39 @@ snapshots: '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.28.5) '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.28.5) + babel-preset-current-node-syntax@1.1.0(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.0) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.0) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.0) + '@babel/plugin-syntax-import-attributes': 7.26.0(@babel/core@7.29.0) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.0) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.0) + optional: true + babel-preset-jest@29.6.3(@babel/core@7.28.5): dependencies: '@babel/core': 7.28.5 babel-plugin-jest-hoist: 29.6.3 babel-preset-current-node-syntax: 1.1.0(@babel/core@7.28.5) + babel-preset-jest@29.6.3(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + babel-plugin-jest-hoist: 29.6.3 + babel-preset-current-node-syntax: 1.1.0(@babel/core@7.29.0) + optional: true + bail@2.0.2: {} balanced-match@1.0.2: {} @@ -17967,6 +18141,8 @@ snapshots: commander@4.1.1: {} + commander@8.3.0: {} + comment-json@4.2.5: dependencies: array-timsort: 1.0.3 @@ -19242,6 +19418,19 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-from-dom@5.0.1: + dependencies: + '@types/hast': 3.0.4 + hastscript: 9.0.1 + web-namespaces: 2.0.1 + + hast-util-from-html-isomorphic@2.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-from-dom: 5.0.1 + hast-util-from-html: 2.0.3 + unist-util-remove-position: 5.0.0 + hast-util-from-html@2.0.3: dependencies: '@types/hast': 3.0.4 @@ -20230,6 +20419,10 @@ snapshots: jwa: 1.4.1 safe-buffer: 5.2.1 + katex@0.16.38: + dependencies: + commander: 8.3.0 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -20699,6 +20892,18 @@ snapshots: transitivePeerDependencies: - supports-color + mdast-util-math@3.0.0: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + longest-streak: 3.1.0 + mdast-util-from-markdown: 2.0.1 + mdast-util-to-markdown: 2.1.0 + unist-util-remove-position: 5.0.0 + transitivePeerDependencies: + - supports-color + mdast-util-mdx-expression@2.0.0: dependencies: '@types/estree-jsx': 1.0.5 @@ -20891,6 +21096,16 @@ snapshots: micromark-util-combine-extensions: 2.0.0 micromark-util-types: 2.0.0 + micromark-extension-math@3.1.0: + dependencies: + '@types/katex': 0.16.8 + devlop: 1.1.0 + katex: 0.16.38 + micromark-factory-space: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + micromark-extension-mdx-expression@3.0.0: dependencies: '@types/estree': 1.0.6 @@ -22429,6 +22644,16 @@ snapshots: unified: 11.0.5 unist-util-visit: 5.0.0 + rehype-katex@7.0.1: + dependencies: + '@types/hast': 3.0.4 + '@types/katex': 0.16.8 + hast-util-from-html-isomorphic: 2.0.0 + hast-util-to-text: 4.0.2 + katex: 0.16.38 + unist-util-visit-parents: 6.0.1 + vfile: 6.0.1 + rehype-mdx-code-props@3.0.1: dependencies: '@types/hast': 3.0.4 @@ -22522,6 +22747,15 @@ snapshots: dependencies: unist-util-visit: 5.0.0 + remark-math@6.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-math: 3.0.0 + micromark-extension-math: 3.1.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + remark-mdx-frontmatter@4.0.0: dependencies: '@types/mdast': 4.0.4 @@ -23414,7 +23648,7 @@ snapshots: ts-interface-checker@0.1.13: {} - ts-jest@29.2.5(@babel/core@7.28.5)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.5))(jest@29.7.0(@types/node@20.17.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.11.21)(@types/node@20.17.11)(typescript@5.7.2)))(typescript@5.7.2): + ts-jest@29.2.5(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(jest@29.7.0(@types/node@20.17.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.11.21)(@types/node@20.17.11)(typescript@5.7.2)))(typescript@5.7.2): dependencies: bs-logger: 0.2.6 ejs: 3.1.10 @@ -23428,10 +23662,10 @@ snapshots: typescript: 5.7.2 yargs-parser: 21.1.1 optionalDependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.29.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.28.5) + babel-jest: 29.7.0(@babel/core@7.29.0) ts-loader@9.5.1(typescript@5.7.2)(webpack@5.97.1(@swc/core@1.11.21)): dependencies: diff --git a/test/__test__/basic.spec.mjs b/test/__test__/basic.spec.mjs index 14355570..1d67fb85 100644 --- a/test/__test__/basic.spec.mjs +++ b/test/__test__/basic.spec.mjs @@ -71,15 +71,7 @@ test("html and client-only counter", async () => { await testClientOnly(); }); -for (const id of [ - "inline-jsx-prop", - "inline-server-action-function", - "inline-server-action-arrow", - "inline-server-action-top-level", - "server-action", - "call-action-prop", - "call-action-import", -]) { +for (const id of ["server-action", "call-action-prop", "call-action-import"]) { test(`${id} server action`, async () => { await server("fixtures/server-actions.jsx"); await page.goto(hostname); diff --git a/test/__test__/use-inline.spec.mjs b/test/__test__/use-inline.spec.mjs new file mode 100644 index 00000000..60017e30 --- /dev/null +++ b/test/__test__/use-inline.spec.mjs @@ -0,0 +1,444 @@ +import { + hostname, + logs, + page, + server, + waitForChange, + waitForHydration, +} from "playground/utils"; +import { expect, test } from "vitest"; + +// --------------------------------------------------------------------------- +// "use client" inline +// --------------------------------------------------------------------------- + +test("use client inline", async () => { + await server("fixtures/use-client-inline.jsx"); + await page.goto(hostname); + await waitForHydration(); + + expect(await page.textContent("h1")).toBe( + '"use client" inline temp server scope' + ); + + // Counter (top-level FunctionDeclaration) + const buttons = await page.getByRole("button").all(); + expect(buttons.length).toBe(2); + + expect(await page.locator("p").nth(0).textContent()).toContain("Count: 0"); + await waitForChange( + () => buttons[0].click(), + () => page.locator("p").nth(0).textContent() + ); + expect(await page.locator("p").nth(0).textContent()).toContain("Count: 1"); + + // Counter2 (nested arrow function) + expect(await page.locator("p").nth(1).textContent()).toContain("Count2: 0"); + await waitForChange( + () => buttons[1].click(), + () => page.locator("p").nth(1).textContent() + ); + expect(await page.locator("p").nth(1).textContent()).toContain("Count2: 1"); +}); + +test("use client inline with props", async () => { + await server("fixtures/use-client-inline-props.jsx"); + await page.goto(hostname); + await waitForHydration(); + + // Badge — top-level client component with explicit prop, captures "shared" + expect(await page.getByTestId("badge").textContent()).toBe("shared tag"); + + // Input — top-level client component with onChange prop + const input = page.getByTestId("input"); + expect(await input.getAttribute("placeholder")).toBe("type here"); + await input.fill("test"); + expect(await input.inputValue()).toBe("test"); + + // Greeting — nested client component with destructured prop, captures "greeting" + expect(await page.getByTestId("greeting").textContent()).toBe("hello world"); + + // Display — nested client component with rest props, captures "greeting" + const display = page.getByTestId("display"); + expect(await display.textContent()).toBe("hello content"); + expect(await display.getAttribute("title")).toBe("box"); +}); + +// --------------------------------------------------------------------------- +// "use server" inline in "use client" inline +// --------------------------------------------------------------------------- + +test("use server inline in use client inline", async () => { + await server("fixtures/use-server-in-client.jsx"); + await page.goto(hostname); + await waitForHydration(); + + expect(await page.getByTestId("count").textContent()).toBe("Count: 0"); + + // Increment (FunctionDeclaration server action) + await page.getByTestId("increment").click(); + await waitForChange(null, () => page.getByTestId("count").textContent()); + expect(await page.getByTestId("count").textContent()).toBe("Count: 1"); + + // Decrement (arrow function server action) + await page.getByTestId("decrement").click(); + await waitForChange( + null, + () => page.getByTestId("count").textContent(), + "Count: 1" + ); + expect(await page.getByTestId("count").textContent()).toBe("Count: 0"); +}); + +// --------------------------------------------------------------------------- +// "use server" inline with captured variables +// --------------------------------------------------------------------------- + +test("use server inline with captured variables", async () => { + await server("fixtures/use-server-inline-captured.jsx"); + await page.goto(hostname); + await waitForHydration(); + + // multiply — top-level server action capturing module-scope `multiplier` and `label` + await page.getByTestId("multiply-btn").click(); + await expect + .poll( + () => + page + .getByTestId("multiply-result") + .textContent() + .catch(() => null), + { timeout: 30000 } + ) + .toBe('{"result":21}'); + + // add — nested FunctionDeclaration capturing `offset` from component scope + await page.getByTestId("add-btn").click(); + await expect + .poll( + () => + page + .getByTestId("add-result") + .textContent() + .catch(() => null), + { timeout: 30000 } + ) + .toBe('{"result":15}'); + + // subtract — nested arrow capturing `offset` from component scope + await page.getByTestId("subtract-btn").click(); + await expect + .poll( + () => + page + .getByTestId("subtract-result") + .textContent() + .catch(() => null), + { timeout: 30000 } + ) + .toBe('{"result":15}'); +}); + +// --------------------------------------------------------------------------- +// Multiple "use server" inline functions called from "use client" inline +// --------------------------------------------------------------------------- + +test("use server inline multiple functions in client", async () => { + await server("fixtures/use-server-inline-multi.jsx"); + await page.goto(hostname); + await waitForHydration(); + + // Add three items via server action + for (let i = 0; i < 3; i++) { + await page.getByTestId("add-btn").click(); + const expected = Array.from({ length: i + 1 }, (_, j) => `item-${j}`).join( + "" + ); + await expect + .poll(() => page.getByTestId("items").textContent(), { timeout: 30000 }) + .toBe(expected); + } + + const items = await page.getByTestId("items").textContent(); + expect(items).toContain("item-0"); + expect(items).toContain("item-1"); + expect(items).toContain("item-2"); + + // Format items via a second server action + await page.getByTestId("format-btn").click(); + await expect + .poll( + () => + page + .getByTestId("formatted") + .textContent() + .catch(() => null), + { timeout: 30000 } + ) + .toBe("[item] item-0, [item] item-1, [item] item-2"); +}); + +// --------------------------------------------------------------------------- +// Mixed "use client" and "use server" inline in one server module +// --------------------------------------------------------------------------- + +test("mixed use client and use server inline", async () => { + await server("fixtures/use-mixed-inline.jsx"); + await page.goto(hostname); + await waitForHydration(); + + // ClientGreeter: top-level "use client" calls top-level "use server" fetchGreeting + await page.getByTestId("greet-btn").click(); + await expect + .poll( + () => + page + .getByTestId("greeting") + .textContent() + .catch(() => null), + { timeout: 30000 } + ) + .toBe("from server: Hello, world!"); + + // Calculator: nested "use client" calls nested "use server" double + await page.getByTestId("calc-btn").click(); + await expect + .poll( + () => + page + .getByTestId("calc-result") + .textContent() + .catch(() => null), + { timeout: 30000 } + ) + .toBe("42"); +}); + +// --------------------------------------------------------------------------- +// Nested inline directives: "use client" containing "use server", +// at both top-level and component-scope +// --------------------------------------------------------------------------- + +test("nested inline use directives", async () => { + await server("fixtures/use-nested-inline.jsx"); + await page.goto(hostname); + await waitForHydration(); + + // ConfigPanel (top-level "use client") calls fetchConfig (top-level "use server") + await page.getByTestId("load-btn").click(); + await expect + .poll( + () => + page + .getByTestId("config") + .textContent() + .catch(() => null), + { timeout: 30000 } + ) + .toBe("nested-app/dark"); + + // ConfigPanel then calls its own nested saveConfig ("use server" inside "use client") + await page.getByTestId("save-btn").click(); + await expect + .poll( + () => + page + .getByTestId("saved") + .textContent() + .catch(() => null), + { timeout: 30000 } + ) + .toBe("saved:nested-app:dark"); + + // MathPanel (component-scope "use client") calls multiply (component-scope "use server") + await page.getByTestId("multiply-btn").click(); + await expect + .poll( + () => + page + .getByTestId("product") + .textContent() + .catch(() => null), + { timeout: 30000 } + ) + .toBe("70"); + + // MathPanel then calls its own nested formatResult ("use server" inside "use client") + await page.getByTestId("format-btn").click(); + await expect + .poll( + () => + page + .getByTestId("formatted-result") + .textContent() + .catch(() => null), + { timeout: 30000 } + ) + .toBe("result=70"); +}); + +// --------------------------------------------------------------------------- +// "use client" inside "use server" — server action returns rendered client component +// --------------------------------------------------------------------------- + +test("use client inline inside use server inline", async () => { + await server("fixtures/use-client-in-server-inline.jsx"); + await page.goto(hostname); + await waitForHydration(); + + // Top-level: getGreeting ("use server") defines GreetingCard ("use client") and returns it + await page.getByTestId("load-greeting").click(); + await expect + .poll( + () => + page + .getByTestId("greeting-message") + .textContent() + .catch(() => null), + { timeout: 30000 } + ) + .toBe("Hello, World!"); + + // The returned client component is interactive (useState works) + await page.getByTestId("like-btn").click(); + await expect + .poll( + () => + page + .getByTestId("liked-status") + .textContent() + .catch(() => null), + { timeout: 30000 } + ) + .toBe("liked"); + + // Component-scope: calculate ("use server", captures multiplier) defines ResultCard ("use client") + await page.getByTestId("calc-btn").click(); + await expect + .poll( + () => + page + .getByTestId("calc-value") + .textContent() + .catch(() => null), + { timeout: 30000 } + ) + .toBe("35"); + + // The returned client component is interactive + await page.getByTestId("highlight-btn").click(); + await expect + .poll( + () => + page + .getByTestId("highlighted-status") + .textContent() + .catch(() => null), + { timeout: 30000 } + ) + .toBe("highlighted"); +}); + +// --------------------------------------------------------------------------- +// Inline "use server" in a top-level "use client" file +// --------------------------------------------------------------------------- + +test("use server inline in top-level use client file", async () => { + await server("fixtures/use-server-in-client-file-app.jsx"); + await page.goto(hostname); + await waitForHydration(); + + // Add two items via inline server action defined in a "use client" file + for (let i = 0; i < 2; i++) { + await page.getByTestId("add-btn").click(); + const expected = Array.from({ length: i + 1 }, (_, j) => `item-${j}`).join( + "" + ); + await expect + .poll(() => page.getByTestId("items").textContent(), { timeout: 30000 }) + .toBe(expected); + } + + // Format first item via a second inline server action + await page.getByTestId("format-btn").click(); + await waitForChange( + null, + () => page.getByTestId("items").textContent(), + "item-0item-1" + ); + const items = await page.getByTestId("items").textContent(); + expect(items).toContain("["); + expect(items).toContain("] item-0"); +}); + +// --------------------------------------------------------------------------- +// Inline "use client" in a top-level "use server" file +// --------------------------------------------------------------------------- + +test("use client inline in top-level use server file", async () => { + await server("fixtures/use-client-in-server-file-app.jsx"); + await page.goto(hostname); + await waitForHydration(); + + // createBadge — server function returns an inline "use client" component + await page.getByTestId("load-badge").click(); + await expect + .poll( + () => + page + .getByTestId("badge") + .textContent() + .catch(() => null), + { timeout: 30000 } + ) + .toBe("[server] hello"); + + // The returned client component is interactive (click toggles text) + await page.getByTestId("badge").click(); + await expect + .poll(() => page.getByTestId("badge").textContent(), { timeout: 30000 }) + .toBe("clicked:[server] hello"); + + // createToggle — second server function returns a different inline "use client" component + await page.getByTestId("load-toggle").click(); + await expect + .poll( + () => + page + .getByTestId("toggle") + .textContent() + .catch(() => null), + { timeout: 30000 } + ) + .toBe("OFF: feature"); + + // Toggle is interactive + await page.getByTestId("toggle").click(); + await expect + .poll(() => page.getByTestId("toggle").textContent(), { timeout: 30000 }) + .toBe("ON: feature"); +}); + +// --------------------------------------------------------------------------- +// Inline server actions (form-based) — moved from basic.spec.mjs +// --------------------------------------------------------------------------- + +for (const id of [ + "inline-jsx-prop", + "inline-server-action-function", + "inline-server-action-arrow", + "inline-server-action-top-level", +]) { + test(`${id} server action`, async () => { + await server("fixtures/server-actions.jsx"); + await page.goto(hostname); + + const button = await page.getByTestId(id); + + await waitForChange( + () => button.click(), + () => logs.includes(`submitted ${id}!`) + ); + expect(logs).toContain(`submitted ${id}!`); + }); +} diff --git a/test/fixtures/use-client-in-server-file-app.jsx b/test/fixtures/use-client-in-server-file-app.jsx new file mode 100644 index 00000000..28030f12 --- /dev/null +++ b/test/fixtures/use-client-in-server-file-app.jsx @@ -0,0 +1,47 @@ +import { useState, useTransition } from "react"; +import { createBadge, createToggle } from "./use-client-in-server-file"; + +function Shell({ loadBadge, loadToggle }) { + "use client"; + + const [badge, setBadge] = useState(null); + const [toggle, setToggle] = useState(null); + const [, startTransition] = useTransition(); + + return ( +
+ +
{badge}
+ + +
{toggle}
+
+ ); +} + +export default function App() { + return ( + + + Test + + + + + + ); +} diff --git a/test/fixtures/use-client-in-server-file.jsx b/test/fixtures/use-client-in-server-file.jsx new file mode 100644 index 00000000..3864b2fc --- /dev/null +++ b/test/fixtures/use-client-in-server-file.jsx @@ -0,0 +1,43 @@ +"use server"; + +import { useState } from "react"; + +// This is a top-level "use server" file with inline "use client" components. +// All exports are server functions. The client components are extracted +// from the server module automatically. + +const PREFIX = "server"; + +export async function createBadge(label) { + function Badge({ text }) { + "use client"; + + const [clicked, setClicked] = useState(false); + return ( + + ); + } + + return ; +} + +export async function createToggle(initialLabel) { + function Toggle({ label }) { + "use client"; + + const [on, setOn] = useState(false); + return ( + + ); + } + + return ; +} diff --git a/test/fixtures/use-client-in-server-inline.jsx b/test/fixtures/use-client-in-server-inline.jsx new file mode 100644 index 00000000..50a0f68f --- /dev/null +++ b/test/fixtures/use-client-in-server-inline.jsx @@ -0,0 +1,124 @@ +import { useState, useTransition } from "react"; + +const PREFIX = "Hello"; + +// Top-level "use server" that defines and returns an inline "use client" component. +// Extraction chain: file → ?use-server-inline=getGreeting → ?…?use-client-inline=GreetingCard +async function getGreeting(name) { + "use server"; + + function GreetingCard({ message }) { + "use client"; + + const [liked, setLiked] = useState(false); + return ( +
+

{message}

+ + {liked && liked} +
+ ); + } + + return ; +} + +// Shell component calls the server action and renders the returned element +function GreetingShell() { + "use client"; + + const [content, setContent] = useState(null); + const [, startTransition] = useTransition(); + + return ( +
+ +
{content}
+
+ ); +} + +export default function App() { + const multiplier = 5; + + // Component-scope "use server" that defines and returns an inline "use client" component. + // Also captures `multiplier` from parent scope. + async function calculate(n) { + "use server"; + + function ResultCard({ value }) { + "use client"; + + const [highlighted, setHighlighted] = useState(false); + return ( +
+ + {value} + + + {highlighted && ( + highlighted + )} +
+ ); + } + + return ; + } + + function CalcShell() { + "use client"; + + const [result, setResult] = useState(null); + const [, startTransition] = useTransition(); + + return ( +
+ +
{result}
+
+ ); + } + + return ( + + + Test + + + + + + + ); +} diff --git a/test/fixtures/use-client-inline-props.jsx b/test/fixtures/use-client-inline-props.jsx new file mode 100644 index 00000000..21bd057d --- /dev/null +++ b/test/fixtures/use-client-inline-props.jsx @@ -0,0 +1,64 @@ +import { useState } from "react"; + +const shared = "shared"; + +function Badge({ label }) { + "use client"; + return ( + + {shared} {label} + + ); +} + +function Input({ onChange, placeholder }) { + "use client"; + const [value, setValue] = useState(""); + return ( + { + setValue(e.target.value); + onChange?.(e.target.value); + }} + /> + ); +} + +export default function App() { + const greeting = "hello"; + + function Greeting({ name }) { + "use client"; + return ( +

+ {greeting} {name} +

+ ); + } + + const Display = ({ children, ...rest }) => { + "use client"; + return ( +
+ {greeting} {children} +
+ ); + }; + + return ( + + + Test + + + + + + content + + + ); +} diff --git a/test/fixtures/use-client-inline.jsx b/test/fixtures/use-client-inline.jsx new file mode 100644 index 00000000..30dcb980 --- /dev/null +++ b/test/fixtures/use-client-inline.jsx @@ -0,0 +1,50 @@ +import { useState } from "react"; + +const t = "temp"; +const o = { + c: "client", + s: "server", +}; +function Counter() { + "use client"; + const [count, setCount] = useState(0); + return ( +
+

+ Count: {count} {t} {o.c} +

+ +
+ ); +} + +export default function App() { + const x = "scope"; + const Counter2 = () => { + "use client"; + const [count, setCount] = useState(0); + return ( +
+

+ Count2: {count} {t} {o.c} {x} +

+ +
+ ); + }; + + return ( + + + Test + + +

+ "use client" inline {t} {o.s} {x} +

+ + + + + ); +} diff --git a/test/fixtures/use-mixed-inline.jsx b/test/fixtures/use-mixed-inline.jsx new file mode 100644 index 00000000..11ab59d9 --- /dev/null +++ b/test/fixtures/use-mixed-inline.jsx @@ -0,0 +1,77 @@ +import { useState, useTransition } from "react"; + +const serverLabel = "from server"; + +async function fetchGreeting(name) { + "use server"; + return `${serverLabel}: Hello, ${name}!`; +} + +function ClientGreeter() { + "use client"; + + const [greeting, setGreeting] = useState(""); + const [, startTransition] = useTransition(); + + return ( +
+ + {greeting &&

{greeting}

} +
+ ); +} + +export default function App() { + const factor = 2; + + async function double(n) { + "use server"; + return n * factor; + } + + function Calculator() { + "use client"; + + const [result, setResult] = useState(null); + const [, startTransition] = useTransition(); + + return ( +
+ + {result !== null &&

{result}

} +
+ ); + } + + return ( + + + Test + + + + + + + ); +} diff --git a/test/fixtures/use-nested-inline.jsx b/test/fixtures/use-nested-inline.jsx new file mode 100644 index 00000000..94819e8b --- /dev/null +++ b/test/fixtures/use-nested-inline.jsx @@ -0,0 +1,131 @@ +import { useState, useTransition } from "react"; + +const APP_NAME = "nested-app"; + +// Top-level "use server" — captures module-scope constant +async function fetchConfig() { + "use server"; + return { app: APP_NAME, theme: "dark" }; +} + +// Top-level "use client" — references sibling "use server" fetchConfig, +// and also defines its own nested "use server" saveConfig +function ConfigPanel() { + "use client"; + + const [config, setConfig] = useState(null); + const [saved, setSaved] = useState(""); + const [, startTransition] = useTransition(); + + // Nested "use server" inside "use client" + async function saveConfig(data) { + "use server"; + return `saved:${data.app}:${data.theme}`; + } + + return ( +
+ + {config && ( + <> + + {config.app}/{config.theme} + + + + )} + {saved && {saved}} +
+ ); +} + +export default function App() { + const factor = 10; + + // Component-scope "use server" — captures `factor` + async function multiply(n) { + "use server"; + return n * factor; + } + + // Component-scope "use client" — references sibling "use server" multiply, + // and also defines its own nested "use server" formatResult + function MathPanel() { + "use client"; + + const [product, setProduct] = useState(null); + const [formatted, setFormatted] = useState(""); + const [, startTransition] = useTransition(); + + // Nested "use server" inside nested "use client" + async function formatResult(value) { + "use server"; + return `result=${value}`; + } + + return ( +
+ + {product !== null && ( + <> + {product} + + + )} + {formatted && {formatted}} +
+ ); + } + + return ( + + + Test + + + + + + + ); +} diff --git a/test/fixtures/use-server-in-client-file-app.jsx b/test/fixtures/use-server-in-client-file-app.jsx new file mode 100644 index 00000000..dac5005f --- /dev/null +++ b/test/fixtures/use-server-in-client-file-app.jsx @@ -0,0 +1,14 @@ +import TodoApp from "./use-server-in-client-file"; + +export default function App() { + return ( + + + Test + + + + + + ); +} diff --git a/test/fixtures/use-server-in-client-file.jsx b/test/fixtures/use-server-in-client-file.jsx new file mode 100644 index 00000000..1e439665 --- /dev/null +++ b/test/fixtures/use-server-in-client-file.jsx @@ -0,0 +1,58 @@ +"use client"; + +import { useState, useTransition } from "react"; + +// This is a top-level "use client" file with inline "use server" functions. +// The server functions are extracted from the client module automatically. + +export default function TodoApp() { + const [items, setItems] = useState([]); + const [, startTransition] = useTransition(); + + async function addItem(text) { + "use server"; + return { id: Date.now(), text }; + } + + async function formatItem(item) { + "use server"; + return `[${item.id}] ${item.text}`; + } + + return ( +
+ +
    + {items.map((item) => ( +
  • {item.text}
  • + ))} +
+ +
+ ); +} diff --git a/test/fixtures/use-server-in-client.jsx b/test/fixtures/use-server-in-client.jsx new file mode 100644 index 00000000..e08fce8c --- /dev/null +++ b/test/fixtures/use-server-in-client.jsx @@ -0,0 +1,59 @@ +import { useState, useTransition } from "react"; + +export default function App() { + function Counter() { + "use client"; + + const [count, setCount] = useState(0); + const [, startTransition] = useTransition(); + + async function increment(n) { + "use server"; + return n + 1; + } + + const decrement = async (n) => { + "use server"; + return n - 1; + }; + + return ( +
+

Count: {count}

+ + +
+ ); + } + + return ( + + + Test + + + + + + ); +} diff --git a/test/fixtures/use-server-inline-captured.jsx b/test/fixtures/use-server-inline-captured.jsx new file mode 100644 index 00000000..fe2b6994 --- /dev/null +++ b/test/fixtures/use-server-inline-captured.jsx @@ -0,0 +1,62 @@ +import { useState, useTransition } from "react"; + +const multiplier = 3; +const label = "result"; + +async function multiply(n) { + "use server"; + return { [label]: n * multiplier }; +} + +function Caller({ id, action, arg }) { + "use client"; + + const [result, setResult] = useState(null); + const [, startTransition] = useTransition(); + + return ( +
+ + {result && ( +
{JSON.stringify(result)}
+ )} +
+ ); +} + +export default function App() { + const offset = 10; + + async function add(n) { + "use server"; + return { result: n + offset }; + } + + const subtract = async (n) => { + "use server"; + return { result: n - offset }; + }; + + return ( + + + Test + + + + + + + + ); +} diff --git a/test/fixtures/use-server-inline-multi.jsx b/test/fixtures/use-server-inline-multi.jsx new file mode 100644 index 00000000..06fcc86e --- /dev/null +++ b/test/fixtures/use-server-inline-multi.jsx @@ -0,0 +1,70 @@ +import { useState, useTransition } from "react"; + +const sharedConfig = { + prefix: "item", + separator: "-", +}; + +async function generateId(index) { + "use server"; + return `${sharedConfig.prefix}${sharedConfig.separator}${index}`; +} + +async function formatItems(items) { + "use server"; + return items.map((item) => `[${sharedConfig.prefix}] ${item}`).join(", "); +} + +function ItemManager() { + "use client"; + + const [items, setItems] = useState([]); + const [formatted, setFormatted] = useState(""); + const [, startTransition] = useTransition(); + + return ( +
+ + +
    + {items.map((item, i) => ( +
  • {item}
  • + ))} +
+ {formatted &&

{formatted}

} +
+ ); +} + +export default function App() { + return ( + + + Test + + + + + + ); +}