Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion cspell-wordlist.txt
Original file line number Diff line number Diff line change
Expand Up @@ -168,4 +168,5 @@ jsxmode
jsxdev
jsxs
labelable
lightningcss
lightningcss
cooldown
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@
"postcss-safe-parser": "^7.0.1",
"postcss-selector-parser": "^7.1.1",
"resolve": "^1.22.0",
"rolldown": "^1.0.0-rc.13",
"rolldown": "^1.0.0-rc.15",
"semver": "^7.7.4",
"terser": "5.37.0",
"typescript": "catalog:"
Expand Down
39 changes: 25 additions & 14 deletions packages/core/src/compiler/build/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,21 +44,32 @@ export const build = async (
tsTimeSpan.finish('transpile finished');
if (buildCtx.hasError) return buildAbort(buildCtx);

// generate types and validate AFTER components.d.ts is written
const { hasTypesChanged, needsRebuild } = await validateTypesAfterGeneration(
config,
compilerCtx,
buildCtx,
tsBuilder,
emittedDts,
);
if (buildCtx.hasError) return buildAbort(buildCtx);
// If TS emitted nothing, the "script change" was a phantom duplicate event — clear the flag
// so type validation and bundling are skipped.
if (buildCtx.isRebuild && buildCtx.hasScriptChanges && compilerCtx.changedModules.size === 0) {
buildCtx.hasScriptChanges = false;
}

// Skip type validation on rebuilds with no script changes — the type graph is unchanged.
const skipTypeValidation = buildCtx.isRebuild && !buildCtx.hasScriptChanges;

if (needsRebuild || (config.watch && hasTypesChanged)) {
// Abort and signal that a rebuild is needed:
// - needsRebuild: components.d.ts was just generated, need fresh TS program
// - watch mode with types changed: let watch trigger rebuild
return null;
if (!skipTypeValidation) {
const { needsRebuild } = await validateTypesAfterGeneration(
config,
compilerCtx,
buildCtx,
tsBuilder,
emittedDts,
);
if (buildCtx.hasError) return buildAbort(buildCtx);

if (needsRebuild) {
// components.d.ts was just created; the current TS program lacks it.
// Return null so watch-build restarts with a fresh program.
return null;
}
// types changed but no restart needed — components.d.ts is watch-ignored
// to prevent cascade rebuilds, so just continue with the current build.
}

// preprocess and generate styles before any outputTarget starts
Expand Down
16 changes: 16 additions & 0 deletions packages/core/src/compiler/build/compiler-ctx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,22 @@ export class CompilerContext implements d.CompilerCtx {
rolldownCacheLazy: any = null;
rolldownCacheNative: any = null;
cssTransformCache = new Map<string, d.CssTransformCacheEntry | null>();
/**
* Cross-build cache for {@link ts.transpileModule} results.
* Keyed by `"${bundleId}:${normalizedFilePath}"`. Unlike the per-instance
* cache inside typescriptPlugin (which only survives one rolldown build),
* this persists across watch rebuilds so only files that actually changed
* (i.e. appear in {@link changedModules}) need to be re-transpiled.
*/
transpileCache = new Map<string, { outputText: string; sourceMapText: string | null }>();
/**
* Cross-build cache of the last style text pushed to the HMR client for
* each component scope (keyed by getScopeId result: "tag$mode"). Used by
* extTransformsPlugin to skip pushing unchanged styles to buildCtx.stylesUpdated,
* preventing the browser from re-injecting all 90+ component styles on every
* rebuild even when only one component's TS file changed.
*/
prevStylesMap = new Map<string, string>();
cachedGlobalStyle: string;
styleModeNames = new Set<string>();
worker: d.CompilerWorkerContext = null;
Expand Down
97 changes: 45 additions & 52 deletions packages/core/src/compiler/build/watch-build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,37 +47,45 @@ export const createWatchBuild = async (
const filesUpdated = new Set<string>();
const filesDeleted = new Set<string>();

// Track TypeScript files that need cache invalidation
// TS files that need cache invalidation before the next rebuild
const tsFilesToInvalidate = new Set<string>();

// Debounce rebuild calls to handle duplicate events from multiple watchers
// Debounce timer — multiple watchers can fire for the same change
let rebuildTimeout: ReturnType<typeof setTimeout> | null = null;

/**
* Trigger a rebuild of the project.
* Invalidates changed TypeScript files in the compiler cache, then rebuilds.
*/
// Suppress FSEvents double-events (the same save can fire twice ~200-500ms apart),
// outside the 10ms debounce window. Drop events for files built within the cooldown period.
const recentlyBuiltFiles = new Set<string>();
let lastBuildFinishedAt = 0;
const DUPLICATE_EVENT_COOLDOWN_MS = 1500;

// Files the active build was triggered by; mid-build duplicates for these are dropped.
const currentlyBuildingFiles = new Set<string>();

/** Trigger a rebuild, invalidating changed TS files first. */
const triggerRebuild = async () => {
if (isBuilding) {
// If already building, schedule another rebuild after current one finishes
rebuildTimeout = setTimeout(triggerRebuild, 50);
return;
}

isBuilding = true;
try {
// Invalidate TypeScript cache for changed files
if (tsFilesToInvalidate.size > 0) {
incrementalCompiler.invalidateFiles(Array.from(tsFilesToInvalidate));
tsFilesToInvalidate.clear();
}

// Rebuild TypeScript program (incremental - only changed files re-emit)
const tsBuilder = incrementalCompiler.rebuild();
// Snapshot pending files so mid-build duplicates can be suppressed in onFsChange.
currentlyBuildingFiles.clear();
filesAdded.forEach((f) => currentlyBuildingFiles.add(f));
filesUpdated.forEach((f) => currentlyBuildingFiles.add(f));
filesDeleted.forEach((f) => currentlyBuildingFiles.add(f));

// Run the Stencil build
const tsBuilder = incrementalCompiler.rebuild();
await onBuild(tsBuilder);
} finally {
currentlyBuildingFiles.clear();
isBuilding = false;
}
};
Expand Down Expand Up @@ -121,9 +129,7 @@ export const createWatchBuild = async (
);
}

// Make sure all files in the module map are still in the fs
// Otherwise, we can run into build errors because the compiler can think
// there are two component files with the same tag name
// Remove stale module map entries to prevent duplicate-tag build errors
Array.from(compilerCtx.moduleMap.keys()).forEach((key) => {
if (filesUpdated.has(key) || filesDeleted.has(key)) {
// Check if the file exists in the fs
Expand All @@ -134,7 +140,7 @@ export const createWatchBuild = async (
}
});

// Make sure all added/updated files are watched
// Ensure newly added/updated files are watched
new Set([...filesUpdated, ...filesAdded]).forEach((filePath) => {
compilerCtx.addWatchFile(filePath);
});
Expand All @@ -148,63 +154,40 @@ export const createWatchBuild = async (
emitFsChange(compilerCtx, buildCtx);

buildCtx.start();

// Rebuild the project
const result = await build(config, compilerCtx, buildCtx, tsBuilder);

if (result && !result.hasError) {
isRebuild = true;
}

// Record consumed files so late-arriving OS duplicates are suppressed.
recentlyBuiltFiles.clear();
buildCtx.filesChanged.forEach((f) => recentlyBuiltFiles.add(f));
lastBuildFinishedAt = Date.now();
};

/**
* Utility method for formatting a debug message that must either list a number of files, or the word 'none' if the
* provided list is empty
*
* @param files a list of files, the list may be empty
* @returns the provided list if it is not empty. otherwise, return the word 'none'
* Returns files as a prefixed list, or 'none' if empty.
* No space before the filename — the logger wraps on whitespace.
* @param files the list of files to format for debug output
* @returns the formatted string for debug output
*/
const formatFilesForDebug = (files: ReadonlyArray<string>): string => {
/**
* In the created message, it's important that there's no whitespace prior to the file name.
* Stencil's logger will split messages by whitespace according to the width of the terminal window.
* Since file names can be fully qualified paths (and therefore quite long), putting whitespace between a '-' and
* the path can lead to formatted messages where the '-' is on its own line
*/
return files.length > 0 ? files.map((filename: string) => `-${filename}`).join('\n') : 'none';
};

/**
* Utility method to start/construct the watch program. This will mark
* all relevant files to be watched and then do the initial build.
*
* @returns A promise that resolves when the watcher is closed.
* Start watchers for all relevant directories and run the initial build.
* @returns a promise that resolves when the watch program is closed.
*/
const start = async () => {
/**
* Stencil watches the following directories for changes:
*/
await Promise.all([
/**
* the `srcDir` directory, e.g. component files
*/
watchFiles(compilerCtx, config.srcDir),
/**
* the root directory, e.g. `stencil.config.ts`
*/
watchFiles(compilerCtx, config.rootDir, {
recursive: false,
}),
/**
* the external directories, defined in `watchExternalDirs`, e.g. `node_modules`
*/
watchFiles(compilerCtx, config.rootDir, { recursive: false }),
...(config.watchExternalDirs || []).map((dir) => watchFiles(compilerCtx, dir)),
]);

// Create the incremental TypeScript compiler
incrementalCompiler = new IncrementalCompiler(config);

// Initial build
const tsBuilder = incrementalCompiler.rebuild();
await onBuild(tsBuilder);

Expand Down Expand Up @@ -232,6 +215,18 @@ export const createWatchBuild = async (
*/
const onFsChange: d.CompilerFileWatcherCallback = (filePath, eventKind) => {
if (incrementalCompiler && !isWatchIgnorePath(config, filePath)) {
// Drop duplicate OS events: same file within cooldown window, or mid-build duplicate.
const isDuplicateOfRecentBuild =
recentlyBuiltFiles.has(filePath) &&
Date.now() - lastBuildFinishedAt < DUPLICATE_EVENT_COOLDOWN_MS;
const isDuplicateMidBuild = isBuilding && currentlyBuildingFiles.has(filePath);
if (isDuplicateOfRecentBuild || isDuplicateMidBuild) {
config.logger.debug(
`WATCH_BUILD::fs_event_change suppressed duplicate - type=${eventKind}, path=${filePath}`,
);
return;
}

updateCompilerCtxCache(config, compilerCtx, filePath, eventKind);

switch (eventKind) {
Expand All @@ -252,7 +247,6 @@ export const createWatchBuild = async (
break;
}

// Track TypeScript files for cache invalidation
if (filePath.endsWith('.ts') || filePath.endsWith('.tsx')) {
tsFilesToInvalidate.add(filePath);
}
Expand All @@ -261,7 +255,6 @@ export const createWatchBuild = async (
`WATCH_BUILD::fs_event_change - type=${eventKind}, path=${filePath}, time=${new Date().getTime()}`,
);

// Debounce rebuild calls - multiple watchers may fire for the same change
if (rebuildTimeout) {
clearTimeout(rebuildTimeout);
}
Expand Down
36 changes: 21 additions & 15 deletions packages/core/src/compiler/bundle/ext-transforms-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,19 +243,13 @@ export const extTransformsPlugin = (
this.error('Plugin CSS transform error');
}

const hasUpdatedStyle = buildCtx.stylesUpdated.some((s) => {
return (
s.styleTag === data.tag &&
s.styleMode === data.mode &&
s.styleText === cacheEntry.cssTransformOutput.styleText
);
});

/**
* if the style has updated, compose all styles for the component
*/
if (!hasUpdatedStyle && data.tag && data.mode) {
const externalStyles = cmp?.styles?.[0]?.externalStyles;
if (data.tag && data.mode) {
// Find the style entry for the current mode (not always styles[0] which is the default mode).
const currentModeStyle = cmp?.styles?.find((s) => s.modeName === data.mode);
const externalStyles = currentModeStyle?.externalStyles;

/**
* if component has external styles, use a list to keep the order to which
Expand Down Expand Up @@ -285,11 +279,23 @@ export const extTransformsPlugin = (
*/
cacheEntry.cssTransformOutput.styleText;

buildCtx.stylesUpdated.push({
styleTag: data.tag,
styleMode: data.mode,
styleText,
});
// Only push to stylesUpdated if the CSS actually changed since the
// last build. Without this check, every rebuild re-pushes all 90+
// component stylesheets even when only a .tsx file changed, causing
// the HMR client to re-inject every style on every save.
const scopeId = getScopeId(data.tag, data.mode);
const prevText = compilerCtx.prevStylesMap.get(scopeId);
const alreadyQueued = buildCtx.stylesUpdated.some(
(s) => s.styleTag === data.tag && s.styleMode === data.mode,
);
if (!alreadyQueued && styleText !== prevText) {
compilerCtx.prevStylesMap.set(scopeId, styleText);
buildCtx.stylesUpdated.push({
styleTag: data.tag,
styleMode: data.mode,
styleText,
});
}
}

return {
Expand Down
23 changes: 6 additions & 17 deletions packages/core/src/compiler/bundle/typescript-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,8 @@ export const typescriptPlugin = (
bundleOpts: BundleOptions,
config: d.ValidatedConfig,
): Plugin => {
/**
* Cache the result of `ts.transpileModule` for a given file, keyed by the
* normalized file path. Rolldown re-runs the `transform` hook for every
* `.generate()` call on the same build object (once per output format:
* esm-browser, esm, cjs), so without this cache a 220-component project
* would call `ts.transpileModule` 660 times; with it, only 220.
*
* The cache is intentionally scoped to this plugin instance (one per
* `bundleOutput` call) so it is automatically discarded when the Rolldown
* build object is garbage-collected — no manual invalidation required.
*/
const transformCache = new Map<string, { outputText: string; sourceMapText: string | null }>();
// Cache key prefix per bundle type so different transformer pipelines don't share entries.
const cachePrefix = bundleOpts.id + ':';
let cacheHits = 0;
let cacheMisses = 0;

Expand Down Expand Up @@ -81,10 +71,9 @@ export const typescriptPlugin = (
const fsFilePath = normalizeFsPath(id);
const mod = getModule(compilerCtx, fsFilePath);
if (mod?.cmps) {
// Return cached transpile result if available. Rolldown calls this
// hook once per file per .generate() invocation, so subsequent
// format variants (esm, cjs, …) get the result for free.
const cached = transformCache.get(fsFilePath);
// Cross-build cache: survives rolldown teardown; evicted per changedModules in output-targets/index.ts.
const cacheKey = cachePrefix + fsFilePath;
const cached = compilerCtx.transpileCache.get(cacheKey);
if (cached) {
cacheHits++;
const sourceMap: d.SourceMap = cached.sourceMapText
Expand All @@ -101,7 +90,7 @@ export const typescriptPlugin = (
before: bundleOpts.customBeforeTransformers ?? [],
},
});
transformCache.set(fsFilePath, {
compilerCtx.transpileCache.set(cacheKey, {
outputText: tsResult.outputText,
sourceMapText: tsResult.sourceMapText ?? null,
});
Expand Down
13 changes: 7 additions & 6 deletions packages/core/src/compiler/config/outputs/validate-dist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,14 @@ export const validateDist = (
file: join(lazyDir, `${config.fsNamespace}.css`),
});

outputs.push({
type: DIST_TYPES,
dir: distOutputTarget.dir,
typesDir: distOutputTarget.typesDir,
});

if (config.buildDist) {
// dist-types is only useful when building a distributable; in dev mode
// (buildDist=false) it would trigger redundant generateAppTypes calls.
outputs.push({
type: DIST_TYPES,
dir: distOutputTarget.dir,
typesDir: distOutputTarget.typesDir,
});
if (distOutputTarget.collectionDir) {
outputs.push({
type: DIST_COLLECTION,
Expand Down
Loading
Loading