From 7f0390a28e083c7e05c6f95c4d7bd6be357d737b Mon Sep 17 00:00:00 2001 From: Nicolas Stepien Date: Wed, 4 Nov 2026 01:37:47 +0000 Subject: [PATCH 1/2] Resolve and load each imported module once per transform A transform resolved, loaded, and parsed an imported module again for every value it interpolated from it. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- src/index.ts | 80 ++++++++++++++++++++++++++++++++-------------------- 2 files changed, 50 insertions(+), 32 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5042bf9..3f892e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,7 +63,7 @@ The plugin implements three Rolldown lifecycle hooks: `parseFile` walks every program with a single module-level `Visitor` (`visitProgram`), whose callbacks keep the state of the visit in module-level variables, which is safe as a visit is synchronous. Do not create a `Visitor` per file: each instance keeps its callbacks alive for the rest of the process, along with everything they capture. -When the CSS interpolates values imported from another module, `transform` reads them from that module's parse, which `parseFile` caches. `const` initializers are resolved on demand, following the imports of their module, and cached for one transform only: the dev server keeps the parse of a module across edits to the modules it imports, so a longer-lived cache would go stale. It also waits for `this.load()` of that module, whose own transform registers its stylesheet, and imports that stylesheet first to keep the cascade order. Modules that interpolate each other's class names would wait for each other forever, so it skips loading a module whose transform is already waiting for the current one (`isWaitingFor`), and whose stylesheet is therefore not registered yet. Vite's dev server does not transform modules loaded through `this.load()`, so there a stylesheet is only imported once its module has been transformed. Do not work around this: the order only decides specificity ties, the README does not promise it, and Vite does not keep CSS in the same order between dev and builds either. +When the CSS interpolates values imported from another module, `transform` reads them from that module's parse, which `parseFile` caches. `const` initializers are resolved on demand, following the imports of their module, and cached for one transform only: the dev server keeps the parse of a module across edits to the modules it imports, so a longer-lived cache would go stale. It resolves, loads, and parses each imported module once per transform, however many values it interpolates from it. It also waits for `this.load()` of that module, whose own transform registers its stylesheet, and imports that stylesheet first to keep the cascade order. Modules that interpolate each other's class names would wait for each other forever, so it skips loading a module whose transform is already waiting for the current one (`isWaitingFor`), and whose stylesheet is therefore not registered yet. Vite's dev server does not transform modules loaded through `this.load()`, so there a stylesheet is only imported once its module has been transformed. Do not work around this: the order only decides specificity ties, the README does not promise it, and Vite does not keep CSS in the same order between dev and builds either. The caches are cleared in `buildEnd`, which the dev server only calls when it closes. There, `watchChange` forgets the parse of an edited module and the stylesheets that change with it, and the Vite-only `hotUpdate` hook adds the modules whose CSS depends on it to the update, as Vite would otherwise reuse their transforms and only update their imports. `transform` records these dependencies in `dependentsPerFile` as it resolves imported values. diff --git a/src/index.ts b/src/index.ts index 8466b00..d6536ba 100644 --- a/src/index.ts +++ b/src/index.ts @@ -575,6 +575,46 @@ export function ecij(configuration?: Configuration | null): Plugin { // is not shared with other transforms, as values read through imports can change in between. const constValues = new Map(); + // The modules that interpolated values are imported from, keyed by importer and import + // source, so that each one is resolved, loaded, and parsed once per transform + const importedModules = new Map>(); + + async function loadImportedModule(source: string, importer: string) { + // Resolve the import path relative to the importer + const resolvedId = await context.resolve(source, importer); + if (resolvedId == null) return; + + const { id } = resolvedId; + + // Record the dependency so that editing the imported module updates the transformed one + let dependents = dependentsPerFile.get(id); + + if (dependents === undefined) { + dependents = new Set(); + dependentsPerFile.set(id, dependents); + } + + dependents.add(filePath); + + // Wait for the imported module's transform, which registers the stylesheet that + // `resolveValue` imports. Vite's dev server does not transform modules loaded this way, + // so there the stylesheet is only imported once that module has been transformed. + // Loading a module whose transform waits for this one would never finish, which + // happens when modules interpolate each other's class names. Its stylesheet is then + // not registered yet, but values come from the module's parse either way. + if (!isWaitingFor(id, filePath)) { + pendingLoads.set(filePath, id); + + try { + await context.load(resolvedId); + } finally { + pendingLoads.delete(filePath); + } + } + + return { id, importedFileInfo: await parseFile(context, id) }; + } + // Statically evaluates an expression of a file, like an interpolation or the initializer of a // `const`, to its string value: a string or number literal, including signed numbers (`-5`, // `+5`), or an identifier bound to such a value or to a class name, in the file or imported. @@ -617,40 +657,18 @@ export function ecij(configuration?: Configuration | null): Plugin { if (importedIdentifier !== undefined) { const { source, imported } = importedIdentifier; + const key = `${fileInfo.filePath}\0${source}`; + let importedModule = importedModules.get(key); - // Resolve the import path relative to the importer - const resolvedId = await context.resolve(source, fileInfo.filePath); - - if (resolvedId != null) { - const { id } = resolvedId; - - // Record the dependency so that editing the imported module updates the transformed one - let dependents = dependentsPerFile.get(id); - - if (dependents === undefined) { - dependents = new Set(); - dependentsPerFile.set(id, dependents); - } + if (importedModule === undefined) { + importedModule = loadImportedModule(source, fileInfo.filePath); + importedModules.set(key, importedModule); + } - dependents.add(filePath); - - // Wait for the imported module's transform, which registers the stylesheet imported - // below. Vite's dev server does not transform modules loaded this way, so there the - // stylesheet is only imported once that module has been transformed. - // Loading a module whose transform waits for this one would never finish, which - // happens when modules interpolate each other's class names. Its stylesheet is then - // not registered yet, but values come from the module's parse either way. - if (!isWaitingFor(id, filePath)) { - pendingLoads.set(filePath, id); - - try { - await context.load(resolvedId); - } finally { - pendingLoads.delete(filePath); - } - } + const loadedModule = await importedModule; - const importedFileInfo = await parseFile(context, id); + if (loadedModule !== undefined) { + const { id, importedFileInfo } = loadedModule; const localName = importedFileInfo.exportedLocalNames.get(imported); // Only bindings declared in the module are resolved, not the imports it exports From 205ff1c3541669a638e3a47dbeab24c696aa46f6 Mon Sep 17 00:00:00 2001 From: Nicolas Stepien Date: Wed, 4 Nov 2026 01:55:43 +0000 Subject: [PATCH 2/2] Move the imported module cache into loadImportedModule loadImportedModule now returns the cached promise, and resolveImportedModule does the resolving, loading, and parsing. Co-Authored-By: Claude Opus 5.5 --- src/index.ts | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/src/index.ts b/src/index.ts index d6536ba..e77fa27 100644 --- a/src/index.ts +++ b/src/index.ts @@ -577,9 +577,21 @@ export function ecij(configuration?: Configuration | null): Plugin { // The modules that interpolated values are imported from, keyed by importer and import // source, so that each one is resolved, loaded, and parsed once per transform - const importedModules = new Map>(); + const importedModules = new Map>(); - async function loadImportedModule(source: string, importer: string) { + function loadImportedModule(source: string, importer: string) { + const key = `${importer}\0${source}`; + let importedModule = importedModules.get(key); + + if (importedModule === undefined) { + importedModule = resolveImportedModule(source, importer); + importedModules.set(key, importedModule); + } + + return importedModule; + } + + async function resolveImportedModule(source: string, importer: string) { // Resolve the import path relative to the importer const resolvedId = await context.resolve(source, importer); if (resolvedId == null) return; @@ -657,15 +669,7 @@ export function ecij(configuration?: Configuration | null): Plugin { if (importedIdentifier !== undefined) { const { source, imported } = importedIdentifier; - const key = `${fileInfo.filePath}\0${source}`; - let importedModule = importedModules.get(key); - - if (importedModule === undefined) { - importedModule = loadImportedModule(source, fileInfo.filePath); - importedModules.set(key, importedModule); - } - - const loadedModule = await importedModule; + const loadedModule = await loadImportedModule(source, fileInfo.filePath); if (loadedModule !== undefined) { const { id, importedFileInfo } = loadedModule;