Skip to content

[*] Bug Fix: Surface a clear error when TypeScript (<5.2) can't read the package exports#8628

Merged
etrepum merged 1 commit into
facebook:mainfrom
etrepum:claude/beautiful-newton-UY7ic
Jun 4, 2026
Merged

[*] Bug Fix: Surface a clear error when TypeScript (<5.2) can't read the package exports#8628
etrepum merged 1 commit into
facebook:mainfrom
etrepum:claude/beautiful-newton-UY7ic

Conversation

@etrepum

@etrepum etrepum commented Jun 3, 2026

Copy link
Copy Markdown
Collaborator

Description

Lexical publishes its TypeScript declarations exclusively through the package.json "exports" map. A consumer using classic moduleResolution (or TypeScript older than 4.7) ignores "exports" entirely, so every subpath import — e.g. @lexical/react/ReactExtension — resolves to nothing and reports:

TS2307: Cannot find module '@lexical/react/ReactExtension' or its corresponding type declarations.

That phrasing looks like a missing install, so it sends people down the wrong path (reinstalling, checking dependencies) instead of fixing the real cause: their TypeScript is too old or misconfigured.

This points the legacy type-resolution fields at a generated "too old" stub so those consumers get an actionable upgrade message instead. It's safe because, since TypeScript 4.9, "exports" takes priority over types/typesVersions — so modern resolvers never see the stub, and only the consumers we want to fail resolve it.

Generated by scripts/updateVersion.mjs for every public package:

  • types + typesVersions ("*": { "*": [stub] }) redirect the package root and every subpath for any TypeScript that consults them (i.e. any TypeScript not resolving types through "exports", at any version).
  • types@<5.2, ordered immediately before types in each exports entry, redirects consumers that do read "exports" but are below the minimum supported version (TypeScript 4.9+ understands the types@ selector).
  • An optional typescript peer dependency (">=5.2", peerDependenciesMeta.typescript.optional) so npm/pnpm emit an install-time warning for an out-of-range TypeScript, complementing the type-check-time guard.

The stub (dist/typescript-too-old.d.ts) is emitted by scripts/build.mjs; it carries an explanatory comment plus a deliberate diagnostic.

Why 5.2? It's the empirically measured lower bound across consumer configurations:

Consumer config Minimum TypeScript
classic moduleResolution: node never (cannot read "exports")
node16/nodenext + skipLibCheck: true 4.7
moduleResolution: bundler 5.0
any resolution + skipLibCheck: false 5.2

The skipLibCheck: false floor is 5.2 because the public LexicalEditorWithDispose interface extends the global Disposable type, which only exists in the TypeScript 5.2 lib. 5.2 is therefore the lowest version that works in every supported configuration.

This also fixes a latent idempotency bug surfaced by the change: the single-entry exports branch derived the declaration basename from packageJson.types, which the stub now overwrites — so a second update-version run pointed the real types condition at the stub. It now derives the basename from the entry source instead, and the generator is idempotent from a clean checkout.

Test plan

Validated on TypeScript 4.4 – 6.0 against the real built packages, plus new per-package audits in scripts/__tests__/unit/build.test.ts.

Before

A classic-resolution consumer (TypeScript 4.4, or any version with moduleResolution: node) importing a subpath:

TS2307: Cannot find module '@lexical/react/ReactExtension' or its corresponding type declarations.

After

  • Classic resolution / TypeScript < 5.2: the import resolves to dist/typescript-too-old.d.ts (no more "Cannot find module"); the file documents the fix, and skipLibCheck: false consumers also see Lexical requires TypeScript >=5.2 with moduleResolution bundler, node16, or nodenext.
  • Modern resolution at/above the floor: real types resolve unchanged.
TS 4.9  (node16)   => guard fires (redirected to stub)
TS 5.1  (bundler)  => guard fires (redirected to stub)
TS 5.2  (bundler)  => OK: real types resolve
TS 5.4  (bundler)  => OK: real types resolve
TS 6.0  (node16/nodenext/bundler) => OK: real types resolve, tombstone ignored
  • pnpm exec vitest --project scripts-unit — 860 passing (includes new guard + peer-dep audits across all public packages)
  • pnpm run tsc-scripts — clean
  • node ./scripts/updateVersion.mjs is idempotent from a clean checkout (identical output on repeated runs)
@vercel

