Skip to content

fix(config): honor custom TypeScript config parity#2247

Open
james-elicx wants to merge 16 commits into
mainfrom
codex/fix-custom-tsconfig-parity
Open

fix(config): honor custom TypeScript config parity#2247
james-elicx wants to merge 16 commits into
mainfrom
codex/fix-custom-tsconfig-parity

Conversation

@james-elicx

Copy link
Copy Markdown
Member

Summary

  • honor typescript.tsconfigPath across App, Pages, and middleware builds
  • match TypeScript path, baseUrl, package extends, and circular-extends resolution semantics
  • reload when the selected config or inherited config changes

Validation

  • vp test run tests/tsconfig-paths-vite8.test.ts (24 passed)
  • focused vp check
  • vp run vinext#build

Next.js reference: v16.2.6 (ee6e79b1792a4d401ddf2480f40a83549fe8e722).

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@pkg-pr-new

pkg-pr-new Bot commented Jun 21, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 601e29d

@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

Comment thread packages/vinext/src/index.ts Fixed
@github-actions

github-actions Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 601e29d against base cf9d601 using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 0 regressed · 6 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 132.8 KB 132.8 KB ⚫ -0.0%
Client entry size (gzip) vinext 118.7 KB 118.7 KB ⚫ -0.0%
Dev server cold start vinext 2.42 s 2.39 s ⚫ -1.4%
Production build time vinext 2.90 s 2.93 s ⚫ +1.0%
RSC entry closure size (gzip) vinext 92.2 KB 92.2 KB ⚫ -0.0%
Server bundle size (gzip) vinext 166.9 KB 166.9 KB ⚫ -0.0%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Jun 21, 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 issues

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

/bigbonk review

Reviewed the custom typescript.tsconfigPath parity work (commits 251bb7f..7772a7e). The implementation is solid and well-tested — exact/wildcard precedence, circular-extends detection, package exports/tsconfig-field resolution, symlink escape guards, and the dev watcher reload all have dedicated coverage, and vp check + the compat suite pass locally. A few observations below, mostly minor.

Findings

1. typescript.tsconfigPath: "" diverges from Next.js (parity)next-config.ts:1148-1155
resolveTsconfigPath() throws for an empty string. Next.js validates this option with Zod (z.string().optional()), which accepts "". So a config that Next.js would load (resolving to the project root) is a hard error in vinext. The PR explicitly claims parity, so this is worth flagging. Rejecting "" is arguably more defensible (an empty tsconfig path is nonsensical), but if the goal is strict parity it should either match Next.js (accept "") or the divergence should be a documented, deliberate choice per AGENTS.md. The null/number rejections are fine — Zod rejects those too.

2. Redundant double-parse of the custom configindex.ts:1852-1864
customTsconfigResolution = loadCustomTsconfigResolution(customTsconfigPath) (1852-1854) already fully parses the config and throws on parse/extends errors. The subsequent loadTsconfigPathAliases(selectedTsconfig.path, root, new Set(), true) (1862-1864) re-parses the same file (plus its extends chain) through a second, independent code path purely to re-validate, discarding the result. Two parsers validating the same files risks them drifting apart on what they consider valid, and it's wasted work on the config hot path. Consider dropping the second call and relying on loadCustomTsconfigResolution for validation, or add a comment explaining why both passes are intentionally required.

3. dependencyRealRootsByProject cache is never invalidatedindex.ts:810-841
The dependency-root list is memoized per project root for the process lifetime. If a dependency is added to node_modules during a running dev session, a bare import from that new package could be misclassified (treated as a project-root baseUrl candidate rather than a dependency). Edge case, but the tsconfig watcher already does a full-reload on config changes — it may be worth clearing this cache on the same event for consistency. Not blocking.

4. CodeQL alert appears already resolved
The Advanced Security alert ("replaces only the first occurrence of *") flagged an earlier commit. The current code uses replaceAll("*", ...) at index.ts:383 and index.ts:1575; there are no remaining single-occurrence .replace("*", ...) calls. The alert should auto-dismiss once it re-scans 7772a7e.

Things verified as correct (no action needed)

  • Path/baseUrl resolution order (paths → installed package → baseUrl) matches TS/Next.js semantics, including resolving inherited child paths relative to a parent-defined baseUrl (loadCustomTsconfigResolution pathsRoot = baseUrl ?? pathsBasePath).
  • Wildcard precedence tiebreak (matchCustomTsconfigPath >= keeps first-declared on equal prefix length) matches TS declaration-order behavior.
  • Export-target escape guards (resolveTsconfigExportTarget) correctly run the node_modules / .. checks after wildcard substitution, and skipSelf: true on every this.resolve prevents recursion.
  • Completed-cache safety: loadCustomTsconfig returns fresh watchedFiles Set copies so callers can't mutate cached entries.

Overall this looks good to merge once the "" parity question (1) is decided and the redundant validation pass (2) is either removed or justified.

@ask-bonk

ask-bonk Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Posted the review to PR #2247. Summary of what I found:

Two actionable items:

  1. typescript.tsconfigPath: "" parity divergence (next-config.ts:1148-1155) — vinext throws on empty string, but Next.js's Zod schema (z.string().optional()) accepts it. Either match Next.js or document the deliberate divergence.
  2. Redundant double-parse (index.ts:1852-1864) — the config is fully parsed/validated by loadCustomTsconfigResolution, then re-parsed by loadTsconfigPathAliases purely for validation. Two parsers can drift; drop the second call or justify it.

One minor note:
3. dependencyRealRootsByProject cache (index.ts:810-841) is never invalidated — a dependency added mid-dev-session could be misclassified. Could clear it on the same watcher full-reload.

Confirmed resolved/correct:

  • The CodeQL alert ("only replaces first *") is already fixed — current code uses replaceAll.
  • Path/baseUrl resolution order, wildcard precedence tiebreak, export escape guards, skipSelf recursion prevention, and completed-cache mutation safety all check out and match TS/Next.js semantics.

The implementation is well-tested and vp check + the compat suite pass locally. Recommended to merge once items 1 and 2 are addressed.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for 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 review

Focused on the custom typescript.tsconfigPath parity work. The resolver itself (path/baseUrl precedence, circular-extends detection, package exports/tsconfig-field resolution, escape guards, dev watcher reload) is well-built and well-tested. I verified the previous /bigbonk review's findings against the actual Next.js 16.2.7 install in this repo's node_modules, and one of them is backwards — the real divergence is the opposite of what was reported. Details below.

Finding 1 (correcting the prior review): tsconfigPath: "" / null should warn-and-fall-back, not hard-error

The earlier review claimed Next.js's schema is z.string().optional() and therefore accepts "". That is not the schema shipped in next@16.2.7. The actual schema is:

// next/dist/server/config-schema.js
tsconfigPath: _zod.z.string().min(1).optional()

So Next.js does flag "" — but the consequence is not a hard error. Two things happen:

  1. Validation is non-fatal. validateConfigSchema (next/dist/server/config.js:1496) splits Zod issues into warnings and fatalErrors via normalizeNextConfigZodErrors (:103). Only images errors and specific experimental.turbopack* keys are fatal; a .min(1) violation on tsconfigPath lands in warnings. Next.js prints a warning and continues — it does not abort the build.

  2. The runtime falls back to the default. load-jsconfig.js:117:

    const tsConfigFileName = config.typescript.tsconfigPath || 'tsconfig.json';

    With "" (and with null), the || selects 'tsconfig.json'. So a Next.js build with tsconfigPath: "" warns once, then loads the default tsconfig.json and proceeds.

vinext instead throws a hard error and aborts the build — resolveTsconfigPath() (config/next-config.ts:1149-1157) throws on empty string, and even if it didn't, selectTsconfig() (index.ts:874) would throw Cannot read file because path.join(root, "") is a directory. The test at tests/next-config.test.ts:1765-1771 codifies this throw-on-""/null behavior, so the divergence is deliberate-by-test, not accidental — but it's the opposite of Next.js.

Per AGENTS.md ("If Next.js and vinext should behave differently ... it must be a deliberate, documented decision, not an accidental divergence"), this should either:

  • match Next.js (treat falsy tsconfigPath as unset → fall back to default tsconfig.json, optionally with a warning), or
  • be documented as an intentional stricter-than-Next.js choice, with the test comment linking to load-jsconfig.js:117 to explain why we reject what Next.js tolerates.

Rejecting non-string types (42) is more defensible since Next.js's 42 || 'tsconfig.json'path.join(dir, 42) throws a TypeError at runtime anyway. But "" and null specifically resolve cleanly to the default in Next.js.

