From aaf2b4f4f2979019592307ea3517d14a22d0c4b7 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:04:20 +1000 Subject: [PATCH 1/6] fix(build): exclude filtered require.context modules require.context regexps previously filtered only the runtime map after a broad eager glob had imported every file. This evaluated and bundled excluded modules, including from client components. Resolve and filter context entries during the transform so only accepted files become static dependencies. Keep context directories watched so create and delete events can update the generated module set. --- .../vinext/src/plugins/require-context.ts | 241 +++++++++++++----- .../require-context/filtered/excluded.js | 3 + .../require-context/filtered/included.safe.js | 1 + .../nextjs-compat/require-context/page.tsx | 10 + tests/nextjs-compat/require-context.test.ts | 12 +- 5 files changed, 207 insertions(+), 60 deletions(-) create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/require-context/filtered/excluded.js create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/require-context/filtered/included.safe.js diff --git a/packages/vinext/src/plugins/require-context.ts b/packages/vinext/src/plugins/require-context.ts index 17f8343f31..3430923a97 100644 --- a/packages/vinext/src/plugins/require-context.ts +++ b/packages/vinext/src/plugins/require-context.ts @@ -1,5 +1,5 @@ // Expands Webpack's build-time `require.context(dir, recursive, regexp)` API -// into a static module map backed by Vite's `import.meta.glob` (eager). +// into a static module map backed by eager static imports. // // Webpack exposes `require.context` to build a map of modules at compile time. // Next.js apps still use it — typically written as `(require as any).context(...)` @@ -7,18 +7,19 @@ // throws `require is not defined`. // // This transform rewrites each genuine `require.context(...)` call into an IIFE -// that wraps the result of `import.meta.glob(, { eager: true })`, -// exposing the subset of the Webpack context interface used in practice: +// backed by modules selected at build time, exposing the subset of the Webpack +// context interface used in practice: // // const ctx = require.context("./dir", true, /\.js$/); // ctx.keys(); // ["./a.js", "./b.js", ...] (relative to dir, sorted) // ctx("./a.js"); // the module namespace object // ctx.resolve("./a.js"); // the relative key (best-effort) -// ctx.id; // the glob base dir +// ctx.id; // the context base dir // -// Only the literal three-argument form with a static string directory is -// rewritten; anything dynamic is left untouched so we never silently break -// unrelated code. +// Only literal forms with a static string directory are rewritten; anything +// dynamic is left untouched so we never silently break unrelated code. +import { glob, stat } from "node:fs/promises"; +import path, { toSlash } from "pathslash"; import { parseAst, type Plugin } from "vite"; import MagicString from "magic-string"; import { @@ -29,7 +30,6 @@ import { type AstRange, type AstRecord, } from "./ast-utils.js"; -import { createTransformCache } from "./transform-cache.js"; const TRANSFORMABLE_EXTENSIONS = new Set([ ".js", @@ -50,8 +50,24 @@ type ParsedCall = { flags: string; }; +type ContextModule = { + binding: string; + key: string; + specifier: string; +}; + +type WatchedContext = { + directory: string; + recursive: boolean; + pattern: string; + flags: string; +}; + export function createRequireContextPlugin(): Plugin { - const cached = createTransformCache(); + // Static imports make edits to existing matches visible automatically. Keep + // the context definitions as well so a create/delete event that changes the + // matched file set can invalidate and re-transform the importing module. + const watchedContexts = new WeakMap>(); return { name: "vinext:require-context", @@ -63,19 +79,58 @@ export function createRequireContextPlugin(): Plugin { id: /\.(?:[cm]?[jt]s|[jt]sx)(?:\?.*)?$/i, code: /\brequire\b[\s\S]*\.context/, }, - handler(code, id) { - return cached(id, code, undefined, () => transformRequireContext(code, id)); + async handler(code, id) { + const transformed = await transformRequireContext(code, id); + const contextsForEnvironment = + watchedContexts.get(this.environment) ?? new Map(); + watchedContexts.set(this.environment, contextsForEnvironment); + + if (!transformed) { + contextsForEnvironment.delete(id); + return null; + } + + contextsForEnvironment.set(id, transformed.contexts); + for (const context of transformed.contexts) { + this.addWatchFile(context.directory); + } + + return { + code: transformed.code, + map: transformed.map, + }; }, }, + hotUpdate({ type, file, modules }) { + if (type === "update") return; + + const contextsForEnvironment = watchedContexts.get(this.environment); + if (!contextsForEnvironment) return; + + const normalizedFile = toSlash(file); + const affectedModules = new Set(modules); + let addedImporter = false; + + for (const [id, contexts] of contextsForEnvironment) { + if (!contexts.some((context) => matchesWatchedContext(normalizedFile, context))) continue; + const module = this.environment.moduleGraph.getModuleById(id); + if (!module || affectedModules.has(module)) continue; + affectedModules.add(module); + addedImporter = true; + } + + return addedImporter ? [...affectedModules] : undefined; + }, }; } type TransformResult = { code: string; map: ReturnType; -} | null; + contexts: WatchedContext[]; +}; -function transformRequireContext(code: string, id: string): TransformResult { +async function transformRequireContext(code: string, id: string): Promise { const lang = langForId(id)!; let ast: unknown; @@ -89,13 +144,26 @@ function transformRequireContext(code: string, id: string): TransformResult { if (calls.length === 0) return null; const output = new MagicString(code); - for (const call of calls) { - output.overwrite(call.range.start, call.range.end, buildReplacement(call)); + const importOffset = findImportInsertionOffset(ast); + const imports: string[] = []; + const contexts: WatchedContext[] = []; + for (const [callIndex, call] of calls.entries()) { + const resolved = await resolveContextModules(id, call, callIndex); + contexts.push(resolved.context); + for (const module of resolved.modules) { + imports.push(`import * as ${module.binding} from ${JSON.stringify(module.specifier)};`); + } + output.overwrite(call.range.start, call.range.end, buildReplacement(call, resolved.modules)); + } + if (imports.length > 0) { + const importBlock = `${importOffset > 0 ? "\n" : ""}${imports.join("\n")}\n`; + output.appendLeft(importOffset, importBlock); } return { code: output.toString(), map: output.generateMap({ hires: "boundary" }), + contexts, }; } @@ -139,6 +207,27 @@ function collectRequireContextCalls(ast: unknown): ParsedCall[] { return calls; } +function findImportInsertionOffset(ast: unknown): number { + if (!isAstRecord(ast) || ast.type !== "Program") return 0; + + let offset = 0; + if (isAstRecord(ast.hashbang) && hasRange(ast.hashbang)) { + offset = ast.hashbang.end; + } + for (const statement of nodeArray(ast.body)) { + if ( + !isAstRecord(statement) || + statement.type !== "ExpressionStatement" || + typeof statement.directive !== "string" || + !hasRange(statement) + ) { + break; + } + offset = statement.end; + } + return offset; +} + // Matches `require.context(dir, recursive?, regexp?)` where the callee object // is the `require` identifier, optionally wrapped in a `(require as any)` // TypeScript assertion or parentheses. Returns null for anything that does not @@ -160,8 +249,8 @@ function parseRequireContextCall(node: AstRecord): ParsedCall | null { const args = nodeArray(node.arguments); // First arg: the directory string. Required and must be a static, relative - // path — `import.meta.glob` only accepts relative (`./`, `../`) or absolute - // glob patterns, so a bare/aliased specifier is left untouched. + // path so each matched file can become a relative static import. A + // bare/aliased specifier is left untouched. const dir = stringLiteralValue(args[0]); if (dir == null || !(dir.startsWith("./") || dir.startsWith("../"))) return null; @@ -177,12 +266,11 @@ function parseRequireContextCall(node: AstRecord): ParsedCall | null { } // Third arg: filter regexp. Optional; defaults to matching every module. - // Parity caveat: with no regexp, the underlying `import.meta.glob` only - // surfaces files Vite can resolve as modules, so extensionless keys that - // Webpack would include can be dropped. Real-world `require.context` usage - // almost always passes a regexp, and upstream Next.js's own test for the - // extensionless case is disabled (Turbopack-pending), so this is left as a - // documented, low-risk divergence rather than worked around. + // Parity caveat: webpack's resolver can expose both extensionless and + // extension-qualified requests for one physical file. This transform maps + // each discovered file once, so the extensionless alias can be absent. + // Upstream Next.js's test for that case is disabled (Turbopack-pending), so + // this remains a documented, low-risk divergence. let pattern = ""; let flags = ""; if (args.length >= 3) { @@ -265,39 +353,19 @@ function regexLiteralValue(value: unknown): { pattern: string; flags: string } | return null; } -// Builds an IIFE that produces a Webpack-compatible require.context function -// backed by `import.meta.glob`. Vite statically analyses the `import.meta.glob` -// call, so its arguments must be literals. -function buildReplacement(call: ParsedCall): string { - const globPattern = globPatternFor(call.dir, call.recursive); - // Eager so the modules resolve synchronously, like Webpack's require.context. - const glob = `import.meta.glob(${JSON.stringify(globPattern)}, { eager: true })`; +// Builds an IIFE that produces a Webpack-compatible require.context function. +// Webpack filters directory entries before it creates module dependencies. The +// generated map must therefore contain only modules accepted by the regexp; +// filtering a broad eager import here would already have evaluated excluded +// modules and included them in the bundle. +function buildReplacement(call: ParsedCall, modules: ContextModule[]): string { const base = JSON.stringify(stripTrailingSlash(call.dir)); - // Strip the global (`g`) and sticky (`y`) flags: they make `RegExp.test()` - // stateful via `lastIndex`, so consecutive membership checks over the sorted - // keys would alternate true/false and silently drop matching modules. They - // are meaningless for the per-key `.test()` filter Webpack applies. - const filterFlags = call.flags.replace(/[gy]/g, ""); - const regexArgs = `${JSON.stringify(call.pattern)}, ${JSON.stringify(filterFlags)}`; - - // The runtime helper below normalises glob keys (which are relative to the - // current module, e.g. "./grandparent/parent/file1.js") into context keys - // relative to the base dir ("./parent/file1.js"), applies the regexp filter, - // and sorts them for deterministic ordering. + const entries = modules.map((module) => `${JSON.stringify(module.key)}: ${module.binding}`); return [ "(() => {", - ` const __modules = ${glob};`, ` const __base = ${base};`, - ` const __re = ${call.pattern ? `new RegExp(${regexArgs})` : "null"};`, - " const __prefix = __base.endsWith('/') ? __base : __base + '/';", - " const __map = Object.create(null);", - " for (const __abs in __modules) {", - " if (!__abs.startsWith(__prefix)) continue;", - " const __key = './' + __abs.slice(__prefix.length);", - " if (__re && !__re.test(__key)) continue;", - " __map[__key] = __modules[__abs];", - " }", - " const __keys = Object.keys(__map).sort();", + ` const __map = Object.assign(Object.create(null), {${entries.join(",")}});`, + ` const __keys = ${JSON.stringify(modules.map((module) => module.key))};`, " const __ctx = (__key) => {", " if (__key in __map) return __map[__key];", " const __err = new Error('Cannot find module \\'' + __key + '\\'');", @@ -312,11 +380,70 @@ function buildReplacement(call: ParsedCall): string { ].join("\n"); } -// Webpack's `recursive` flag controls whether subdirectories are included. -// Vite's glob uses `*` (one segment) vs `**` (any depth). -function globPatternFor(dir: string, recursive: boolean): string { - const base = stripTrailingSlash(dir); - return recursive ? `${base}/**/*` : `${base}/*`; +async function resolveContextModules( + id: string, + call: ParsedCall, + callIndex: number, +): Promise<{ context: WatchedContext; modules: ContextModule[] }> { + const importer = toSlash(id.split("?", 1)[0]); + const directory = path.resolve(path.dirname(importer), call.dir); + const context: WatchedContext = { + directory, + recursive: call.recursive, + pattern: call.pattern, + flags: filterFlags(call.flags), + }; + const regexp = call.pattern ? new RegExp(call.pattern, context.flags) : null; + const modules: ContextModule[] = []; + const globPattern = call.recursive ? "**/*" : "*"; + + for await (const entry of glob(globPattern, { cwd: directory, withFileTypes: true })) { + if (!entry.isFile()) { + if (!entry.isSymbolicLink()) continue; + try { + if (!(await stat(path.join(toSlash(entry.parentPath), entry.name))).isFile()) continue; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; + throw error; + } + } + + const candidate = path.relative(directory, path.join(toSlash(entry.parentPath), entry.name)); + const key = `./${candidate}`; + if (regexp && !regexp.test(key)) continue; + modules.push({ + binding: `__vinext_require_context_${callIndex}_${modules.length}`, + key, + specifier: `${stripTrailingSlash(call.dir)}/${candidate}`, + }); + } + + modules.sort((left, right) => (left.key < right.key ? -1 : left.key > right.key ? 1 : 0)); + return { context, modules }; +} + +function matchesWatchedContext(file: string, context: WatchedContext): boolean { + const candidate = path.relative(context.directory, file); + if ( + candidate.length === 0 || + candidate === ".." || + candidate.startsWith("../") || + candidate.split("/").some((segment) => segment.startsWith(".")) || + (!context.recursive && candidate.includes("/")) + ) { + return false; + } + + return ( + context.pattern === "" || new RegExp(context.pattern, context.flags).test(`./${candidate}`) + ); +} + +// Global and sticky regexps make repeated RegExp.test() calls stateful. They do +// not change which individual context key should match, so normalize them once +// before build-time filtering and development invalidation. +function filterFlags(flags: string): string { + return flags.replace(/[gy]/g, ""); } function stripTrailingSlash(value: string): string { diff --git a/tests/fixtures/app-basic/app/nextjs-compat/require-context/filtered/excluded.js b/tests/fixtures/app-basic/app/nextjs-compat/require-context/filtered/excluded.js new file mode 100644 index 0000000000..4b40a2c26b --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/require-context/filtered/excluded.js @@ -0,0 +1,3 @@ +globalThis.__requireContextExcludedEvaluated = true; + +export default "excluded"; diff --git a/tests/fixtures/app-basic/app/nextjs-compat/require-context/filtered/included.safe.js b/tests/fixtures/app-basic/app/nextjs-compat/require-context/filtered/included.safe.js new file mode 100644 index 0000000000..501adc9ab3 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/require-context/filtered/included.safe.js @@ -0,0 +1 @@ +export default "included"; diff --git a/tests/fixtures/app-basic/app/nextjs-compat/require-context/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/require-context/page.tsx index 48b99e5d79..778fd15f64 100644 --- a/tests/fixtures/app-basic/app/nextjs-compat/require-context/page.tsx +++ b/tests/fixtures/app-basic/app/nextjs-compat/require-context/page.tsx @@ -1,5 +1,8 @@ +"use client"; + export default function RequireContextWithRegex() { const translationsContext = (require as any).context("./grandparent", true, /\.js/); + const filteredContext = (require as any).context("./filtered", false, /\.safe\.js$/); // Same context but with a global-flagged regexp. A naive `new RegExp(src, "g")` // filter is stateful via `lastIndex` and would silently drop every other @@ -22,6 +25,13 @@ export default function RequireContextWithRegex() { <>
{JSON.stringify(translationsContext.keys())}
{JSON.stringify(globalFlagContext.keys())}
+
{JSON.stringify(filteredContext.keys())}
+
+        {String(
+          (globalThis as { __requireContextExcludedEvaluated?: boolean })
+            .__requireContextExcludedEvaluated === true,
+        )}
+      
{file1}
{missingCode}
diff --git a/tests/nextjs-compat/require-context.test.ts b/tests/nextjs-compat/require-context.test.ts index c253410283..0c300bebee 100644 --- a/tests/nextjs-compat/require-context.test.ts +++ b/tests/nextjs-compat/require-context.test.ts @@ -6,9 +6,9 @@ * Webpack exposes `require.context(dir, recursive, regexp)` to build a module * map at compile time. Next.js apps still use it (often written as * `(require as any).context(...)` to satisfy TypeScript). vinext rewrites the - * call at build time into a static map backed by Vite's `import.meta.glob`, - * exposing the subset of the webpack context interface used in practice: - * a callable context function with `.keys()`. + * call at build time into a statically imported module map, exposing the subset + * of the webpack context interface used in practice: a callable context + * function with `.keys()`. * * Fixture page lives in: * - fixtures/app-basic/app/nextjs-compat/require-context/ @@ -55,6 +55,12 @@ describe("Next.js compat: require-context", () => { expect(parseKeys(html, "require-context-keys-global")).toEqual(expectedKeys); }); + it("should not evaluate modules excluded by the filter regexp", async () => { + const { html } = await fetchHtml(baseUrl, "/nextjs-compat/require-context"); + expect(parseKeys(html, "require-context-filtered-keys")).toEqual(["./included.safe.js"]); + expect(html).toContain('
false
'); + }); + it("should resolve a module namespace through the context callable", async () => { const { html } = await fetchHtml(baseUrl, "/nextjs-compat/require-context"); const match = html.match(/
([^<]*)<\/pre>/);

