Skip to content

Bind imports in the module-level scope, and resolve default imports - #131

Merged
nstepien merged 3 commits into
mainfrom
root-scope-imports
Nov 7, 2026
Merged

nstepien merged 3 commits into
mainfrom
root-scope-imports

Conversation

@nstepien

@nstepien nstepien commented Nov 6, 2026 •

Copy link
Copy Markdown
Owner

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

@nstepien
nstepien added this pull request to stack #129 November 6, 2026 22:17
@coderabbitai

coderabbitai Bot commented Nov 6, 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

@nstepien nstepien changed the title Bind imports in the module-level scope Bind imports in the module-level scope, and resolve default imports Nov 6, 2026
Base automatically changed from share-pending-parses to main November 7, 2026 01:28
nstepien and others added 2 commits November 7, 2026 01:28
Imports were kept apart from the scopes, in importedIdentifiers, and
the css tag in cssTagNames. resolveValue only looked them up when 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, resolveBinding resolves them next to `const`
bindings, and the tag check reads the binding of the tag.

Values exported as imported bindings, like `export { a }` after
`import { a } from './x'`, now resolve in the module they are imported
from, as `export const b = a` already did. `export { a } from './x'`
is still skipped, as it binds nothing in the module.

Imports are cached with the `const` values of the transform, unknown
while they resolve, so imports that modules export to each other stay
unknown instead of being followed forever. A Vite dev server test
covers it, as a bundle cannot link such modules. Without the cache, the
test worker runs out of memory.

The JS, CSS, and source maps of the benchmark builds are unchanged, and
their timings move by a few percent in both directions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.
A TypeScript interface exported by default binds nothing.

Namespace imports stay unknown, and the import-forms test now shows
that their members are not resolved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… it skips

Each expression type is now handled in one place. A negated const covers
the unary expressions that are not signed numbers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nstepien
nstepien marked this pull request as ready for review November 7, 2026 04:55
@nstepien
nstepien merged commit 767cc2b into main Nov 7, 2026
11 checks passed
@nstepien
nstepien deleted the root-scope-imports branch November 7, 2026 04:55
nstepien added a commit that referenced this pull request Nov 7, 2026
`resolveValue` and `resolveBinding` were async, so every interpolation
went through a promise, even a literal or a `const` declared in the
module. They now return the value itself, and only return a promise when
an import has to be loaded first. Cached bindings, literals, and local
`const` bindings resolve synchronously.

The binding cache still holds values, not promises: while a binding
resolves, it is cached as unknown, and its value replaces it once
resolved. A cached promise would make a cycle wait for itself.
`AGENTS.md` and the comment on `bindingValues` now say so.

**Benchmarks**

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

| Workload | #131 | This branch |
|---|---|---|
| Vite build of a synthetic project: 40 components × 8 templates × 5
tokens from one `tokens.ts` | 25.2 ms | 25.5 ms |
| The same with 400 components | 175.4 ms | 170.1 ms (−3%) |
| The same, with this branch built first in each pair | 184.8 ms | 179.2
ms (−3%) |
| Cold dev server transforms of the 40 components | 37.6 ms | 37.5 ms |
| Cold dev server transforms of the 400 components | 369.5 ms | 357.5 ms
(−3%) |
| Vite build of react-data-grid's `src` | 22.0 ms | 21.7 ms |

**Alternatives**

- Sharing the values between transforms, in `WeakMap`s keyed by AST
nodes, goes stale in the dev server: editing `tokens.ts` keeps the parse
of a module that exports one of its values again, so the old value stays
cached. A transform that reads a cached value would also skip importing
the stylesheet of its module and registering itself as a dependent for
HMR. And concurrent transforms would see the bindings that others are
resolving as cycles.

This also enables the `prefer-const` lint rule, which the code already
follows. `denyWarnings` makes it fail the lint like the other rules.

The tests pass unchanged, and the only uncovered line is the same as in
#131.

🤖 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