Skip to content

Commit 0d059c7

Browse files
authored
fix: use cache plugin (#387)
The `use-cache-inline` plugin had several bugs in how it tracks closure variables and handles exported cached functions. The root cause was a flat `locals` array that accumulated variable names from all function scopes without any scoping — variables declared inside one function would leak into the closure capture list of completely unrelated sibling functions. This caused the plugin to emit invalid `.bind()` calls on module-level exported cached functions, producing parse errors like `export __cache_name__.bind(null, src, selector, isolate)` during production builds. The fix replaces the flat `locals` array with a proper scope stack. Each non-cached function pushes a scope entry on enter and pops it on leave, so only variables from actual enclosing lexical scopes are considered for closure capture. Module-level cached functions now correctly see an empty scope stack and skip `.bind()` entirely. Additional fixes included in this change: destructured binding patterns (`ObjectPattern`, `ArrayPattern`, `AssignmentPattern`, `RestElement`) are now properly walked when collecting local variable names. Function parameters in enclosing scopes are tracked for nested cached function closure capture. Identifiers in non-reference positions (object property keys, member expression properties, declaration ids, function names) are excluded from closure matching — this was necessary because the JSX transform runs before the cache plugin, turning JSX attribute names into regular Property key Identifiers that would otherwise be falsely captured. Exported cached functions are now wrapped in `__react_cache__()` at module level and get correct `CACHE_KEY`/`CACHE_PROVIDER` assignments using scoping-aware target resolution.
1 parent 1c4cca1 commit 0d059c7

6 files changed

Lines changed: 396 additions & 43 deletions

File tree

packages/react-server/lib/plugins/use-cache-inline.mjs

Lines changed: 195 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -129,8 +129,46 @@ export default function useCacheInline(profiles, providers = {}, type) {
129129
...resolvedProviders,
130130
};
131131

132+
// Extract all binding names from a pattern node (Identifier, ObjectPattern,
133+
// ArrayPattern, AssignmentPattern, RestElement).
134+
function collectBindingNames(pattern, out = []) {
135+
if (!pattern) return out;
136+
switch (pattern.type) {
137+
case "Identifier":
138+
out.push(pattern.name);
139+
break;
140+
case "ObjectPattern":
141+
for (const prop of pattern.properties) {
142+
if (prop.type === "RestElement") {
143+
collectBindingNames(prop.argument, out);
144+
} else {
145+
collectBindingNames(prop.value, out);
146+
}
147+
}
148+
break;
149+
case "ArrayPattern":
150+
for (const el of pattern.elements) {
151+
if (el) collectBindingNames(el, out);
152+
}
153+
break;
154+
case "AssignmentPattern":
155+
collectBindingNames(pattern.left, out);
156+
break;
157+
case "RestElement":
158+
collectBindingNames(pattern.argument, out);
159+
break;
160+
}
161+
return out;
162+
}
163+
132164
const caches = [];
133-
const locals = [];
165+
// Scope stack: each entry is { node, names: Set<string> } representing
166+
// a function scope. Only non-cached function scopes are pushed.
167+
// When checking closure captures for a cached function, we look at all
168+
// entries on the stack — they represent the enclosing lexical scopes.
169+
// This prevents variables from unrelated sibling functions from leaking
170+
// into the closure capture list.
171+
const scopeStack = [];
134172
let parent = null;
135173
let useCacheNode = null;
136174
let useCache = null;
@@ -208,14 +246,53 @@ export default function useCacheInline(profiles, providers = {}, type) {
208246
}
209247

210248
if (useCacheNode && node.type === "Identifier") {
249+
// Skip identifiers in non-reference positions (object property
250+
// keys, non-computed member expression properties, declaration
251+
// ids). After JSX transformation, JSX attribute names become
252+
// Property keys (e.g., { name: "World" }) and must not be
253+
// treated as closure variable references.
254+
const isNonRef =
255+
(node.parent?.type === "Property" &&
256+
node.parent.key === node &&
257+
!node.parent.computed) ||
258+
(node.parent?.type === "MemberExpression" &&
259+
node.parent.property === node &&
260+
!node.parent.computed) ||
261+
(node.parent?.type === "VariableDeclarator" &&
262+
node.parent.id === node) ||
263+
((node.parent?.type === "FunctionDeclaration" ||
264+
node.parent?.type === "FunctionExpression") &&
265+
node.parent.id === node);
211266
if (
212-
locals.includes(node.name) &&
213-
!useCache.params.includes(node.name)
267+
!isNonRef &&
268+
scopeStack.some((s) => s.names.has(node.name)) &&
269+
!useCache.params.includes(node.name) &&
270+
!useCache.locals.includes(node.name)
214271
) {
215272
useCache.params.push(node.name);
216273
}
217274
}
218275

276+
// Track function/arrow parameters as locals (these are bindings in
277+
// the enclosing scope that a nested "use cache" function may close
278+
// over). Push a new scope entry onto the stack so that variables
279+
// from sibling/unrelated functions don't leak into the closure
280+
// capture list.
281+
if (
282+
!useCacheNode &&
283+
(node.type === "FunctionDeclaration" ||
284+
node.type === "FunctionExpression" ||
285+
node.type === "ArrowFunctionExpression")
286+
) {
287+
const names = new Set();
288+
for (const param of node.params) {
289+
for (const n of collectBindingNames(param)) {
290+
names.add(n);
291+
}
292+
}
293+
scopeStack.push({ node, names });
294+
}
295+
219296
if (node.type === "VariableDeclarator") {
220297
let parent = node.parent;
221298
while (parent) {
@@ -228,17 +305,28 @@ export default function useCacheInline(profiles, providers = {}, type) {
228305
parent = parent.parent;
229306
}
230307
if (parent) {
308+
const names = collectBindingNames(node.id);
231309
if (useCacheNode) {
232-
useCache.locals.push(node.id.name);
233-
} else {
234-
locals.push(node.id.name);
310+
useCache.locals.push(...names);
311+
} else if (scopeStack.length > 0) {
312+
const topScope = scopeStack[scopeStack.length - 1].names;
313+
for (const name of names) topScope.add(name);
235314
}
236315
}
237316
}
238317

239318
parent = node;
240319
},
241320
leave(node) {
321+
// Pop scope when leaving a non-cached function whose scope was
322+
// pushed in `enter`.
323+
if (
324+
scopeStack.length > 0 &&
325+
scopeStack[scopeStack.length - 1].node === node
326+
) {
327+
scopeStack.pop();
328+
}
329+
242330
if (node === useCacheNode) {
243331
if (useCache.params.length > 0) {
244332
useCacheNode.type = "CallExpression";
@@ -264,14 +352,14 @@ export default function useCacheInline(profiles, providers = {}, type) {
264352
})),
265353
];
266354
} else if (useCache.parent?.type === "ExportNamedDeclaration") {
267-
useCache.name = useCache.parent.declaration.id.name;
355+
useCache.exported = true;
268356
useCache.parent.parent.body =
269357
useCache.parent.parent.body.filter(
270358
(n) => n !== useCache.parent
271359
);
272360
} else if (useCache.parent?.type === "ExportDefaultDeclaration") {
361+
useCache.exported = true;
273362
useCache.identifier = "_default";
274-
useCache.name = "_default";
275363
useCache.parent.parent.body =
276364
useCache.parent.parent.body.filter(
277365
(n) => n !== useCache.parent
@@ -548,11 +636,8 @@ export default function useCacheInline(profiles, providers = {}, type) {
548636
type: "ArrayPattern",
549637
elements: [
550638
...cache.params.map((param) => ({
551-
type: "VariableDeclarator",
552-
id: {
553-
type: "Identifier",
554-
name: param,
555-
},
639+
type: "Identifier",
640+
name: param,
556641
})),
557642
...cache.node.params,
558643
],
@@ -790,29 +875,72 @@ export default function useCacheInline(profiles, providers = {}, type) {
790875
},
791876
},
792877
];
793-
ast.body.push(
794-
{
795-
type: "FunctionDeclaration",
796-
async: !(cache.provider === "request" && isClient),
797-
id: {
798-
type: "Identifier",
799-
name: cache.name,
800-
},
801-
params: [
802-
...(argsName
803-
? [
878+
ast.body.push({
879+
type: "FunctionDeclaration",
880+
async: !(cache.provider === "request" && isClient),
881+
id: {
882+
type: "Identifier",
883+
name: cache.name,
884+
},
885+
params: [
886+
...(argsName
887+
? [
888+
{
889+
type: "RestElement",
890+
argument: {
891+
type: "Identifier",
892+
name: argsName,
893+
},
894+
},
895+
]
896+
: cache.node.params),
897+
],
898+
body: cache.node.body,
899+
});
900+
901+
// For exported cached functions, wrap in __react_cache__() so they
902+
// get the same per-request memoization as non-exported cached
903+
// functions.
904+
if (cache.exported && cache.identifier) {
905+
ast.body.push({
906+
type: "VariableDeclaration",
907+
kind: "const",
908+
declarations: [
909+
{
910+
type: "VariableDeclarator",
911+
id: {
912+
type: "Identifier",
913+
name: cache.identifier,
914+
},
915+
init: {
916+
type: "CallExpression",
917+
callee: {
918+
type: "Identifier",
919+
name: "__react_cache__",
920+
},
921+
arguments: [
804922
{
805-
type: "RestElement",
806-
argument: {
807-
type: "Identifier",
808-
name: argsName,
809-
},
923+
type: "Identifier",
924+
name: cache.name,
810925
},
811-
]
812-
: cache.node.params),
926+
],
927+
},
928+
},
813929
],
814-
body: cache.node.body,
815-
},
930+
});
931+
}
932+
933+
// For nested cached functions (parent is a BlockStatement inside
934+
// another function), the identifier is scoped to the enclosing
935+
// function and not accessible at module level. Use the mangled impl
936+
// name (which is always module-level) for the CACHE_KEY/PROVIDER
937+
// assignments.
938+
const isModuleScoped =
939+
cache.exported || cache.parent?.type === "Program";
940+
const cacheTarget = isModuleScoped
941+
? (cache.identifier ?? cache.name)
942+
: cache.name;
943+
ast.body.push(
816944
{
817945
type: "ExpressionStatement",
818946
expression: {
@@ -823,7 +951,7 @@ export default function useCacheInline(profiles, providers = {}, type) {
823951
computed: true,
824952
object: {
825953
type: "Identifier",
826-
name: cache.identifier ?? cache.name,
954+
name: cacheTarget,
827955
},
828956
property: {
829957
type: "Identifier",
@@ -843,7 +971,7 @@ export default function useCacheInline(profiles, providers = {}, type) {
843971
computed: true,
844972
object: {
845973
type: "Identifier",
846-
name: cache.identifier ?? cache.name,
974+
name: cacheTarget,
847975
},
848976
property: {
849977
type: "Identifier",
@@ -863,14 +991,38 @@ export default function useCacheInline(profiles, providers = {}, type) {
863991
ast.body.push({
864992
type: "ExportNamedDeclaration",
865993
declaration: null,
866-
specifiers: caches.map((cache) => ({
867-
type: "ExportSpecifier",
868-
exported: {
869-
type: "Identifier",
870-
name: cache.name === "_default" ? "default" : cache.name,
871-
},
872-
local: { type: "Identifier", name: cache.name },
873-
})),
994+
specifiers: caches.flatMap((cache) => {
995+
const specs = [
996+
{
997+
type: "ExportSpecifier",
998+
exported: {
999+
type: "Identifier",
1000+
name: cache.name === "_default" ? "default" : cache.name,
1001+
},
1002+
local: { type: "Identifier", name: cache.name },
1003+
},
1004+
];
1005+
// For exported cached functions, also export the user-facing
1006+
// identifier (which is the __react_cache__-wrapped version).
1007+
if (
1008+
cache.exported &&
1009+
cache.identifier &&
1010+
cache.identifier !== cache.name
1011+
) {
1012+
specs.push({
1013+
type: "ExportSpecifier",
1014+
exported: {
1015+
type: "Identifier",
1016+
name:
1017+
cache.identifier === "_default"
1018+
? "default"
1019+
: cache.identifier,
1020+
},
1021+
local: { type: "Identifier", name: cache.identifier },
1022+
});
1023+
}
1024+
return specs;
1025+
}),
8741026
});
8751027

8761028
return codegen(ast, id);
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { hostname, page, server } from "playground/utils";
2+
import { expect, test } from "vitest";
3+
4+
test("use cache locals", async () => {
5+
await server("fixtures/use-cache-locals.jsx");
6+
await page.goto(hostname);
7+
8+
// Test 1: Function parameter closure — `prefix` captured as a local
9+
expect(await page.textContent("#greeting")).toBe("Hello: World");
10+
11+
// Test 2: Destructured variable closure — `locale` and `currency` captured
12+
expect(await page.textContent("#formatted")).toBe("$42.00");
13+
14+
// Test 3: Array destructured variable closure — `left` and `right` captured
15+
expect(await page.textContent("#labeled")).toBe("[test]");
16+
17+
// Test 4: Exported cached function
18+
const cachedId = await page.textContent("#cached-id");
19+
expect(cachedId).toBe("default");
20+
21+
const cachedTime = await page.textContent("#cached-time");
22+
expect(cachedTime).toBeTruthy();
23+
24+
// Verify caching works — reload should return the same cached time
25+
await page.reload();
26+
expect(await page.textContent("#cached-time")).toBe(cachedTime);
27+
expect(await page.textContent("#greeting")).toBe("Hello: World");
28+
expect(await page.textContent("#formatted")).toBe("$42.00");
29+
expect(await page.textContent("#labeled")).toBe("[test]");
30+
31+
// Verify dynamic args work — different id should produce different cached-id
32+
await page.goto(hostname + "?id=test123");
33+
expect(await page.textContent("#cached-id")).toBe("test123");
34+
});
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { hostname, page, server } from "playground/utils";
2+
import { expect, test } from "vitest";
3+
4+
test("use cache sibling scope - no false closure capture across sibling functions", async () => {
5+
await server("fixtures/use-cache-sibling-scope.jsx");
6+
await page.goto(hostname);
7+
8+
// Verify the prepared (non-cached helper → cached function) path works
9+
expect(await page.textContent("#prepared-label")).toBe("[foo]");
10+
expect(await page.textContent("#prepared-value")).toBe("42");
11+
expect(await page.textContent("#prepared-extra")).toBe("bonus");
12+
13+
const preparedTimestamp = await page.textContent("#prepared-timestamp");
14+
expect(preparedTimestamp).toBeTruthy();
15+
16+
// Verify the direct cached function call works
17+
expect(await page.textContent("#direct-label")).toBe("direct");
18+
expect(await page.textContent("#direct-value")).toBe("99");
19+
expect(await page.textContent("#direct-extra")).toBe("none");
20+
21+
const directTimestamp = await page.textContent("#direct-timestamp");
22+
expect(directTimestamp).toBeTruthy();
23+
24+
// Verify caching: reload should return the same cached timestamps
25+
await page.reload();
26+
expect(await page.textContent("#prepared-timestamp")).toBe(preparedTimestamp);
27+
expect(await page.textContent("#direct-timestamp")).toBe(directTimestamp);
28+
29+
// Verify values still correct after reload
30+
expect(await page.textContent("#prepared-label")).toBe("[foo]");
31+
expect(await page.textContent("#prepared-value")).toBe("42");
32+
expect(await page.textContent("#direct-label")).toBe("direct");
33+
expect(await page.textContent("#direct-value")).toBe("99");
34+
});

0 commit comments

Comments
 (0)