Skip to content

fix(app-router): support extensionless variable imports - #1958

Merged
james-elicx merged 6 commits into
mainfrom
codex/compat-dynamic-import
Jun 12, 2026
Merged

fix(app-router): support extensionless variable imports#1958
james-elicx merged 6 commits into
mainfrom
codex/compat-dynamic-import

Conversation

@james-elicx

Copy link
Copy Markdown
Member

Summary

  • transform relative extensionless variable import() calls into lazy import.meta.glob module maps
  • preserve normal extension resolution for JavaScript and TypeScript modules
  • port the focused Next.js dynamic-import regression fixture and add transform safety tests

Next.js reference

Validation

  • vp check
  • vp test run tests/extensionless-dynamic-import.test.ts tests/nextjs-compat/dynamic.test.ts
  • Node 24, concurrency 1: VINEXT_BUILD=1 NEXT_TEST_CONCURRENCY=1 ./scripts/run-nextjs-deploy-suite.sh .nextjs-ref --retries 0 -c 1 test/e2e/app-dir/dynamic-import/dynamic-import.test.ts

PPR/cache behavior is intentionally out of scope.

@pkg-pr-new

pkg-pr-new Bot commented Jun 12, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@1958
npm i https://pkg.pr.new/vinext@1958

commit: 68a522c

@github-actions

Copy link
Copy Markdown
Contributor
Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
workers-cache preview production
web preview production

@james-elicx

Copy link
Copy Markdown
Member Author

Independent review hardened extension ordering/custom extensions/index resolution and prevented false-positive rewrites. CI is rerunning on commit 3926b53.

@james-elicx
james-elicx marked this pull request as ready for review June 12, 2026 15:58
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk

@ask-bonk

ask-bonk Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

PR #1958 Thread Summary

Title: fix(app-router): support extensionless variable imports

Author: james-elicx | Status: OPEN | Base: main ← codex/compat-dynamic-import

What it does