Finding 2 (prior review item 2 looks addressed): double-parse now has a rationale comment

The prior review flagged a "redundant double-parse" at the old index.ts:1852-1864. In the current head, the second loadTsconfigPathAliases(...) call (index.ts:1856-1858) is preceded by a comment explaining it is intentional (custom configs are owned by the paths plugin; this validates without materializing stale aliases). The drift risk between the two parsers (loadCustomTsconfigResolution vs loadTsconfigPathAliases) the prior review noted is still real — they are independent parsers that must agree on what is valid — but the call is now justified in-code. Reasonable to leave as-is; worth a focused test asserting both paths reject the same malformed configs if you want to lock the invariant down.

Finding 3 (agree with prior review, minor): dependencyRealRootsByProject is never invalidated

index.ts:804 memoizes the dependency-root list per project for the process lifetime. The custom-tsconfig watcher already does moduleGraph.invalidateAll() + full-reload on config change (index.ts:1535-1539); clearing this Map in the same handler would keep a dependency added mid-session from being misclassified as a project-root baseUrl candidate by isDependencyImporter (:806). Edge case, non-blocking.

Verified correct (no action)

  • Path → installed-package → baseUrl resolution order in the custom resolver (resolveId, index.ts:1566-1596) matches TS/Next.js semantics, including the baseUrl-vs-package disambiguation via createRequire(importerFile).resolve(id).
  • Wildcard precedence tiebreak in matchCustomTsconfigPath (index.ts:790-801) keeps the longer fixed prefix and is stable on ties.
  • Export-target escape guards (resolveTsconfigExportTarget, index.ts:382-393) run node_modules / .. / dot-segment checks after * substitution, and use replaceAll("*", ...) (CodeQL alert 217 is moot on the current head).
  • skipSelf: true on every this.resolve prevents recursion into this same plugin.
  • loadCustomTsconfig returns fresh watchedFiles Set copies from the completed cache (index.ts:733, :780) so callers can't mutate cached entries.

The implementation is strong; Finding 1 is the one I'd want resolved (or explicitly documented) before merge, since the PR's stated goal is parity and the prior review's parity assessment of that exact item was inverted.

@ask-bonk

ask-bonk Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2247.

Summary

I reviewed the custom typescript.tsconfigPath parity work and verified the previous /bigbonk review's findings against the actual next@16.2.7 install in node_modules. The headline result: the prior review's main parity finding was inverted.

Finding 1 (corrects prior review) — tsconfigPath: "" / null should warn-and-fall-back, not hard-error.

  • The prior review claimed Next.js's schema is z.string().optional() and accepts "". The real schema is z.string().min(1).optional().
  • But that .min(1) violation is a non-fatal warning in validateConfigSchema (only images/turbopack* are fatal), and load-jsconfig.js:117 does config.typescript.tsconfigPath || 'tsconfig.json', so ""/null fall back to the default tsconfig and the build continues.
  • vinext instead throws and aborts (next-config.ts:1149, also index.ts:874), codified by tests/next-config.test.ts:1765. Per AGENTS.md this needs to either match Next.js or be documented as deliberate.

Finding 2 — the "redundant double-parse" the prior review flagged now carries an in-code rationale comment; reasonable to keep, though the two-parser drift risk remains.

Finding 3 — agreed with prior review: dependencyRealRootsByProject cache isn't cleared on the watcher full-reload. Minor.

I also confirmed the resolution order, wildcard tiebreak, export escape guards, skipSelf recursion prevention, and cache mutation-safety all check out, and that the CodeQL alert is moot on the current head (replaceAll).

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for 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 review

Reviewed the custom typescript.tsconfigPath parity work against the current head (a901f31). The previous /bigbonk reviews were posted against earlier commits, so I re-checked their findings against the code as it stands now. Two of the three prior findings are now resolved; one parity gap remains.

Finding 1 (partially fixed — null still diverges from Next.js)

The prior review's headline item was that tsconfigPath: "" hard-errored instead of falling back. Commit a901f31 fixed exactly that — resolveTsconfigPath (config/next-config.ts:1149-1157) now returns undefined for "", and tests/next-config.test.ts:1765 codifies the fall-back. Good.

But null is still rejected (tests/next-config.test.ts:1773, it.each([42, null])("rejects invalid values")), and that remains a parity divergence:

  • Next.js's schema is z.string().min(1).optional() (next/dist/server/config-schema.js:716). null fails it.
  • That failure is a non-fatal warningnormalizeNextConfigZodErrors (next/dist/server/config.js:103-137) only marks images and specific experimental.turbopack* keys as fatal; everything else (including a tsconfigPath violation) lands in warnings, so the build continues.
  • At runtime load-jsconfig.js:117 does config.typescript.tsconfigPath || 'tsconfig.json', so null || 'tsconfig.json' → falls back to the default tsconfig.

Net: Next.js warns once and proceeds with the default for null; vinext throws and aborts. Since "" was deliberately changed to match Next.js, null should get the same treatment for consistency (both are falsy values that Next.js resolves to the default via ||). Either fold null into the "" branch (if (value === undefined || value === "" || value === null) return undefined;) or, if the stricter behavior is intentional, document it per AGENTS.md with a comment linking load-jsconfig.js:117.

Rejecting 42 is more defensible — Next.js's path.join(dir, 42) throws a TypeError at runtime anyway — so I'd leave that as-is.

Finding 2 (resolved): redundant double-parse is gone

The prior review flagged a redundant second parse of the custom config purely for validation. In the current head that's gone: for custom configs tsconfigPathAliases is {} and the config is parsed once via loadCustomTsconfigResolution (index.ts:1908-1917). No second validating parse on the custom path. No action.

Finding 3 (still present, minor): dependencyRealRootsByProject is never invalidated

index.ts:866 memoizes the per-project dependency-root list for the process lifetime, and the custom-tsconfig watcher (index.ts:1590-1608) already does moduleGraph.invalidateAll() + full-reload on config change. Clearing this Map in the same handler would keep a dependency added mid-dev-session from being misclassified by isDependencyImporter (index.ts:868). Edge case, non-blocking.

Verified correct (no action)

  • Path → installed-package → baseUrl resolution order (resolveId, index.ts:1616-1659) matches TS/Next.js semantics, including the baseUrl-vs-package disambiguation via createRequire(importerFile).resolve(id).
  • Wildcard substitution uses replaceAll("*", ...) at index.ts:423 and index.ts:1631 — CodeQL alert 217 ("replaces only the first occurrence") is moot on the current head.
  • Export-target escape guards (resolveTsconfigExportTarget, index.ts:414-462) run node_modules / .. / dot-segment checks after * substitution, gate on .json extension, and reject backslashes on POSIX.
  • skipSelf: true on every this.resolve prevents recursion into this same plugin.
  • selectTsconfig correctly receives the resolved (""undefined) path, so an empty tsconfigPath falls through to default detection (index.ts:930-959).

Implementation is strong and well-tested. The one item I'd resolve (or document) before merge is the null divergence in Finding 1, since the PR's stated goal is parity and "" was already brought into line.

@ask-bonk

ask-bonk Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2247.

Summary

I reviewed the custom typescript.tsconfigPath parity work against the current head (a901f31), since the prior /bigbonk reviews were posted against earlier commits. Re-checking their findings against the code as it stands:

