Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
ea50d9d
fix(use-cache): support nested cache functions passed as props
james-elicx Jun 9, 2026
71f2c22
fix(use-cache): use inline registerServerReference instead of broken …
james-elicx Jun 9, 2026
e08d6f4
fix(use-cache): use correct normalised id and register in manifest fo…
james-elicx Jun 9, 2026
1d3c833
fix(use-cache): wrap hoisted exports as cached server references and …
james-elicx Jun 10, 2026
e8a434b
docs(use-cache): document dev-key normalisation scope for inline cach…
james-elicx Jun 10, 2026
f600230
fix(use-cache): throw instead of emitting unresolvable inline cache s…
james-elicx Jun 10, 2026
eeda6cf
test(use-cache): pin unencrypted closure-captured bound args and docu…
james-elicx Jun 10, 2026
a9cd049
refactor(use-cache): route registerServerReference through a vinext s…
james-elicx Jun 10, 2026
67d7fb2
test(use-cache): pin cached-invoke semantics for the closure-bound ge…
james-elicx Jun 10, 2026
9937ec7
fix(use-cache): encrypt closure-bound arguments
james-elicx Jun 11, 2026
b8a116c
refactor(use-cache): use plugin-rsc directive transforms
james-elicx Jun 12, 2026
7e05790
test(use-cache): cover directive transforms across environments
james-elicx Jun 12, 2026
f38e1a1
Merge remote-tracking branch 'origin/main' into codex/pr-1871
james-elicx Jun 12, 2026
6537755
fix(cache): update RSC directive prerelease
james-elicx Jun 12, 2026
5ca1ee2
fix(cache): stabilize directive reference tests
james-elicx Jun 12, 2026
9f4b6ee
style(cache): format HMR test
james-elicx Jun 12, 2026
0da1dc9
refactor(use-cache): move server function directives to user land
james-elicx Jun 18, 2026
6d2d5e9
refactor(use-cache): clarify generic directive plugin naming
james-elicx Jun 18, 2026
4bd11b1
refactor(use-cache): own directive plugin types
james-elicx Jun 18, 2026
75f7fb0
refactor(use-cache): use plugin-rsc metadata map directly
james-elicx Jun 18, 2026
b07d317
refactor(use-cache): own server reference metadata lifecycle
james-elicx Jul 20, 2026
622f0b9
chore(use-cache): keep directive type internal
james-elicx Jul 20, 2026
250707c
refactor(use-cache): adopt server reference claims
james-elicx Jul 24, 2026
a1c2e67
fix(init): install required plugin-rsc prerelease
james-elicx Jul 24, 2026
442d760
feat(rsc): harden use cache server functions
james-elicx Jul 27, 2026
8a73724
feat(cache): adopt plugin-rsc transform primitives
james-elicx Aug 6, 2026
e841cd9
refactor(cache): rename callable plugin
james-elicx Aug 6, 2026
ccb6db6
Merge remote-tracking branch 'origin/main' into codex/pr-1871-userlan…
james-elicx Aug 6, 2026
7078ed9
test(init): update plugin-rsc install expectations
james-elicx Aug 6, 2026
f833865
test(cache): avoid reloading during HMR retries
james-elicx Aug 6, 2026
2b26266
test(cache): align callable references with plugin-rsc
james-elicx Aug 6, 2026
c31ae14
fix(cache): align mixed directives with plugin-rsc 0.5.34
james-elicx Aug 7, 2026
286c6ec
fix(cache): harden callable use cache transforms
james-elicx Aug 7, 2026
900b627
Merge origin/main into codex/pr-1871-userland-server-functions
james-elicx Aug 7, 2026
ad542da
fix(cache): support manually configured RSC
james-elicx Aug 7, 2026
5174155
fix(cache): harden manual RSC ordering
james-elicx Aug 7, 2026
b67ffdc
Merge origin/main into codex/pr-1871-userland-server-functions
james-elicx Aug 7, 2026
36c26e3
Merge origin/main into codex/pr-1871-userland-server-functions
james-elicx Aug 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/vinext/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@
"peerDependencies": {
"@mdx-js/rollup": "^3.0.0",
"@vitejs/plugin-react": "^5.1.4 || ^6.0.0",
"@vitejs/plugin-rsc": "^0.5.26",
"@vitejs/plugin-rsc": "^0.5.34",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-server-dom-webpack": "^19.2.6",
Expand Down
324 changes: 30 additions & 294 deletions packages/vinext/src/index.ts

Large diffs are not rendered by default.

374 changes: 374 additions & 0 deletions packages/vinext/src/plugins/use-cache-callable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,374 @@
import { createRequire } from "node:module";

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/bigbonk review for issues

import path from "pathslash";
import { pathToFileURL } from "node:url";
import type { RscPluginManager } from "@vitejs/plugin-rsc";
import type {
ModuleExportMeta,
TransformHoistInlineDirectiveMeta,
} from "@vitejs/plugin-rsc/transforms";
import { parseAstAsync, type Plugin } from "vite";
import { stripViteModuleQuery } from "../utils/path.js";

type RscTransforms = typeof import("@vitejs/plugin-rsc/transforms");
type Program = Awaited<ReturnType<typeof parseAstAsync>>;

type Options = {
projectRoot: string;
cacheRuntime: string;
getAppDir: () => string | undefined;
matchesPageExtension: (fileName: string) => boolean;
allowMissingRsc?: boolean;
};

type CacheWrapperOptions = {
acceptsSecondArgument: boolean;
appPageDefaultExport?: boolean;
argumentCount?: number;
};

const PLUGIN_NAME = "vinext:server-function-directives";
const SOURCE_MODULE_ID_RE = /\.(?:tsx?|jsx?|mjs)(?:\?.*)?$/;
const DEPENDENCY_MODULE_ID_RE = /[\\/]node_modules[\\/]/;
const RESOLVED_VIRTUAL_MODULE_ID_RE = new RegExp(`^${String.fromCharCode(0)}`);
const USE_CACHE_DIRECTIVE = /^use cache(?:: ([^\s].*))?$/;
const USE_CACHE_DIRECTIVE_CANDIDATE = /^use cache.*$/;

function resolvePluginRscModule(projectRoot: string, specifier: string): string {
try {
return createRequire(path.join(projectRoot, "package.json")).resolve(specifier);
} catch {}

try {
return createRequire(import.meta.url).resolve(specifier);
} catch {
throw new Error(`vinext: Installed @vitejs/plugin-rsc does not expose ${specifier}.`);
}
}

function matchUseCacheDirective(directive: string): RegExpMatchArray {
const match = directive.match(USE_CACHE_DIRECTIVE);
if (match) return match;

const cacheKind = directive.includes(":")
? directive.slice(directive.indexOf(":") + 1).trim()
: directive.slice("use cache".length).trim();
const expected = cacheKind ? `use cache: ${cacheKind}` : "use cache";
throw new Error(
`Invalid cache directive ${JSON.stringify(directive)}. Did you mean ${JSON.stringify(expected)}?`,
);
}

function findModuleUseCacheDirective(ast: Program): string | undefined {
for (const statement of ast.body) {
if (
statement.type !== "ExpressionStatement" ||
!("directive" in statement) ||
typeof statement.directive !== "string"
) {
break;
}
if (statement.directive.startsWith("use cache")) {
return matchUseCacheDirective(statement.directive)[0];
}
}
}

function getArgumentCount(
meta: Pick<ModuleExportMeta, "valueNode"> | TransformHoistInlineDirectiveMeta,
): number | undefined {
const node = meta.valueNode;
if (
node?.type !== "FunctionDeclaration" &&
node?.type !== "FunctionExpression" &&
node?.type !== "ArrowFunctionExpression"
) {
return;
}
return node.params.at(-1)?.type === "RestElement" ? undefined : node.params.length;
}

function acceptsSecondArgument(
meta: Pick<ModuleExportMeta, "valueNode"> | TransformHoistInlineDirectiveMeta,
): boolean {
const node = meta.valueNode;
if (
node?.type !== "FunctionDeclaration" &&
node?.type !== "FunctionExpression" &&
node?.type !== "ArrowFunctionExpression"
) {
return true;
}
return (
node.params.length >= 2 || node.params.some((parameter) => parameter.type === "RestElement")
);
}

function isInsideDirectory(directory: string, filePath: string): boolean {
const relativePath = path.relative(directory, filePath);
return relativePath !== "" && !relativePath.startsWith("..") && !path.isAbsolute(relativePath);
}

function isAppPageDefaultExport(
options: Options,
id: string,
name: string,
isModuleDirective: boolean,
): boolean {
const appDir = options.getAppDir();
if (!isModuleDirective || name !== "default" || !appDir) return false;
const modulePath = stripViteModuleQuery(id);
const moduleFileName = path.basename(modulePath);
return (
isInsideDirectory(appDir, modulePath) &&
path.parse(moduleFileName).name === "page" &&
options.matchesPageExtension(moduleFileName)
);
}

function shouldTransformModuleExport(name: string, id: string, meta: ModuleExportMeta): boolean {
if (
meta.isFunction === false &&
(meta.valueNode?.type === "ObjectExpression" || meta.valueNode?.type === "ArrayExpression")
) {
return false;
}
if (/\/(layout|template)\.(tsx?|jsx?|mjs)$/.test(id) && name === "default") return false;
return true;
}

function validateModuleExport(transforms: RscTransforms, meta: ModuleExportMeta): void {
if (!meta.valueNode) return;
if (
meta.isFunction !== false &&
(meta.valueNode.type === "ObjectExpression" || meta.valueNode.type === "ArrayExpression")
) {
return;
}
transforms.validateNonAsyncFunction({ rejectNonAsyncFunction: true }, meta.valueNode);
}

function hasFunctionDirective(
meta: Pick<ModuleExportMeta, "valueNode">,
directive: string,
): boolean {
const node = meta.valueNode;
if (
(node?.type !== "FunctionDeclaration" &&
node?.type !== "FunctionExpression" &&
node?.type !== "ArrowFunctionExpression") ||
node.body.type !== "BlockStatement"
) {
return false;
}
return node.body.body.some(
(statement) =>
statement.type === "ExpressionStatement" &&
"directive" in statement &&
statement.directive === directive,
);
}

function getCacheWrapperOptions(
options: Options,
id: string,
name: string,
isModuleDirective: boolean,
meta: Pick<ModuleExportMeta, "valueNode"> | TransformHoistInlineDirectiveMeta,
): CacheWrapperOptions {
const argumentCount = getArgumentCount(meta);
return {
acceptsSecondArgument: acceptsSecondArgument(meta),
...(isAppPageDefaultExport(options, id, name, isModuleDirective)
? { appPageDefaultExport: true }
: {}),
...(argumentCount === undefined ? {} : { argumentCount }),
};
}

export async function createUseCacheCallablePlugin(options: Options): Promise<Plugin> {
const rscModulePath = resolvePluginRscModule(options.projectRoot, "@vitejs/plugin-rsc");
const transformsPath = resolvePluginRscModule(
options.projectRoot,
"@vitejs/plugin-rsc/transforms",
);
const rscModule: typeof import("@vitejs/plugin-rsc") = await import(
pathToFileURL(rscModulePath).href
);
const transforms: RscTransforms = await import(pathToFileURL(transformsPath).href);
let manager: RscPluginManager | undefined;

return {
name: PLUGIN_NAME,
configResolved(config) {
const pluginApi = rscModule.getPluginApi(config);
const hasRscPlugin = config.plugins.some((plugin) => plugin.name === "rsc");
if (!pluginApi && options.allowMissingRsc && !hasRscPlugin) return;
if (!pluginApi?.manager.serverReferences) {
throw new Error("vinext: callable use cache requires @vitejs/plugin-rsc 0.5.34 or newer.");
}
if (options.allowMissingRsc) {
const useCacheIndex = config.plugins.findIndex((plugin) => plugin.name === PLUGIN_NAME);
const useServerIndex = config.plugins.findIndex(
(plugin) => plugin.name === "rsc:use-server",
);
if (useServerIndex !== -1 && useCacheIndex > useServerIndex) {
throw new Error(
"vinext: when configuring @vitejs/plugin-rsc manually, vinext({ rsc: false }) must appear before rsc() in the Vite plugins array.",
);
}
}
manager = pluginApi.manager;
},
transform: {
filter: {
id: {
include: SOURCE_MODULE_ID_RE,
exclude: [DEPENDENCY_MODULE_ID_RE, RESOLVED_VIRTUAL_MODULE_ID_RE],
},
},
async handler(code, id) {
if (!manager) return;
if (!code.includes("use cache")) {
manager.serverReferences.deleteClaim(PLUGIN_NAME, id);
return;
}

const ast = await parseAstAsync(code);
const moduleDirective = findModuleUseCacheDirective(ast);
const useServerBoundary = transforms.hasDirective(ast.body, "use server");
if (moduleDirective && useServerBoundary) {
throw new Error(
`A module cannot contain both ${JSON.stringify(moduleDirective)} and "use server" directives.`,
);
}

const reference = manager.serverReferences.resolve(id, "rsc");
const isRsc = this.environment.name === "rsc";

if (!isRsc) {
if (useServerBoundary) {
manager.serverReferences.deleteClaim(PLUGIN_NAME, id);
return;
}
if (!moduleDirective) {
transforms.transformHoistInlineDirective(code, ast, {
directive: USE_CACHE_DIRECTIVE_CANDIDATE,
rejectNonAsyncFunction: true,
runtime: (_value, _name, meta) => {
matchUseCacheDirective(meta.directiveMatch[0]);
throw new Error(
`It is not allowed to define inline "use cache" annotated functions in Client Components. Export them from a separate file with a module-level "use cache" or "use server" directive, or pass them down through props from a Server Component. (${this.environment.name}: ${id})`,
);
},
});
manager.serverReferences.deleteClaim(PLUGIN_NAME, id);
return;
}

const result = transforms.transformDirectiveProxyExport(ast, {
code,
directive: moduleDirective,
filter: (name, meta) => {
if (!shouldTransformModuleExport(name, id, meta)) return false;
validateModuleExport(transforms, meta);
return true;
},
runtime: (name) =>
`$$ReactClient.createServerReference(${JSON.stringify(`${reference.referenceKey}#${name}`)},$$ReactClient.callServer,undefined,${this.environment.mode === "dev" ? "$$ReactClient.findSourceMapURL" : "undefined"},${JSON.stringify(name)})`,
});
if (!result?.output.hasChanged()) {
manager.serverReferences.deleteClaim(PLUGIN_NAME, id);
return;
}

manager.serverReferences.replaceClaim(PLUGIN_NAME, id, {
...reference,
exportNames: result.exportNames,
});
const runtimeEnvironment = this.environment.name === "client" ? "browser" : "ssr";
result.output.prepend(
`import * as $$ReactClient from "@vitejs/plugin-rsc/react/${runtimeEnvironment}";\n`,
);
return {
code: result.output.toString(),
map: result.output.generateMap({ hires: "boundary", source: id }),
};
}

const wrap = (
value: string,
name: string,
directiveMatch: RegExpMatchArray,
meta: Pick<ModuleExportMeta, "valueNode"> | TransformHoistInlineDirectiveMeta,
isModuleDirective: boolean,
) => {
const variant = directiveMatch[1] ?? "";
const wrapperOptions = getCacheWrapperOptions(options, id, name, isModuleDirective, meta);
return `$$cacheRuntime.registerCachedFunction(${value}, ${JSON.stringify(`${id}:${name}`)}, ${JSON.stringify(variant)}, ${JSON.stringify(wrapperOptions)})`;
};
let needsReactServer = false;
const runtime = (
value: string,
name: string,
directiveMatch: RegExpMatchArray,
meta: Pick<ModuleExportMeta, "valueNode"> | TransformHoistInlineDirectiveMeta,
isModuleDirective: boolean,
) => {
const cached = wrap(value, name, directiveMatch, meta, isModuleDirective);
needsReactServer = true;
return `$$VinextReactServer.registerServerReference(${cached}, ${JSON.stringify(reference.referenceKey)}, ${JSON.stringify(name)})`;
};

const result = moduleDirective
? transforms.transformWrapExport(code, ast, {
filter: (name, meta) => {
if (
hasFunctionDirective(meta, "use server") ||
!shouldTransformModuleExport(name, id, meta)
) {
return false;
}
validateModuleExport(transforms, meta);
return true;
},
runtime: (value, name, meta) =>
runtime(value, name, matchUseCacheDirective(moduleDirective), meta, true),
})
: transforms.transformHoistInlineDirective(code, ast, {
directive: USE_CACHE_DIRECTIVE_CANDIDATE,
rejectNonAsyncFunction: true,
hoistRuntime: true,
runtime: (value, name, meta) =>
runtime(value, name, matchUseCacheDirective(meta.directiveMatch[0]), meta, false),
encode: (value) => `$$cacheRuntime.encryptCacheCaptures(${value})`,
decode: (value) => value,
});
if (!result.output.hasChanged()) {
manager.serverReferences.deleteClaim(PLUGIN_NAME, id);
return;
}

manager.serverReferences.replaceClaim(PLUGIN_NAME, id, {
...reference,
exportNames: "names" in result ? result.names : result.exportNames,
});
const importPosition =
ast.body.find((node) => !("directive" in node))?.start ?? code.length;
result.output.prependLeft(
importPosition,
[
`import * as $$cacheRuntime from ${JSON.stringify(options.cacheRuntime)};`,
needsReactServer &&
`import * as $$VinextReactServer from "@vitejs/plugin-rsc/react/rsc/server";`,
]
.filter(Boolean)
.join("\n") + "\n",
);
return {
code: result.output.toString(),
map: result.output.generateMap({ hires: "boundary", source: id }),
};
},
},
};
}
Loading
Loading