Skip to content

Commit e3cf802

Browse files
authored
chore: v5 watch / build improvements (#6681)
* chore: v5 watch / build improvements * chore:
1 parent fadc83f commit e3cf802

18 files changed

Lines changed: 450 additions & 360 deletions

File tree

cspell-wordlist.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,4 +168,5 @@ jsxmode
168168
jsxdev
169169
jsxs
170170
labelable
171-
lightningcss
171+
lightningcss
172+
cooldown

packages/core/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@
113113
"postcss-safe-parser": "^7.0.1",
114114
"postcss-selector-parser": "^7.1.1",
115115
"resolve": "^1.22.0",
116-
"rolldown": "^1.0.0-rc.13",
116+
"rolldown": "^1.0.0-rc.15",
117117
"semver": "^7.7.4",
118118
"terser": "5.37.0",
119119
"typescript": "catalog:"

packages/core/src/compiler/build/build.ts

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -44,21 +44,32 @@ export const build = async (
4444
tsTimeSpan.finish('transpile finished');
4545
if (buildCtx.hasError) return buildAbort(buildCtx);
4646

47-
// generate types and validate AFTER components.d.ts is written
48-
const { hasTypesChanged, needsRebuild } = await validateTypesAfterGeneration(
49-
config,
50-
compilerCtx,
51-
buildCtx,
52-
tsBuilder,
53-
emittedDts,
54-
);
55-
if (buildCtx.hasError) return buildAbort(buildCtx);
47+
// If TS emitted nothing, the "script change" was a phantom duplicate event — clear the flag
48+
// so type validation and bundling are skipped.
49+
if (buildCtx.isRebuild && buildCtx.hasScriptChanges && compilerCtx.changedModules.size === 0) {
50+
buildCtx.hasScriptChanges = false;
51+
}
52+
53+
// Skip type validation on rebuilds with no script changes — the type graph is unchanged.
54+
const skipTypeValidation = buildCtx.isRebuild && !buildCtx.hasScriptChanges;
5655

57-
if (needsRebuild || (config.watch && hasTypesChanged)) {
58-
// Abort and signal that a rebuild is needed:
59-
// - needsRebuild: components.d.ts was just generated, need fresh TS program
60-
// - watch mode with types changed: let watch trigger rebuild
61-
return null;
56+
if (!skipTypeValidation) {
57+
const { needsRebuild } = await validateTypesAfterGeneration(
58+
config,
59+
compilerCtx,
60+
buildCtx,
61+
tsBuilder,
62+
emittedDts,
63+
);
64+
if (buildCtx.hasError) return buildAbort(buildCtx);
65+
66+
if (needsRebuild) {
67+
// components.d.ts was just created; the current TS program lacks it.
68+
// Return null so watch-build restarts with a fresh program.
69+
return null;
70+
}
71+
// types changed but no restart needed — components.d.ts is watch-ignored
72+
// to prevent cascade rebuilds, so just continue with the current build.
6273
}
6374

6475
// preprocess and generate styles before any outputTarget starts

packages/core/src/compiler/build/compiler-ctx.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,22 @@ export class CompilerContext implements d.CompilerCtx {
4040
rolldownCacheLazy: any = null;
4141
rolldownCacheNative: any = null;
4242
cssTransformCache = new Map<string, d.CssTransformCacheEntry | null>();
43+
/**
44+
* Cross-build cache for {@link ts.transpileModule} results.
45+
* Keyed by `"${bundleId}:${normalizedFilePath}"`. Unlike the per-instance
46+
* cache inside typescriptPlugin (which only survives one rolldown build),
47+
* this persists across watch rebuilds so only files that actually changed
48+
* (i.e. appear in {@link changedModules}) need to be re-transpiled.
49+
*/
50+
transpileCache = new Map<string, { outputText: string; sourceMapText: string | null }>();
51+
/**
52+
* Cross-build cache of the last style text pushed to the HMR client for
53+
* each component scope (keyed by getScopeId result: "tag$mode"). Used by
54+
* extTransformsPlugin to skip pushing unchanged styles to buildCtx.stylesUpdated,
55+
* preventing the browser from re-injecting all 90+ component styles on every
56+
* rebuild even when only one component's TS file changed.
57+
*/
58+
prevStylesMap = new Map<string, string>();
4359
cachedGlobalStyle: string;
4460
styleModeNames = new Set<string>();
4561
worker: d.CompilerWorkerContext = null;

packages/core/src/compiler/build/watch-build.ts

Lines changed: 45 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -47,37 +47,45 @@ export const createWatchBuild = async (
4747
const filesUpdated = new Set<string>();
4848
const filesDeleted = new Set<string>();
4949

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

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

56-
/**
57-
* Trigger a rebuild of the project.
58-
* Invalidates changed TypeScript files in the compiler cache, then rebuilds.
59-
*/
56+
// Suppress FSEvents double-events (the same save can fire twice ~200-500ms apart),
57+
// outside the 10ms debounce window. Drop events for files built within the cooldown period.
58+
const recentlyBuiltFiles = new Set<string>();
59+
let lastBuildFinishedAt = 0;
60+
const DUPLICATE_EVENT_COOLDOWN_MS = 1500;
61+
62+
// Files the active build was triggered by; mid-build duplicates for these are dropped.
63+
const currentlyBuildingFiles = new Set<string>();
64+
65+
/** Trigger a rebuild, invalidating changed TS files first. */
6066
const triggerRebuild = async () => {
6167
if (isBuilding) {
62-
// If already building, schedule another rebuild after current one finishes
6368
rebuildTimeout = setTimeout(triggerRebuild, 50);
6469
return;
6570
}
6671

6772
isBuilding = true;
6873
try {
69-
// Invalidate TypeScript cache for changed files
7074
if (tsFilesToInvalidate.size > 0) {
7175
incrementalCompiler.invalidateFiles(Array.from(tsFilesToInvalidate));
7276
tsFilesToInvalidate.clear();
7377
}
7478

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

78-
// Run the Stencil build
85+
const tsBuilder = incrementalCompiler.rebuild();
7986
await onBuild(tsBuilder);
8087
} finally {
88+
currentlyBuildingFiles.clear();
8189
isBuilding = false;
8290
}
8391
};
@@ -121,9 +129,7 @@ export const createWatchBuild = async (
121129
);
122130
}
123131

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

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

150156
buildCtx.start();
151-
152-
// Rebuild the project
153157
const result = await build(config, compilerCtx, buildCtx, tsBuilder);
154158

155159
if (result && !result.hasError) {
156160
isRebuild = true;
157161
}
162+
163+
// Record consumed files so late-arriving OS duplicates are suppressed.
164+
recentlyBuiltFiles.clear();
165+
buildCtx.filesChanged.forEach((f) => recentlyBuiltFiles.add(f));
166+
lastBuildFinishedAt = Date.now();
158167
};
159168

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

177179
/**
178-
* Utility method to start/construct the watch program. This will mark
179-
* all relevant files to be watched and then do the initial build.
180-
*
181-
* @returns A promise that resolves when the watcher is closed.
180+
* Start watchers for all relevant directories and run the initial build.
181+
* @returns a promise that resolves when the watch program is closed.
182182
*/
183183
const start = async () => {
184-
/**
185-
* Stencil watches the following directories for changes:
186-
*/
187184
await Promise.all([
188-
/**
189-
* the `srcDir` directory, e.g. component files
190-
*/
191185
watchFiles(compilerCtx, config.srcDir),
192-
/**
193-
* the root directory, e.g. `stencil.config.ts`
194-
*/
195-
watchFiles(compilerCtx, config.rootDir, {
196-
recursive: false,
197-
}),
198-
/**
199-
* the external directories, defined in `watchExternalDirs`, e.g. `node_modules`
200-
*/
186+
watchFiles(compilerCtx, config.rootDir, { recursive: false }),
201187
...(config.watchExternalDirs || []).map((dir) => watchFiles(compilerCtx, dir)),
202188
]);
203189

204-
// Create the incremental TypeScript compiler
205190
incrementalCompiler = new IncrementalCompiler(config);
206-
207-
// Initial build
208191
const tsBuilder = incrementalCompiler.rebuild();
209192
await onBuild(tsBuilder);
210193

@@ -232,6 +215,18 @@ export const createWatchBuild = async (
232215
*/
233216
const onFsChange: d.CompilerFileWatcherCallback = (filePath, eventKind) => {
234217
if (incrementalCompiler && !isWatchIgnorePath(config, filePath)) {
218+
// Drop duplicate OS events: same file within cooldown window, or mid-build duplicate.
219+
const isDuplicateOfRecentBuild =
220+
recentlyBuiltFiles.has(filePath) &&
221+
Date.now() - lastBuildFinishedAt < DUPLICATE_EVENT_COOLDOWN_MS;
222+
const isDuplicateMidBuild = isBuilding && currentlyBuildingFiles.has(filePath);
223+
if (isDuplicateOfRecentBuild || isDuplicateMidBuild) {
224+
config.logger.debug(
225+
`WATCH_BUILD::fs_event_change suppressed duplicate - type=${eventKind}, path=${filePath}`,
226+
);
227+
return;
228+
}
229+
235230
updateCompilerCtxCache(config, compilerCtx, filePath, eventKind);
236231

237232
switch (eventKind) {
@@ -252,7 +247,6 @@ export const createWatchBuild = async (
252247
break;
253248
}
254249

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

264-
// Debounce rebuild calls - multiple watchers may fire for the same change
265258
if (rebuildTimeout) {
266259
clearTimeout(rebuildTimeout);
267260
}

packages/core/src/compiler/bundle/ext-transforms-plugin.ts

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -243,19 +243,13 @@ export const extTransformsPlugin = (
243243
this.error('Plugin CSS transform error');
244244
}
245245

246-
const hasUpdatedStyle = buildCtx.stylesUpdated.some((s) => {
247-
return (
248-
s.styleTag === data.tag &&
249-
s.styleMode === data.mode &&
250-
s.styleText === cacheEntry.cssTransformOutput.styleText
251-
);
252-
});
253-
254246
/**
255247
* if the style has updated, compose all styles for the component
256248
*/
257-
if (!hasUpdatedStyle && data.tag && data.mode) {
258-
const externalStyles = cmp?.styles?.[0]?.externalStyles;
249+
if (data.tag && data.mode) {
250+
// Find the style entry for the current mode (not always styles[0] which is the default mode).
251+
const currentModeStyle = cmp?.styles?.find((s) => s.modeName === data.mode);
252+
const externalStyles = currentModeStyle?.externalStyles;
259253

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

288-
buildCtx.stylesUpdated.push({
289-
styleTag: data.tag,
290-
styleMode: data.mode,
291-
styleText,
292-
});
282+
// Only push to stylesUpdated if the CSS actually changed since the
283+
// last build. Without this check, every rebuild re-pushes all 90+
284+
// component stylesheets even when only a .tsx file changed, causing
285+
// the HMR client to re-inject every style on every save.
286+
const scopeId = getScopeId(data.tag, data.mode);
287+
const prevText = compilerCtx.prevStylesMap.get(scopeId);
288+
const alreadyQueued = buildCtx.stylesUpdated.some(
289+
(s) => s.styleTag === data.tag && s.styleMode === data.mode,
290+
);
291+
if (!alreadyQueued && styleText !== prevText) {
292+
compilerCtx.prevStylesMap.set(scopeId, styleText);
293+
buildCtx.stylesUpdated.push({
294+
styleTag: data.tag,
295+
styleMode: data.mode,
296+
styleText,
297+
});
298+
}
293299
}
294300

295301
return {

packages/core/src/compiler/bundle/typescript-plugin.ts

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

@@ -81,10 +71,9 @@ export const typescriptPlugin = (
8171
const fsFilePath = normalizeFsPath(id);
8272
const mod = getModule(compilerCtx, fsFilePath);
8373
if (mod?.cmps) {
84-
// Return cached transpile result if available. Rolldown calls this
85-
// hook once per file per .generate() invocation, so subsequent
86-
// format variants (esm, cjs, …) get the result for free.
87-
const cached = transformCache.get(fsFilePath);
74+
// Cross-build cache: survives rolldown teardown; evicted per changedModules in output-targets/index.ts.
75+
const cacheKey = cachePrefix + fsFilePath;
76+
const cached = compilerCtx.transpileCache.get(cacheKey);
8877
if (cached) {
8978
cacheHits++;
9079
const sourceMap: d.SourceMap = cached.sourceMapText
@@ -101,7 +90,7 @@ export const typescriptPlugin = (
10190
before: bundleOpts.customBeforeTransformers ?? [],
10291
},
10392
});
104-
transformCache.set(fsFilePath, {
93+
compilerCtx.transpileCache.set(cacheKey, {
10594
outputText: tsResult.outputText,
10695
sourceMapText: tsResult.sourceMapText ?? null,
10796
});

packages/core/src/compiler/config/outputs/validate-dist.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -60,13 +60,14 @@ export const validateDist = (
6060
file: join(lazyDir, `${config.fsNamespace}.css`),
6161
});
6262

63-
outputs.push({
64-
type: DIST_TYPES,
65-
dir: distOutputTarget.dir,
66-
typesDir: distOutputTarget.typesDir,
67-
});
68-
6963
if (config.buildDist) {
64+
// dist-types is only useful when building a distributable; in dev mode
65+
// (buildDist=false) it would trigger redundant generateAppTypes calls.
66+
outputs.push({
67+
type: DIST_TYPES,
68+
dir: distOutputTarget.dir,
69+
typesDir: distOutputTarget.typesDir,
70+
});
7071
if (distOutputTarget.collectionDir) {
7172
outputs.push({
7273
type: DIST_COLLECTION,

0 commit comments

Comments
 (0)