vercel Bot commented Jun 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
lexical Ready Ready Preview, Comment Jun 4, 2026 4:57am
lexical-playground Ready Ready Preview, Comment Jun 4, 2026 4:57am

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Jun 3, 2026
@etrepum etrepum marked this pull request as draft June 3, 2026 20:38
@etrepum etrepum force-pushed the claude/beautiful-newton-UY7ic branch from e714096 to 4bb8491 Compare June 3, 2026 20:44
@etrepum etrepum changed the title [*] Bug Fix: Surface a clear error when TypeScript can't read the package exports Jun 4, 2026
…kage exports

Lexical publishes its declaration files exclusively through the package.json
"exports" map. A consumer using classic moduleResolution (or TypeScript < 4.7)
ignores "exports", so every subpath import such as
`@lexical/react/ReactExtension` resolved to nothing and reported a misleading
`TS2307: Cannot find module ... or its corresponding type declarations`, which
looks like a missing install.

Point the legacy type-resolution fields at a generated "too old" stub so those
consumers get an actionable upgrade message instead:

- `types` and `typesVersions` (`"*": {"*": [stub]}`) redirect the package root
  and every subpath for any TypeScript that consults them (i.e. that is not
  resolving types through "exports").
- A `types@<5.2` condition, ordered before `types` in every exports entry,
  redirects consumers that do read "exports" but are below the minimum
  supported TypeScript version (4.9+ understands it).

Modern resolvers (node16/nodenext/bundler on TypeScript >= 4.9) ignore the
legacy fields because "exports" takes priority, so they continue to resolve
the real types unchanged. The stub is emitted into each package's dist during
the build; update-version maintains the package.json fields.

The 5.2 floor is the empirically measured lower bound: node16/nodenext works
at 4.7 with skipLibCheck, `moduleResolution: bundler` needs 5.0, and
`skipLibCheck: false` needs 5.2 because the public `LexicalEditorWithDispose`
interface extends the global `Disposable` type, which only exists in the
TypeScript 5.2 lib.

Also generate an optional `typescript` peer dependency (`>=5.2`, marked
optional) on every public package so npm/pnpm emit an install-time warning
for an out-of-range TypeScript, complementing the type-check-time guard. The
lockfile is updated to match.

The single-entry exports branch now derives the declaration basename from the
entry source rather than the `types` field (which the stub overwrites), keeping
update-version idempotent.

https://claude.ai/code/session_017CtQcG8G9i6vVTSaMTKj93
@potatowagon

Copy link
Copy Markdown
Contributor

Review: TypeScript Version Gate

Verdict: Safe to approve ✅

What I verified:

  1. Design correctness: Three-layer defense (legacy types field → typesVersionstypes@<5.2 export condition) ensures consumers on old TypeScript or classic moduleResolution get a clear actionable error message rather than the misleading TS2307: Cannot find module. The ordering logic (versioned condition immediately before plain types) is correct.

  2. Stub content: The typescript-too-old.d.ts stub uses an unresolvable import string as the error mechanism — clever and effective. The comment explains exactly what to do (upgrade TS ≥ 5.2, set moduleResolution to bundler/node16/nodenext).

  3. Build integration: build.mjs emits the stub via writeTypeScriptTooOldStub(); updateVersion.mjs maintains the package.json fields. Both are clean.

  4. Test coverage: The integrity test suite validates condition ordering, types field pointing at stub, typesVersions shape, and peer dependency declaration for every public package. Solid.

  5. No runtime impact: This only affects TypeScript type resolution for consumers — zero runtime behavior change.

  6. CI status: 42+ checks pass. Only browser-tests (windows-2022, firefox) fails — this is a known flaky runner unrelated to this change (no code changes affect browser behavior).

  7. Consistency: Pattern applied uniformly across all 31 public packages via the build scripts.

The MIN_TYPESCRIPT_VERSION = 5.2 rationale is well-documented (Disposable interface in lib requires ≥ 5.2 when skipLibCheck: false). The optional peer dep is the right call — doesn't break non-TS consumers.

Review by Navi (potatowagon's AI assistant)

@etrepum etrepum added this pull request to the merge queue Jun 4, 2026
Merged via the queue into facebook:main with commit ac3814e Jun 4, 2026
82 of 83 checks passed
@etrepum etrepum deleted the claude/beautiful-newton-UY7ic branch June 4, 2026 15:19
@etrepum etrepum mentioned this pull request Jun 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. extended-tests Run extended e2e tests on a PR

3 participants