From db8a78032f38176f5df4f03a2e45104f7077b570 Mon Sep 17 00:00:00 2001
From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
Date: Tue, 28 Jul 2026 16:50:23 +1000
Subject: [PATCH 2/6] test: update require-context unit tests for static-import
 transform

---
 tests/require-context.test.ts | 41 ++++++++++++++++++++---------------
 1 file changed, 24 insertions(+), 17 deletions(-)

diff --git a/tests/require-context.test.ts b/tests/require-context.test.ts
index dde67de8c3..e4b3a3c001 100644
--- a/tests/require-context.test.ts
+++ b/tests/require-context.test.ts
@@ -1,34 +1,41 @@
+import path from "node:path";
 import { describe, expect, it } from "vite-plus/test";
 import { createRequireContextPlugin } from "../packages/vinext/src/plugins/require-context.js";
 
-function unwrapHook(hook: any): Function {
-  return typeof hook === "function" ? hook : hook?.handler;
-}
+const importerId = path.resolve(
+  import.meta.dirname,
+  "./fixtures/app-basic/app/nextjs-compat/require-context/page.tsx",
+);
 
-function createTransform(): Function {
+function createTransform(): (code: string, id: string) => Promise<{ code: string } | null> {
   const plugin = createRequireContextPlugin();
-  return unwrapHook(plugin.transform).bind(plugin);
+  const hook = plugin.transform;
+  const handler = typeof hook === "function" ? hook : hook?.handler;
+  // The handler keys per-environment state and registers directory watchers;
+  // give it the minimal plugin context those two calls need.
+  const context = { environment: {}, addWatchFile: () => {} };
+  return handler!.bind(context as never) as never;
 }
 
 describe("vinext:require-context", () => {
-  it("rewrites literal require.context calls into an import.meta.glob map", () => {
+  it("emits static imports only for modules accepted by the regexp", async () => {
     const transform = createTransform();
-    const result = transform(
-      `const ctx = require.context("./posts", true, /\\.md$/);`,
-      "/app/page.tsx",
+    const result = await transform(
+      `const ctx = require.context("./filtered", false, /\\.safe\\.js$/);`,
+      importerId,
     );
 
-    expect(result.code).toContain('import.meta.glob("./posts/**/*", { eager: true })');
+    expect(result?.code).toContain('from "./filtered/included.safe.js"');
+    expect(result?.code).not.toContain("excluded.js");
+    expect(result?.code).toContain('["./included.safe.js"]');
   });
 
-  it("reuses the cached transform result for a repeated id/source pair", () => {
+  it("inserts generated imports after the directive prologue", async () => {
     const transform = createTransform();
-    const source = `const ctx = require.context("./posts", true, /\\.md$/);`;
+    const source = `"use client";\nconst ctx = require.context("./filtered", false, /\\.safe\\.js$/);`;
+    const result = await transform(source, importerId);
 
-    const first = transform(source, "/app/page.tsx");
-    expect(first).toBeTruthy();
-    expect(transform(source, "/app/page.tsx")).toBe(first);
-    expect(transform(`${source}\nconsole.log("changed");`, "/app/page.tsx")).not.toBe(first);
-    expect(transform(source, "/app/other.tsx")).not.toBe(first);
+    const code = result!.code;
+    expect(code.indexOf('"use client"')).toBeLessThan(code.indexOf("import * as "));
   });
 });

From 9b949f76c27de666db9d800751e9309fef81657b Mon Sep 17 00:00:00 2001
From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
Date: Tue, 28 Jul 2026 17:05:54 +1000
Subject: [PATCH 3/6] fix(build): harden require.context enumeration and
 bindings

Replace fs.glob (withFileTypes needs Node 22.2, engines allow >=22) with a readdir walk that follows directory symlinks like webpack and guards cycles via realpath, and grow the generated import binding prefix past any identifier already present in the source.
---
 .../vinext/src/plugins/require-context.ts     | 73 +++++++++++++++----
 tests/require-context.test.ts                 | 38 ++++++++++
 2 files changed, 95 insertions(+), 16 deletions(-)

diff --git a/packages/vinext/src/plugins/require-context.ts b/packages/vinext/src/plugins/require-context.ts
index 3430923a97..5a5e79e0f1 100644
--- a/packages/vinext/src/plugins/require-context.ts
+++ b/packages/vinext/src/plugins/require-context.ts
@@ -18,7 +18,8 @@
 //
 // Only literal forms with a static string directory are rewritten; anything
 // dynamic is left untouched so we never silently break unrelated code.
-import { glob, stat } from "node:fs/promises";
+import type { Dirent } from "node:fs";
+import { readdir, realpath, stat } from "node:fs/promises";
 import path, { toSlash } from "pathslash";
 import { parseAst, type Plugin } from "vite";
 import MagicString from "magic-string";
@@ -147,8 +148,12 @@ async function transformRequireContext(code: string, id: string): Promise {
   const importer = toSlash(id.split("?", 1)[0]);
@@ -395,24 +401,12 @@ async function resolveContextModules(
   };
   const regexp = call.pattern ? new RegExp(call.pattern, context.flags) : null;
   const modules: ContextModule[] = [];
-  const globPattern = call.recursive ? "**/*" : "*";
-
-  for await (const entry of glob(globPattern, { cwd: directory, withFileTypes: true })) {
-    if (!entry.isFile()) {
-      if (!entry.isSymbolicLink()) continue;
-      try {
-        if (!(await stat(path.join(toSlash(entry.parentPath), entry.name))).isFile()) continue;
-      } catch (error) {
-        if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
-        throw error;
-      }
-    }
 
-    const candidate = path.relative(directory, path.join(toSlash(entry.parentPath), entry.name));
+  for (const candidate of await listContextFiles(directory, call.recursive)) {
     const key = `./${candidate}`;
     if (regexp && !regexp.test(key)) continue;
     modules.push({
-      binding: `__vinext_require_context_${callIndex}_${modules.length}`,
+      binding: `${bindingPrefix}_${callIndex}_${modules.length}`,
       key,
       specifier: `${stripTrailingSlash(call.dir)}/${candidate}`,
     });
@@ -422,6 +416,53 @@ async function resolveContextModules(
   return { context, modules };
 }
 
+// Enumerates candidate files like webpack's context walk: dot-entries are
+// skipped (matching the prior glob semantics), symlinks resolve through stats —
+// including symlinked directories in recursive contexts, which `fs.glob` does
+// not descend into — and a realpath guard breaks symlink cycles. A missing
+// context directory yields an empty context rather than an error.
+async function listContextFiles(directory: string, recursive: boolean): Promise {
+  const files: string[] = [];
+  const visitedDirectories = new Set();
+
+  async function walk(currentDirectory: string, prefix: string): Promise {
+    let realDirectory: string;
+    let entries: Dirent[];
+    try {
+      realDirectory = await realpath(currentDirectory);
+      entries = await readdir(currentDirectory, { withFileTypes: true });
+    } catch (error) {
+      const code = (error as NodeJS.ErrnoException).code;
+      if (code === "ENOENT" || code === "ENOTDIR") return;
+      throw error;
+    }
+    if (visitedDirectories.has(realDirectory)) return;
+    visitedDirectories.add(realDirectory);
+
+    for (const entry of entries) {
+      if (entry.name.startsWith(".")) continue;
+      const entryPath = path.join(currentDirectory, entry.name);
+      let isFile = entry.isFile();
+      let isDirectory = entry.isDirectory();
+      if (entry.isSymbolicLink()) {
+        try {
+          const stats = await stat(entryPath);
+          isFile = stats.isFile();
+          isDirectory = stats.isDirectory();
+        } catch (error) {
+          if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
+          throw error;
+        }
+      }
+      if (isFile) files.push(`${prefix}${entry.name}`);
+      else if (isDirectory && recursive) await walk(entryPath, `${prefix}${entry.name}/`);
+    }
+  }
+
+  await walk(directory, "");
+  return files;
+}
+
 function matchesWatchedContext(file: string, context: WatchedContext): boolean {
   const candidate = path.relative(context.directory, file);
   if (
diff --git a/tests/require-context.test.ts b/tests/require-context.test.ts
index e4b3a3c001..d0f4b1963c 100644
--- a/tests/require-context.test.ts
+++ b/tests/require-context.test.ts
@@ -1,4 +1,7 @@
+import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
+import os from "node:os";
 import path from "node:path";
+import { parseAst } from "vite";
 import { describe, expect, it } from "vite-plus/test";
 import { createRequireContextPlugin } from "../packages/vinext/src/plugins/require-context.js";
 
@@ -38,4 +41,39 @@ describe("vinext:require-context", () => {
     const code = result!.code;
     expect(code.indexOf('"use client"')).toBeLessThan(code.indexOf("import * as "));
   });
+
+  it("avoids colliding with existing identifiers when generating import bindings", async () => {
+    const transform = createTransform();
+    const source = [
+      `const __vinext_require_context_0_0 = 1;`,
+      `const ctx = require.context("./filtered", false, /\\.safe\\.js$/);`,
+      `export { ctx, __vinext_require_context_0_0 };`,
+    ].join("\n");
+    const result = await transform(source, importerId);
+
+    const code = result!.code;
+    expect(code).not.toMatch(/import \* as __vinext_require_context_0_0 /);
+    // Redeclaring the user's binding would make the module fail to parse.
+    expect(() => parseAst(code)).not.toThrow();
+  });
+
+  it("traverses symlinked directories in recursive contexts", async () => {
+    const root = await mkdtemp(path.join(os.tmpdir(), "vinext-require-context-"));
+    try {
+      await mkdir(path.join(root, "target/sub"), { recursive: true });
+      await writeFile(path.join(root, "target/sub/deep.js"), "export default 1;\n");
+      await mkdir(path.join(root, "context"));
+      await symlink(path.join(root, "target"), path.join(root, "context/link"));
+
+      const transform = createTransform();
+      const result = await transform(
+        `const ctx = require.context("./context", true, /\\.js$/);`,
+        path.join(root, "page.tsx"),
+      );
+
+      expect(result?.code).toContain('"./link/sub/deep.js"');
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
 });

From 4faa0a6cd0753500e7fdc6098fa3446922953382 Mon Sep 17 00:00:00 2001
From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
Date: Tue, 28 Jul 2026 17:15:06 +1000
Subject: [PATCH 4/6] fix(build): scope symlink cycle guard to the recursion
 path

A global realpath set deduplicated distinct symlink aliases of the same directory; track realpaths only along the current recursion path so aliases keep their own context keys while cycles still terminate.
---
 .../vinext/src/plugins/require-context.ts     | 48 +++++++++++--------
 tests/require-context.test.ts                 |  5 ++
 2 files changed, 32 insertions(+), 21 deletions(-)

diff --git a/packages/vinext/src/plugins/require-context.ts b/packages/vinext/src/plugins/require-context.ts
index 5a5e79e0f1..97edfa06eb 100644
--- a/packages/vinext/src/plugins/require-context.ts
+++ b/packages/vinext/src/plugins/require-context.ts
@@ -419,11 +419,13 @@ async function resolveContextModules(
 // Enumerates candidate files like webpack's context walk: dot-entries are
 // skipped (matching the prior glob semantics), symlinks resolve through stats —
 // including symlinked directories in recursive contexts, which `fs.glob` does
-// not descend into — and a realpath guard breaks symlink cycles. A missing
-// context directory yields an empty context rather than an error.
+// not descend into — and a missing context directory yields an empty context
+// rather than an error. Cycles are broken by tracking realpaths along the
+// current recursion path only, so distinct symlink aliases of the same target
+// still enumerate under their own keys.
 async function listContextFiles(directory: string, recursive: boolean): Promise {
   const files: string[] = [];
-  const visitedDirectories = new Set();
+  const ancestorRealPaths = new Set();
 
   async function walk(currentDirectory: string, prefix: string): Promise {
     let realDirectory: string;
@@ -436,26 +438,30 @@ async function listContextFiles(directory: string, recursive: boolean): Promise<
       if (code === "ENOENT" || code === "ENOTDIR") return;
       throw error;
     }
-    if (visitedDirectories.has(realDirectory)) return;
-    visitedDirectories.add(realDirectory);
-
-    for (const entry of entries) {
-      if (entry.name.startsWith(".")) continue;
-      const entryPath = path.join(currentDirectory, entry.name);
-      let isFile = entry.isFile();
-      let isDirectory = entry.isDirectory();
-      if (entry.isSymbolicLink()) {
-        try {
-          const stats = await stat(entryPath);
-          isFile = stats.isFile();
-          isDirectory = stats.isDirectory();
-        } catch (error) {
-          if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
-          throw error;
+    if (ancestorRealPaths.has(realDirectory)) return;
+    ancestorRealPaths.add(realDirectory);
+
+    try {
+      for (const entry of entries) {
+        if (entry.name.startsWith(".")) continue;
+        const entryPath = path.join(currentDirectory, entry.name);
+        let isFile = entry.isFile();
+        let isDirectory = entry.isDirectory();
+        if (entry.isSymbolicLink()) {
+          try {
+            const stats = await stat(entryPath);
+            isFile = stats.isFile();
+            isDirectory = stats.isDirectory();
+          } catch (error) {
+            if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
+            throw error;
+          }
         }
+        if (isFile) files.push(`${prefix}${entry.name}`);
+        else if (isDirectory && recursive) await walk(entryPath, `${prefix}${entry.name}/`);
       }
-      if (isFile) files.push(`${prefix}${entry.name}`);
-      else if (isDirectory && recursive) await walk(entryPath, `${prefix}${entry.name}/`);
+    } finally {
+      ancestorRealPaths.delete(realDirectory);
     }
   }
 
diff --git a/tests/require-context.test.ts b/tests/require-context.test.ts
index d0f4b1963c..7505d043f8 100644
--- a/tests/require-context.test.ts
+++ b/tests/require-context.test.ts
@@ -64,6 +64,9 @@ describe("vinext:require-context", () => {
       await writeFile(path.join(root, "target/sub/deep.js"), "export default 1;\n");
       await mkdir(path.join(root, "context"));
       await symlink(path.join(root, "target"), path.join(root, "context/link"));
+      await symlink(path.join(root, "target"), path.join(root, "context/alias"));
+      // A cycle back into the context itself must terminate, not recurse forever.
+      await symlink(path.join(root, "context"), path.join(root, "target/loop"));
 
       const transform = createTransform();
       const result = await transform(
@@ -71,7 +74,9 @@ describe("vinext:require-context", () => {
         path.join(root, "page.tsx"),
       );
 
+      // Distinct symlink aliases of one target each keep their own keys.
       expect(result?.code).toContain('"./link/sub/deep.js"');
+      expect(result?.code).toContain('"./alias/sub/deep.js"');
     } finally {
       await rm(root, { recursive: true, force: true });
     }

From 214755a8cd7c3aeadc80c75194ae4801398f44c7 Mon Sep 17 00:00:00 2001
From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
Date: Tue, 28 Jul 2026 17:23:47 +1000
Subject: [PATCH 5/6] fix(build): stat directory entries with unknown dirent
 types

Filesystems without dirent type info (NFS, SMB, FUSE) report entries that are neither file nor directory; fall back to stat for any unknown type instead of only symlinks, and skip unresolvable ENOENT/ELOOP entries.
---
 packages/vinext/src/plugins/require-context.ts | 9 +++++++--
 tests/require-context.test.ts                  | 2 ++
 2 files changed, 9 insertions(+), 2 deletions(-)

diff --git a/packages/vinext/src/plugins/require-context.ts b/packages/vinext/src/plugins/require-context.ts
index 97edfa06eb..307f13f86e 100644
--- a/packages/vinext/src/plugins/require-context.ts
+++ b/packages/vinext/src/plugins/require-context.ts
@@ -447,13 +447,18 @@ async function listContextFiles(directory: string, recursive: boolean): Promise<
         const entryPath = path.join(currentDirectory, entry.name);
         let isFile = entry.isFile();
         let isDirectory = entry.isDirectory();
-        if (entry.isSymbolicLink()) {
+        // Covers symlinks and filesystems without dirent type info (NFS, SMB,
+        // FUSE), where entries report neither file nor directory. Broken links
+        // (ENOENT) and self-referential link loops (ELOOP) are unresolvable,
+        // so they cannot become context entries.
+        if (!isFile && !isDirectory) {
           try {
             const stats = await stat(entryPath);
             isFile = stats.isFile();
             isDirectory = stats.isDirectory();
           } catch (error) {
-            if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
+            const code = (error as NodeJS.ErrnoException).code;
+            if (code === "ENOENT" || code === "ELOOP") continue;
             throw error;
           }
         }
diff --git a/tests/require-context.test.ts b/tests/require-context.test.ts
index 7505d043f8..cca6862a1f 100644
--- a/tests/require-context.test.ts
+++ b/tests/require-context.test.ts
@@ -67,6 +67,8 @@ describe("vinext:require-context", () => {
       await symlink(path.join(root, "target"), path.join(root, "context/alias"));
       // A cycle back into the context itself must terminate, not recurse forever.
       await symlink(path.join(root, "context"), path.join(root, "target/loop"));
+      // A self-referential symlink (stat -> ELOOP) must be skipped, not throw.
+      await symlink(path.join(root, "context/self"), path.join(root, "context/self"));
 
       const transform = createTransform();
       const result = await transform(

From 6772622901c58d762fc251566cf97d3a942bcbb3 Mon Sep 17 00:00:00 2001
From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
Date: Tue, 28 Jul 2026 17:38:57 +1000
Subject: [PATCH 6/6] fix(build): make require.context deterministic and
 dev-invalidation complete

Assign import binding indices after sorting so readdir order cannot change bundle bytes; invalidate recursive contexts on any membership event since a directory create/delete can change matching descendants without matching the file regexp; and drop watched-context entries for updated modules so importers that lose their last require.context call stop invalidating.
---
 .../vinext/src/plugins/require-context.ts     | 35 ++++++++++++++-----
 1 file changed, 26 insertions(+), 9 deletions(-)

diff --git a/packages/vinext/src/plugins/require-context.ts b/packages/vinext/src/plugins/require-context.ts
index 307f13f86e..bd3da2ebd9 100644
--- a/packages/vinext/src/plugins/require-context.ts
+++ b/packages/vinext/src/plugins/require-context.ts
@@ -103,11 +103,19 @@ export function createRequireContextPlugin(): Plugin {
       },
     },
     hotUpdate({ type, file, modules }) {
-      if (type === "update") return;
-
       const contextsForEnvironment = watchedContexts.get(this.environment);
       if (!contextsForEnvironment) return;
 
+      // The event's own modules retransform after this update, but the
+      // transform filter skips modules that no longer call require.context —
+      // its map cleanup never runs for them. Drop their entries here instead;
+      // the transform re-adds any that still match.
+      for (const module of modules) {
+        if (module.id != null) contextsForEnvironment.delete(module.id);
+      }
+
+      if (type === "update") return;
+
       const normalizedFile = toSlash(file);
       const affectedModules = new Set(modules);
       let addedImporter = false;
@@ -400,19 +408,22 @@ async function resolveContextModules(
     flags: filterFlags(call.flags),
   };
   const regexp = call.pattern ? new RegExp(call.pattern, context.flags) : null;
-  const modules: ContextModule[] = [];
+  const accepted: Omit[] = [];
 
   for (const candidate of await listContextFiles(directory, call.recursive)) {
     const key = `./${candidate}`;
     if (regexp && !regexp.test(key)) continue;
-    modules.push({
-      binding: `${bindingPrefix}_${callIndex}_${modules.length}`,
-      key,
-      specifier: `${stripTrailingSlash(call.dir)}/${candidate}`,
-    });
+    accepted.push({ key, specifier: `${stripTrailingSlash(call.dir)}/${candidate}` });
   }
 
-  modules.sort((left, right) => (left.key < right.key ? -1 : left.key > right.key ? 1 : 0));
+  // Assign binding indices after sorting: readdir order is filesystem
+  // dependent, and index-before-sort would leak that order into the generated
+  // identifiers, changing bundle bytes for an unchanged source tree.
+  accepted.sort((left, right) => (left.key < right.key ? -1 : left.key > right.key ? 1 : 0));
+  const modules = accepted.map((entry, index) => ({
+    ...entry,
+    binding: `${bindingPrefix}_${callIndex}_${index}`,
+  }));
   return { context, modules };
 }
 
@@ -486,6 +497,12 @@ function matchesWatchedContext(file: string, context: WatchedContext): boolean {
     return false;
   }
 
+  // A created or deleted directory in a recursive context can add or remove
+  // matching descendants even though its own path fails the file regexp (the
+  // watcher may only report the directory, e.g. a symlinked directory with
+  // followSymlinks disabled), so membership alone must invalidate.
+  if (context.recursive) return true;
+
   return (
     context.pattern === "" || new RegExp(context.pattern, context.flags).test(`./${candidate}`)
   );