Finding 1 — partially fixed; null still diverges (actionable).

  • The prior review's headline (empty-string hard-error) was fixed in a901f31: resolveTsconfigPath now returns undefined for "", matching Next.js's fall-back-to-default behavior.
  • But null is still rejected with a throw (tests/next-config.test.ts:1773). Next.js treats null as a non-fatal warning then falls back via config.typescript.tsconfigPath || 'tsconfig.json' (load-jsconfig.js:117), so the build proceeds. Since "" was deliberately aligned, null should get the same treatment — or the stricter behavior should be documented. 42 is fine to reject (Next.js's path.join(dir, 42) throws at runtime anyway).

Finding 2 — resolved. The redundant double-parse the prior review flagged is gone; custom configs are parsed once via loadCustomTsconfigResolution.

Finding 3 — still present, minor. dependencyRealRootsByProject (index.ts:866) is never cleared on the watcher's full-reload; a dependency added mid-dev-session could be misclassified. Non-blocking.

Verified correct: resolution order, wildcard tiebreak, export escape guards (../node_modules/dot-segments/.json), skipSelf recursion prevention, replaceAll (CodeQL alert 217 moot), and selectTsconfig's ""→default fall-through.

The implementation is strong and well-tested. The one item worth resolving (or documenting) before merge is the null divergence, since the PR's stated goal is parity and "" was already brought into line.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for 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 review

Reviewed against the current head (468969f), which is one commit newer than the prior /bigbonk reviews (a901f31). The new commit fix(config): align tsconfig selection warnings resolves the last open parity item and adds a typescript-presence gate. Verified the resolver against the actual next@16.2.7 install in node_modules (build/load-jsconfig.js, server/config-schema.js). Net: the prior reviews' Findings 1 and 2 are now resolved, Finding 3 remains. No new blocking issues.

Finding 1 (now resolved) — tsconfigPath: "" / null parity

The prior reviews flagged that "" (and later null) hard-errored instead of warning + falling back to the default. Commit 468969f aligns both with Next.js. resolveTsconfigPath (config/next-config.ts:1149-1167) now:

  • returns undefined for undefined,
  • warns and returns undefined for null / "", with messages that mirror the Zod issues Next.js surfaces ("Expected string, received null" / "String must contain at least 1 character(s)"),
  • throws only for non-string types (e.g. 42).

This matches next@16.2.7: tsconfigPath: z.string().min(1).optional() (config-schema.js:716) treats ""/null as non-fatal warnings, and load-jsconfig.js:117 does config.typescript.tsconfigPath || 'tsconfig.json', so falsy values fall back to the default. Rejecting 42 is a defensible divergence — Next.js's path.join(dir, 42) throws a TypeError at runtime anyway. Tests at tests/next-config.test.ts:1765-1793 codify all three branches. Good.

Finding 2 (resolved) — redundant double-parse is gone

Custom configs now parse once via loadCustomTsconfigResolution; tsconfigPathAliases is {} on the custom path (index.ts:1926-1928) and resolution happens dynamically in resolveId. No second validating parse. No action.

Finding 3 (still present, minor) — dependencyRealRootsByProject is never invalidated

index.ts:866 memoizes the per-project dependency-root list for the process lifetime, and isDependencyImporter (index.ts:868) reads it. The custom-tsconfig watcher (index.ts:1601-1619) already does moduleGraph.invalidateAll() + full-reload on config change but does not clear this Map. A dependency added mid-dev-session could be misclassified (treated as a project-root baseUrl candidate rather than a dependency). Edge case, non-blocking — clearing the entry for projectRoot in the same watcher handler would close it.

New in 468969f: hasProjectTypeScript gate — verified correct

selectTsconfig (index.ts:939-970) now gates on whether typescript resolves from the project (hasProjectTypeScript, index.ts:930-937). This faithfully reproduces load-jsconfig.js:

  • TS present + custom path + file exists → custom (useTypeScript = true). ✓
  • TS present + custom path missing → falls back to jsconfig.json only, never the default tsconfig.json — matches Next.js exactly (useTypeScript = falseif (!useTypeScript && existsSync(jsConfigPath))). Covered by tsconfig-paths-vite8.test.ts:858. ✓
  • TS not installed → ignores tsconfigPath entirely, uses jsconfig.json (useTypeScript requires typeScriptPath). Covered by tsconfig-paths-vite8.test.ts:164. ✓

hasProjectTypeScript resolving from path.join(projectRoot, "package.json") is safe even when package.json is absent (createRequire only uses the path as a resolution base), matching hasNecessaryDependencies(dir, …).

Verified correct (no action)

  • Export-target escape guards (resolveTsconfigExportTarget, index.ts:414-462): replaceAll("*", …) (CodeQL alert 217 moot), ./.. segment rejection after substitution, node_modules rejection, .json extension gate, POSIX backslash rejection.
  • Path → installed-package → baseUrl resolution order, wildcard tiebreak, and skipSelf: true recursion prevention all match TS/Next.js semantics.
  • vp check (changed files), tests/next-config.test.ts, tests/tsconfig-paths-vite8.test.ts (44), tests/nextjs-compat/custom-tsconfig.test.ts (7), and vp run vinext#build all pass locally.

Minor note (non-blocking)

  • The tsconfigPath warning fires once per plugin instance. A hybrid app+pages build instantiates vinext() more than once (see the build-ID comment at index.ts:1872-1881), so a misconfigured tsconfigPath could warn twice. Next.js dedups via a module-level flag. Cosmetic only.

Implementation is strong and well-tested. The only carried-over item is the minor Finding 3 cache invalidation; nothing blocking.

@ask-bonk

ask-bonk Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2247.

Summary

I reviewed the custom typescript.tsconfigPath parity work against the current head (468969f), which is one commit newer than the prior /bigbonk reviews (a901f31). The new commit fix(config): align tsconfig selection warnings resolves the last open parity item. I verified the resolver against the actual next@16.2.7 install in node_modules.

Findings:

  1. tsconfigPath: "" / null parity — now RESOLVED. 468969f makes resolveTsconfigPath warn-and-fall-back for falsy values (matching Next.js's non-fatal Zod .min(1) warning + || 'tsconfig.json' fallback in load-jsconfig.js:117), and only throws for non-string types like 42. Warning messages mirror Next.js's Zod issue strings. Tests codify all three branches.

  2. Redundant double-parse — RESOLVED. Custom configs parse once; aliases are {} and resolution is dynamic.

  3. dependencyRealRootsByProject cache — still present, minor. Memoized for process lifetime, not cleared on the watcher's full-reload. Non-blocking edge case.

New hasProjectTypeScript gate — verified correct. It faithfully reproduces load-jsconfig.js's useTypeScript = Boolean(typeScriptPath && existsSync(tsConfigPath)) logic, including the subtle case where a missing custom path falls back to jsconfig.json only (never the default tsconfig.json).

Verified correct: export escape guards (replaceAll, ../node_modules/dot-segment/.json checks), resolution order, wildcard tiebreak, skipSelf recursion prevention. vp check, all targeted tests (44 + 7 + tsconfigPath), and vp run vinext#build pass locally.

No blocking issues. The implementation is strong and well-tested.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

1 similar comment
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for 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 review

Reviewed against the current head (1d5785f), which is two commits newer than the prior /bigbonk reviews (468969f). The two new commits are fix(config): require project TypeScript compiler (2a3243e) and fix(config): reject mixed tsconfig package exports (1d5785f). The earlier parity findings (""/null fallback, redundant double-parse) remain resolved. I found one new parity divergence introduced by 1d5785f, verified against the actual typescript@5.9.3 resolver via tsc --showConfig.

Finding (new, introduced by 1d5785f) — mixed package exports rejects the main export, diverging from TypeScript

resolveTsconfigPackageExport (packages/vinext/src/index.ts:481-483) now returns null for any exports object whose keys mix subpath keys (./…) with non-subpath keys (conditions like node, default):

if (subpathKeys.length > 0 && subpathKeys.length !== exportKeys.length) {
  return null;
}

This is correct for subpath access (extends: "preset/base"), but it over-rejects the main export (extends: "preset"). TypeScript only requires allKeysStartWithDot for subpath resolution; for the main export it falls back to exports["."] when present, even if other (condition) keys are mixed in. From node_modules/typescript/lib/typescript.js loadModuleFromExports:

if (subpath === ".") {
  // string | array | noKeyStartsWithDot → use as mainExport
  // else if hasProperty(exports, ".") → use exports["."]
} else if (allKeysStartWithDot(exports)) { /* resolve subpath */ }

Repronode_modules/preset/package.json:

{ "name": "preset", "exports": { ".": "./main.json", "node": "./node.json" } }
  • tsconfig.json { "extends": "preset" }TypeScript resolves ./main.json (tsc --showConfig applies main.json's options). vinext throws Cannot read file 'preset'. ← divergence.
  • tsconfig.json { "extends": "preset/base" } (with { "./base": …, "node": … }) → TypeScript errors File 'preset/base' not found; vinext also rejects. ✓ matches (this is the case the commit's new test covers).

So the new test (tsconfig-paths-vite8.test.ts:822, "rejects package exports that mix subpaths and conditions") only exercises the subpath path; the main-export path was inadvertently broken. Per AGENTS.md this is an accidental, undocumented divergence from Next.js/TypeScript.

Suggested fix: scope the mixed-keys rejection to subpath access only — when packageSubpath === "" and exports["."] exists, resolve it. e.g. gate the early return null on packageSubpath (the main-export Object.hasOwn(exportsMap, exportKey) branch at :490 already handles . correctly once the early return is bypassed). Add a regression test for extends: "preset" against a mixed-keys exports object.

2a3243e (hasProjectTypeScripttypescript/lib/typescript.js) — verified correct

Switching the resolution probe from typescript/package.json to typescript/lib/typescript.js (index.ts:932) more faithfully reproduces Next.js load-jsconfig.js's hasNecessaryDependencies check, which resolves the compiler entry, not just metadata. The new test ("uses jsconfig when the app has TypeScript metadata without its compiler", tsconfig-paths-vite8.test.ts:200) correctly covers the "package.json present but no compiler" case, and the setupProject helper now writes the real lib/typescript.js. Good.

Carried-over items

  • Finding (prior) — dependencyRealRootsByProject never invalidated (index.ts:870, read at :880): still memoized for the process lifetime and not cleared in the custom-tsconfig watcher (index.ts:1615-1616, which does moduleGraph.invalidateAll() + full-reload). Minor edge case; clearing the projectRoot entry there would close it. Non-blocking.
  • ""/null parity (config/next-config.ts:1149-1167) and the redundant double-parse remain resolved.

Verified (no action)

  • Export-target escape guards (resolveTsconfigExportTarget, index.ts:414-462): replaceAll("*", …), dot-segment / .. / node_modules rejection after substitution, .json gate, POSIX backslash rejection — all correct.
  • Path → installed-package → baseUrl order, wildcard tiebreak, skipSelf: true recursion prevention.
  • tests/tsconfig-paths-vite8.test.ts, tests/next-config.test.ts, tests/nextjs-compat/custom-tsconfig.test.ts (258 total) all pass locally on 1d5785f.

The one item worth fixing before merge is the mixed-exports main-export divergence in 1d5785f, since the PR's stated goal is parity and the new commit narrowed acceptance beyond what TypeScript does.

@ask-bonk

ask-bonk Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2247.

Summary

Reviewed against the current head (1d5785f), which is two commits newer than the prior /bigbonk reviews (468969f). I verified findings against the actual typescript@5.9.3 resolver using tsc --showConfig.

New finding (introduced by 1d5785f "reject mixed tsconfig package exports") — actionable parity divergence:

resolveTsconfigPackageExport (index.ts:481-483) now blanket-rejects any exports object that mixes subpath keys with condition keys. This is correct for subpath access but over-rejects the main export:

  • exports: { ".": "./main.json", "node": "./node.json" } accessed via extends: "preset"TypeScript resolves ./main.json (confirmed: tsc --showConfig applies main.json's options), but vinext throws Cannot read file 'preset'.

TypeScript only requires allKeysStartWithDot for subpath resolution; for the main export it falls back to exports["."] when present. The commit's new test only covers the subpath case (which matches), so the main-export regression went uncaught. Suggested fix: scope the mixed-keys rejection to packageSubpath !== "" and add a regression test.

Verified correct:

  • 2a3243e (hasProjectTypeScript probing typescript/lib/typescript.js) faithfully reproduces Next.js's hasNecessaryDependencies compiler-entry check.
  • Prior findings (""/null fallback, redundant double-parse) remain resolved.
  • Export escape guards, resolution order, wildcard tiebreak, skipSelf recursion prevention all check out.
  • 258 tests across the three affected suites pass locally.

Carried over (minor, non-blocking): dependencyRealRootsByProject cache is still not invalidated on the watcher's full-reload.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

/bigbonk review

Reviewed against the current head (601e29d), which is one commit newer than the prior /bigbonk review (1d5785f). The new commit fix(config): allow mixed main tsconfig exports resolves the mixed-exports divergence flagged in the previous review. I verified the resolver against the actual typescript@5.9.3 (lib/typescript.js) and next@16.2.7 (config.js, config-schema.js) installs in node_modules.

Net: all four prior findings are now resolved or confirmed correct; only the minor cache-invalidation item carries over. No blocking issues.

Finding (prior review's mixed-exports divergence) — now RESOLVED

601e29d scopes the mixed-keys rejection to subpath access only:

if (packageSubpath && subpathKeys.length > 0 && subpathKeys.length !== exportKeys.length) {
  return null;
}

This matches TypeScript's loadModuleFromExports (typescript.js:45855-45880): for subpath === "." it uses the whole object when noKeyStartsWithDot, else falls back to exports["."] if present — regardless of mixed condition keys. The new code falls through the early return for the main export and resolves exports["."] at index.ts:490. Verified the three cases against TS semantics:

  • exports: { ".": "./main.json", "node": ... } via extends: "preset" → resolves ./main.json. ✓ (now covered by the new test at tsconfig-paths-vite8.test.ts:845)
  • exports: { "./base": ..., "node": ... } via extends: "preset" → no . property, not all-dot → mainExport undefined → null. ✓
  • exports: { "./base": ..., "node": ... } via extends: "preset/base" (subpath) → still rejected. ✓ (the case the 1d5785f test covers)

Finding 1 (tsconfigPath: "" / null parity) — RESOLVED

resolveTsconfigPath (config/next-config.ts:1149-1167) warns and returns undefined for null/"", and throws only for non-string types. This matches next@16.2.7: tsconfigPath: z.string().min(1).optional() (config-schema.js:716) treats null/"" as non-fatal warningsnormalizeNextConfigZodErrors (config.js:103-137) only marks images and specific experimental.turbopack* keys as shouldExit; everything else lands in warnings and the build continues — then load-jsconfig.js does config.typescript.tsconfigPath || 'tsconfig.json', falling back to the default. Rejecting 42 is a defensible divergence (path.join(dir, 42) throws at runtime in Next.js anyway). Tests at tests/next-config.test.ts codify all branches.

Finding 2 (redundant double-parse) — RESOLVED

Custom configs parse once via loadCustomTsconfigResolution; no second validating parse on the custom path.

Finding 3 (dependencyRealRootsByProject never invalidated) — still present, minor

index.ts:870 memoizes the per-project dependency-root list for the process lifetime (read by isDependencyImporter, :872). The custom-tsconfig watcher (index.ts:1605-1623) does moduleGraph.invalidateAll() + full-reload on config change but does not clear this Map. A dependency added mid-dev-session could be misclassified (treated as a project-root baseUrl candidate rather than a dependency). Edge case, non-blocking — clearing the projectRoot entry in the same handler would close it.

Verified correct (no action)

  • hasProjectTypeScript (index.ts:934) probing typescript/lib/typescript.js faithfully reproduces Next.js hasNecessaryDependencies' compiler-entry check, including the missing-custom-path → jsconfig.json-only fallback (selectTsconfig, index.ts:943-973).
  • Export-target escape guards (resolveTsconfigExportTarget, index.ts:414-462): replaceAll("*", …) (CodeQL alert 217 moot), dot-segment / .. / node_modules rejection after substitution, .json extension gate, POSIX backslash rejection.
  • Path → installed-package → baseUrl resolution order, wildcard precedence tiebreak, versioned types@ condition handling, and skipSelf: true recursion prevention all match TS/Next.js semantics.
  • vp check (changed files), and the three affected suites (tsconfig-paths-vite8, next-config, nextjs-compat/custom-tsconfig — 259 tests) all pass locally on 601e29d.

The implementation is strong and well-tested. Nothing blocking remains; only the minor Finding 3 cache-invalidation edge case is worth folding into the watcher handler if you want to close it.

@ask-bonk

ask-bonk Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2247.

Summary

I reviewed the custom typescript.tsconfigPath parity work against the current head (601e29d), one commit newer than the prior /bigbonk review. The new commit fix(config): allow mixed main tsconfig exports resolves the last outstanding finding. I verified everything against the actual typescript@5.9.3 and next@16.2.7 installs in node_modules.

Net: no blocking issues. All four prior findings are resolved or confirmed correct; only one minor non-blocking item carries over.

  • Mixed package exports divergence (prior review's headline) — RESOLVED. 601e29d scopes the mixed-keys rejection to subpath access (packageSubpath && …), so the main export (extends: "preset") now correctly falls back to exports["."], matching TypeScript's loadModuleFromExports. A regression test was added.
  • Finding 1 (""/null parity) — RESOLVED. Warns and falls back to default; throws only for non-string types. Matches Next.js's non-fatal Zod warning + || 'tsconfig.json' fallback.
  • Finding 2 (redundant double-parse) — RESOLVED. Custom configs parse once.
  • Finding 3 (dependencyRealRootsByProject not invalidated on watcher reload) — still present, minor/non-blocking.

vp check and all 259 tests across the three affected suites pass locally on 601e29d.

github run

@james-elicx james-elicx marked this pull request as ready for review June 21, 2026 23:45
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