Fixes App Router support for relative, extensionless variable import() calls (e.g. import(\./components/${name}`)). It adds a new Vite plugin that transforms these dynamic imports into lazy import.meta.glob` module maps, while leaving normal extension-based resolution for JS/TS modules untouched.

Changes (6 files, +238/-0)

  • plugins/extensionless-dynamic-import.ts (new, +143) — the core transform plugin
  • index.ts (+2) — wires the plugin into the Vite plugin pipeline
  • tests/extensionless-dynamic-import.test.ts (new, +68) — transform safety tests
  • tests/nextjs-compat/dynamic.test.ts (+7) — extended compat coverage
  • Two new fixture files under app-basic/.../extensionless-import/ (page + button)

Next.js parity

Ports the focused regression fixture from Next.js's test/e2e/app-dir/dynamic-import/dynamic-import.test.ts. PPR/cache behavior is explicitly out of scope.

Validation

vp check, targeted Vitest runs, and the Next.js deploy suite (Node 24, concurrency 1) against the upstream dynamic-import test.

Thread activity

  • pkg-pr-new — published preview packages (vinext@1958, @vinext/cloudflare@1958).
  • github-actions — deployed preview URLs for all examples.
  • james-elicx — noted that an independent review hardened extension ordering, custom extensions, and index resolution, and prevented false-positive rewrites; CI rerunning on commit 3926b53c.

No outstanding review comments or change requests in the thread yet.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for correctness issues

@ask-bonk

ask-bonk Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

@james-elicx Bonk workflow was cancelled.

View workflow run · To retry, trigger Bonk again.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for correctness issues

1 similar comment
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for correctness issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

/bigbonk correctness review — extensionless variable imports

Focused on the new transform in packages/vinext/src/plugins/extensionless-dynamic-import.ts. The core approach (rewrite extensionless relative variable import() into an import.meta.glob map + a runtime resolver that tries file extensions, then /index extensions) is sound and matches the existing require-context.ts pattern. The fixtures exercise the file-vs-directory precedence, JSON, and static-prefix cases well. A few correctness gaps stood out.

1. Transform is not scoped to user code — node_modules deps are rewritten too

transform() has no id filter and no node_modules guard. Any dependency module that contains a relative extensionless variable import() (e.g. an i18n lib doing import(\./${locale}`)) will be rewritten into a import.meta.glob("./**/*{…}")` over that package's own directory tree.

transform(code, id) {
  if (!/\bimport\s*\(/.test(code)) return null;
  const lang = langForId(id);
  if (!lang) return null;
  // …no node_modules / id filtering…

The sibling plugins in this repo deliberately guard against this — dynamic-preload-metadata.ts uses exclude: /node_modules/ and an explicit id.includes("node_modules") bail, and client-reference-dedup.ts filters on id. require-context.ts gets away without a guard only because its trigger (require.context) is extremely narrow; this plugin triggers on a very common syntax (import(\./${x}`)`).

Deps that go through optimizeDeps won't hit this (esbuild/rolldown prebundle doesn't run Vite transform), but SSR/RSC-externalized and non-optimized deps do pass through transform hooks, so a third-party lib's own dynamic resolution can be silently replaced. Recommend adding a node_modules bail (and ideally restricting to the project root) so the transform only applies to user code, matching how the other plugins scope themselves.

2. Multi-interpolation templates only validate the first and last quasi

parseExtensionlessImport checks first for glob chars (line 117) and last for [.?#] (line 118), but never inspects the middle quasis of a multi-${} template. So these slip past the guards:

import(`./${a}*/${b}`)    // middle quasi "*/" contains a glob metachar
import(`./${a}.bak/${b}`) // middle quasi ".bak/" contains a dot
import(`./${a}?x/${b}`)   // middle quasi "?x/" contains a query-like char

These are mostly benign (the runtime key lookup just misses and falls into Promise.reject), but the intent of lines 117–118 is to refuse to rewrite anything with glob metacharacters or query/hash/extension semantics — and that intent isn't enforced for interpolated paths with more than one ${}. Worth either validating every quasi or documenting that only the boundary quasis are checked.

3. Glob scope drops the static filename prefix and sub-path segments

The glob is anchored only at the directory portion of the first quasi (everything up to the last /):

const directoryEnd = first.lastIndexOf("/") + 1;
const directory = first.slice(0, directoryEnd);
globPattern: `${directory}**/*{${moduleExtensions.join(",")}}`,

For import(\./prefixed-${slug}`)the staticprefixed-prefix is dropped, so the glob becomes.//{…}over the entire sibling tree rather than./prefixed-. Likewise import(`./${a}/${b}`)globs.//*even though the resolvable set is narrower. Runtime correctness is preserved (the exact__vinextPathkey lookup still selects the right module), but every module in the subtree gets registered as a lazy chunk, which is broader than Webpack's contextual-import scoping and can bloat the chunk graph. Worth scoping the glob tighter (include the static prefix, restrict depth when no/` appears in the dynamic portion) to better match Webpack/Next.js behavior and avoid over-emitting chunks.

Minor

  • import(\./file.${ext}`)` (variable is the extension) is transformed; it happens to work when the resolved file is a module the glob captured, but it's another case the boundary-only quasi checks don't catch.

None of these block the happy path the PR targets, and the ported Next.js regression fixture passes. #1 (node_modules scoping) is the one I'd most want addressed before merge, since it can change the behavior of third-party code that the user never intended to transform.

@ask-bonk

ask-bonk Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #1958. Summary of the correctness findings:

Correctness review of PR #1958

The core transform approach (rewrite extensionless relative variable import() into an import.meta.glob map + runtime resolver trying file extensions then /index) is sound and mirrors the existing require-context.ts pattern. Fixtures and the ported Next.js regression test pass. Three concerns:

  1. No node_modules scoping (most important)transform() has no id filter or node_modules guard, so it triggers on the very common import(./${x}) syntax inside dependencies, potentially replacing a third-party lib's own dynamic resolution with an import.meta.glob over that package's tree. Sibling plugins (dynamic-preload-metadata.ts, client-reference-dedup.ts) deliberately exclude node_modules; require-context.ts only avoids it because its trigger is far narrower.

  2. Multi-interpolation templates only validate boundary quasis — only the first quasi (glob chars) and last quasi (.?#) are checked. Middle quasis like `./${a}*/${b}` slip past the guards (benign at runtime, but defeats the intent of lines 117–118).

  3. Glob scope drops the static prefix / sub-pathimport(./prefixed-${slug}) globs ./**/* instead of ./prefixed-*, registering the entire sibling subtree as lazy chunks. Runtime stays correct (exact key lookup) but it's broader than Webpack's contextual-import scoping.

Plus a minor note that import(./file.${ext}) (variable as extension) is transformed. None block the targeted happy path; #1 is the one I'd want resolved before merge.

github run

Comment thread packages/vinext/src/plugins/extensionless-dynamic-import.ts Fixed
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for correctness issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

/bigbonk correctness review — extensionless variable imports

Re-reviewed the transform after the hardening commits (3926b5368a522c). The earlier round of concerns has been substantively addressed, so this pass focuses on what remains.

Previously-raised concerns — now resolved

  • node_modules scoping — fixed. transform now bails via isDependencyId(id) (line 37), which normalizes separators and checks for /node_modules/. Covered by the "leaves dependency imports unchanged" test.
  • Glob over-scoping / dropped static prefix — fixed. When a filename prefix is present the glob is now anchored to it ([${first}, ${first}/**/*], line 137), and there's a dedicated test asserting import.meta.glob(["./components/prefixed-*","./components/prefixed-*/**/*"]).
  • Multi-interpolation quasi validation — fixed. The glob-metachar guard now runs over all quasis (texts.some(...), line 124) rather than only the boundary quasis, and texts.slice(1) rejects any dot in a non-leading quasi (line 125). The it.each table covers ./${a}*/${b}, ./${a}.bak/${b}, ./${a}?query/${b}, and ./file.${ext}.
  • Regex backtracking (CodeQL alert #197) — fixed in 68a522c. The vulnerable ^(?:\s|\/\*[\s\S]*?\*\/|...)*$ pattern was replaced with the linear isImportPrefix scanner, and there's a regression test that feeds 10k repetitions of *//*. (The CodeQL alert predates this commit and should clear on the next scan.)

The runtime resolver order (exact key → path + extpath + "/index" + ext) is correct and matches the file-vs-directory precedence the fixtures exercise (resolver-priority.tsx wins over resolver-priority/index.js). The ported Next.js regression test and the transform unit tests all pass locally.

Remaining minor observations (non-blocking)

  1. Extension-priority parity differs from Next.js when both foo.ts and foo.js exist. The runtime resolver iterates config.resolve.extensions, whose vinext default is [".mjs", ".js", ".mts", ".ts", ".jsx", ".tsx", ".json"] (JS before TS). For an extensionless variable import that could resolve to either foo.js or foo.ts, vinext selects .js; Webpack/Next.js resolve .ts first. This is internally consistent with how vinext resolves everything else (it's Vite's order), so it's arguably correct for this toolchain — just worth being aware it's a deliberate divergence rather than a match.

  2. Extensionless-file keys depend on the bundler emitting them. The suffixless fixture relies on import.meta.glob("./**/*") producing a "./suffixless" key (the exact-key branch). This works in dev. require-context.ts explicitly documents that the prod glob "can drop extensionless keys that Webpack would include." The PR notes the VINEXT_BUILD=1 deploy suite passed, so this is presumably fine — but it's the one behavior I'd want a prod-build assertion for, since the unit tests only check the generated string and the integration test only runs the dev server. A build-mode test (or a smoke check on a deployed preview) covering the suffixless case would close the dev/prod-parity gap that AGENTS.md calls out.

  3. Nested import() inside an interpolation isn't transformed. collectExtensionlessImports returns early after matching an ImportExpression (line 96–97) and doesn't descend into the source's ${} expressions. So import(./${(await import(./${x})).y}) would leave the inner call untransformed. This is a contrived case and the inner call still works for static specifiers, so it's a missed-transform, not a miscompile.

Nothing here blocks the targeted happy path; the prior high-priority items are all addressed. Observation #2 (prod-build coverage for the extensionless-key case) is the only follow-up I'd actively suggest.

@ask-bonk

ask-bonk Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #1958.

Summary of correctness review

I re-reviewed the transform in packages/vinext/src/plugins/extensionless-dynamic-import.ts after the hardening commits (3926b5368a522c). All four previously-raised concerns are now resolved:

  • node_modules scoping — fixed via isDependencyId(id) bail (line 37).
  • Glob over-scoping — fixed; the static filename prefix is now preserved in the glob (line 137).
  • Multi-quasi validation — fixed; glob-metachar and dot checks now run over all quasis (lines 124–125).
  • Regex backtracking (CodeQL fix(cli): ensure type:module before Vite loads vite.config.ts (fixes #184) #197) — fixed in 68a522c by replacing the vulnerable regex with a linear scanner.

The runtime resolver order (exact → +ext/index+ext) is correct, and the targeted tests pass locally (31/31).

Three non-blocking observations remain:

  1. Extension-priority diverges from Next.js (JS-before-TS, matching Vite's order) — a deliberate, internally-consistent divergence.
  2. The extensionless-file (suffixless) case is only covered by dev-server integration + generated-string unit tests; a prod-build assertion would close the dev/prod parity gap that require-context.ts warns about.
  3. Nested import() inside an interpolation is a missed-transform (not a miscompile).

The PR is in good shape — the prior high-priority items are addressed and nothing blocks the targeted happy path.

github run

@james-elicx
james-elicx merged commit 70ee909 into main Jun 12, 2026
53 checks passed
@james-elicx
james-elicx deleted the codex/compat-dynamic-import branch June 12, 2026 23:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants