Skip to content

Share pending parses of imported modules between transforms - #128

Merged
nstepien merged 2 commits into
mainfrom
share-pending-parses
Nov 7, 2026
Merged

nstepien merged 2 commits into
mainfrom
share-pending-parses

Conversation

@nstepien

@nstepien nstepien commented Nov 4, 2026 •

Copy link
Copy Markdown
Owner

The parse cache only stored finished parses. The transforms of the modules that import the same module run concurrently: in a build, they all wait for this.load() of that module and resume together, so each one missed the cache and read and parsed the file again. In a project of 400 components that interpolate values of one tokens.ts, it was parsed 400 times per build, and in a cold dev server too.

The cache now holds the pending parse of each module read from disk, stored as soon as it is requested, so the file is read and parsed once. The parse of a module being transformed is stored as is. parseFile only parses now, and loadParsedFile returns the cached or pending parse of a module read from disk, like loadImportedModule does for the imported modules of a transform (#124). Caching the promise is safe for the same reason: a parse never waits for a value to resolve, so it can't wait for itself.

Benchmarks

Medians with Vite 8.3.5, #127 against this branch. The JS, CSS, and final source maps are identical.

Workload #127 This branch
Vite build of a synthetic project: 40 components × 8 templates × 5 tokens from one tokens.ts 28.5 ms 23.1 ms (−19%)
The same with 400 components 224.0 ms 171.6 ms (−23%)
Cold dev server transforms of the 40 components 35.9 ms 31.8 ms (−11%)
Cold dev server transforms of the 400 components 386.1 ms 339.9 ms (−12%)
Vite build of react-data-grid's src, which reads no module from disk 20.7 ms 21.0 ms

tokens.ts is now read and parsed once per build and per cold dev server, instead of 400 times.

Other effects

  • A read that fails is cached like a parse, until buildEnd or watchChange clears it. It fails the transform that interpolates the module's values either way.
  • In the dev server, a read that finishes after watchChange forgot the module can no longer store its stale parse, as the pending parse is cached before the read starts.

Alternative

parse from rolldown/utils, which parses on another thread, was also measured. On its own, it gave up to 11% on these builds, the most where the duplicate parses overlapped, and 0–4% on cold dev transforms. On top of this branch, it gave at most 4%, while Rolldown advises preferring parseSync, so it's left out.

The tests pass unchanged, with the same uncovered lines and branches as #127. AGENTS.md now says why the parse is cached while pending.

🤖 Generated with Claude Code

@nstepien
nstepien added this pull request to stack #129 November 4, 2026 05:11
@coderabbitai

coderabbitai Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

Important

  • Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

Run configuration

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

The parse cache only stored finished parses. The transforms of the
modules that import the same module run concurrently: in a build, they
all wait for this.load() of that module and resume together, so each
missed the cache and read and parsed the file again. A project of 400
components interpolating values of one tokens.ts parsed it 400 times
per build, and in a cold dev server.

The cache now holds the promise of each parse, stored as soon as it is
requested, so the file is read and parsed once. parseFile only parses,
and loadParsedFile returns the cached or pending parse of a module read
from disk, like loadImportedModule does for the imported modules of a
transform.

Medians with Vite 8.3.5, against the previous commit, with identical
JS, CSS, and final source maps:
- Vite build, 40 components x 8 templates x 5 tokens: 28.5 ms to
  23.1 ms (-19%)
- The same with 400 components: 224.0 ms to 171.6 ms (-23%)
- Cold dev server transforms of the 40 components: 35.9 ms to 31.8 ms
  (-11%), of the 400 components: 386.1 ms to 339.9 ms (-12%)
- Vite build of react-data-grid's src, which reads no module from disk:
  20.7 ms to 21.0 ms

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nstepien
nstepien force-pushed the share-pending-parses branch from 736580d to ed1a7d1 Compare November 6, 2026 22:47
The parse of an imported module is awaited either way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nstepien
nstepien marked this pull request as ready for review November 7, 2026 01:26
@nstepien
nstepien merged commit 080f5d1 into main Nov 7, 2026
11 checks passed
@nstepien
nstepien deleted the share-pending-parses branch November 7, 2026 01:28
nstepien added a commit that referenced this pull request Nov 7, 2026
…131)

**Imports in the module-level scope**

Imports were kept apart from the scopes: their sources in
`importedIdentifiers`, and the local names of ecij's `css` in
`cssTagNames`. `resolveValue` only looked an import up once no scope
bound the name, and the tag check combined both lookups. Imports are now
bindings of the module-level scope, like its declarations:
- The scope chain finds them, so `findBindingScope` is the only lookup.
- `resolveBinding` resolves them next to `const` bindings, through a new
`resolveImport`, which holds what was the import branch of
`resolveValue`.
- The tag check reads the binding of the tag: ecij's `css` is `{ type:
'ImportBinding', source: 'ecij', imported: 'css' }`, and a local binding
of the same name shadows it, as before.
- `ParsedFileInfo.importedIdentifiers` and `cssTagNames` are gone.

Namespace imports are recorded as bindings with unknown values, which
they were in effect before.

**Default imports**

A default import is now an import of the binding exported as `default`,
like `import { default as x }`. `export default` binds its value to
`*default*`, the local name the spec gives it, which no identifier can
be. So the exported value resolves like a `const` initializer: a
literal, a binding, or a css`` template, whose class name it takes, with
its stylesheet imported first. Functions and classes exported by default
don't resolve, like any expression `resolveValue` doesn't support, and a
TypeScript interface binds nothing. The README no longer lists default
imports and exports as missing.

**Imports exported again**

Values that a module exports as imported bindings now resolve in the
module they are imported from. On #128, `import { a } from './x'; export
{ a }` didn't resolve, while `export const b = a` did. `export { a }
from './x'` is still skipped, as it binds nothing in the module (the
`TODO: handle re-exports`).

**Cycles**

Imports are cached with the `const` values of the transform
(`constValues` is now `bindingValues`), unknown while they resolve. So
imports that modules export to each other stay unknown instead of being
followed forever. A new test covers it in the Vite dev server, which
transforms modules one by one, as a bundle can't link such modules.
Without the cache, the test worker runs out of memory within about 10
seconds.

**Unsupported expressions**

`resolveValue` now returns as soon as a literal or a unary expression
turns out to be unsupported, so each expression type is handled in one
place. A negated `const` (`${-gap}`), which stays unresolved, covers the
unary case in the literal interpolation test.

**Tests**

The import-forms test, now named "resolve the supported import and
export forms, and skip the others", covers:
- default imports, of a value and of a class name
- an interface exported by default
- a value and a class name exported again
- members of a namespace import, which stay unresolved

The uncovered lines are unchanged from #128, with one fewer uncovered
branch.

The two guards of `resolveImport` are one-liners, like the one in
`resolveImportedModule`, as an import that can't be resolved stays
untested. `AGENTS.md` now says that imports are module-level bindings,
where default exports are bound, and that cycles through imports stay
unknown.

**Benchmarks**

The JS, CSS, and final source maps of the benchmark builds are
unchanged, react-data-grid's included, and their timings moved by a few
percent in both directions against #128.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
nstepien added a commit that referenced this pull request Nov 7, 2026
Main reworked the import handling underneath this branch: imports read
from the top-level statements (#127), parses cached while pending (#128),
imports as bindings of the module-level scope with default imports
through `*default*` (#131), values resolved without promises (#133), and
TypeScript namespaces given a scope of their own (#130). src/index.ts was
taken from main and this branch's features ported onto it:

- Namespace imports are `NamespaceImport` bindings, and member chains
  (`ns.a.b`) resolve through the exports of each namespace.
- Exports are `moduleExports` (local, re-export, namespace) plus
  `exportStarSources`, read from the top-level statements, and
  `resolveExport` follows them with ESM precedence and ambiguity.
  Exporting an imported binding re-exports it, as in the spec. Exported
  functions, classes, enums, and destructured bindings are recorded, so
  that they take precedence over `export *`.
- Type-only imports and exports are skipped through `importKind` and
  `exportKind`, as the module record is no longer read.
- Main's `isCssTag`, `bindingValues`, and `MaybePromise` resolution are
  kept. An import resolves through `resolveExport` and imports the
  stylesheet of the module that declares the value.

Main's decisions win where they differ from this branch:

- TypeScript namespaces are not supported: the namespace-name and
  `import x =` bindings are dropped, along with the typed fixture's
  namespace cases, as main's namespace test covers containment. Enums
  still shadow.
- Templates exported by default get no `default` name, so their class
  names hash like main's.

Main's import-forms test now extracts the namespace member and the
re-export it skipped. Two fixture cases cover the export of a namespace
import and a member of a local object.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant