Skip to content

Commit 1748dd9

Browse files
authored
chore: v5 remove buildDist and buildDocs. Replace with per output skipInDev (#6682)
* chore: v5 remove buildDist and buildDocs. Replace with per output `skipInDev` * chore:
1 parent cf90c90 commit 1748dd9

39 files changed

+642
-128
lines changed

V5_PLANNING.md

Lines changed: 201 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,15 @@ See [CLI/Core Architecture](#clicore-architecture) section for details.
8484
- `formAssociated: true` → Use `@AttachInternals()` decorator instead (auto-sets `formAssociated: true`)
8585
- To use `@AttachInternals` without form association: `@AttachInternals({ formAssociated: false })`
8686
- Run `stencil migrate --dry-run` to preview automatic migration, or `stencil migrate` to apply changes
87+
- **`buildDist` and `buildDocs` config options removed.** Use `skipInDev` on individual output targets for granular control:
88+
- `dist`: `skipInDev: false` (default) - always builds in both dev and prod
89+
- `dist-custom-elements`: `skipInDev: true` (default) - skips in dev mode, builds in prod
90+
- `dist-hydrate-script`: `skipInDev: true` (default, unless `devServer.ssr` is enabled)
91+
- `docs-*` targets: `skipInDev: true` (default) - skips in dev mode, builds in prod
92+
- `custom` output targets: `skipInDev: true` (default) - skips in dev mode, builds in prod
93+
- All outputs ALWAYS run in production mode regardless of `skipInDev` setting
94+
- Run `stencil migrate` to update your config (removes deprecated options)
95+
- **`--esm` CLI flag removed.** Configure `skipInDev` on output targets instead.
8796

8897
### 8. 🏷️ Release Management: Changesets
8998
**Status:** 📋 Planned
@@ -356,6 +365,197 @@ Simplified the version/build identification system for v5:
356365
</details>
357366

358367

368+
---
369+
370+
## 🚀 Compilation Performance: Watch Mode Fast Path
371+
372+
**Status:** 📋 Planned
373+
374+
### Current Architecture
375+
376+
```
377+
┌─────────────────────────────────────────────────────────────────────────┐
378+
│ File Change → IncrementalCompiler.rebuild() → Full build() │
379+
│ └─ runTsProgram (all changed files) │
380+
│ └─ generateOutputTargets (rolldown compiles) │
381+
│ └─ writeBuild │
382+
└─────────────────────────────────────────────────────────────────────────┘
383+
```
384+
385+
**Problem:** Even a single-file change triggers the full pipeline.
386+
387+
### Solution: Leverage `transpileModule` for Watch Mode
388+
389+
The existing `transpileModule()` function (`src/compiler/transpile/transpile-module.ts`) already does single-file compilation with all necessary transforms. We can use it for a "fast path" in watch mode.
390+
391+
#### How `transpileModule` Works Today
392+
393+
1. Creates a fresh `ts.Program` for each call
394+
2. Runs `convertDecoratorsToStatic` (extracts component metadata)
395+
3. Runs output-target transforms (`lazyComponentTransform` or `nativeComponentTransform`)
396+
4. Handles inheritance via `extraFiles` parameter
397+
5. Returns: JS code, sourcemap, `moduleFile` with component metadata
398+
399+
#### Proposed Enhancement: Add Shared Context
400+
401+
```typescript
402+
// Enhanced transpileModule signature
403+
export const transpileModule = (
404+
config: d.ValidatedConfig,
405+
input: string,
406+
transformOpts: d.TransformOptions,
407+
context?: {
408+
// Reuse existing Program/TypeChecker from IncrementalCompiler
409+
program?: ts.Program;
410+
typeChecker?: ts.TypeChecker;
411+
// Update existing moduleMap instead of creating fresh
412+
compilerCtx?: d.CompilerCtx;
413+
// Access to component list for cross-component transforms
414+
buildCtx?: d.BuildCtx;
415+
},
416+
): d.TranspileModuleResults => {
417+
// If context provided, reuse it; otherwise create fresh (current behavior)
418+
const compilerCtx = context?.compilerCtx ?? new CompilerContext();
419+
const buildCtx = context?.buildCtx ?? new BuildContext(config, compilerCtx);
420+
const typeChecker = context?.typeChecker ?? program.getTypeChecker();
421+
// ...
422+
}
423+
```
424+
425+
#### Watch Mode Fast Path
426+
427+
```
428+
┌────────────────────────────────────────────────────────────────────┐
429+
│ File Change Detected │
430+
├────────────────────────────────────────────────────────────────────┤
431+
│ 1. Quick check: is it a component file? │
432+
│ ├─ NO (plain .ts) → Skip to step 5 │
433+
│ └─ YES → Continue... │
434+
│ │
435+
│ 2. transpileModule(source, opts, { │
436+
│ program: incrementalCompiler.getProgram(), │
437+
│ typeChecker: incrementalCompiler.getProgram().getTypeChecker(),
438+
│ compilerCtx, │
439+
│ buildCtx, │
440+
│ }) │
441+
│ └─ Reuses existing TypeChecker (no fresh Program creation) │
442+
│ └─ Updates existing moduleMap entry │
443+
│ │
444+
│ 3. Compare old vs new component metadata: │
445+
│ - API changed (props/events/methods)? → Full rebuild │
446+
│ - JSDoc changed && docsOutputTargets.length > 0? → Regen docs │
447+
│ - Neither? → HOT SWAP only │
448+
│ │
449+
│ 4. If docs need regen: outputDocs() only (skip bundling) │
450+
│ │
451+
│ 5. Hot-swap module in dev server (skip rolldown entirely) │
452+
└────────────────────────────────────────────────────────────────────┘
453+
```
454+
455+
### Implementation Plan
456+
457+
#### Phase 1: Add `context` Parameter to `transpileModule`
458+
459+
Allow reusing existing `Program`/`TypeChecker`/`CompilerCtx` from the watch build:
460+
461+
```typescript
462+
// In watch-build.ts
463+
const results = transpileModule(config, source, transformOpts, {
464+
program: incrementalCompiler.getProgram(),
465+
typeChecker: incrementalCompiler.getProgram()?.getTypeChecker(),
466+
compilerCtx,
467+
buildCtx,
468+
});
469+
```
470+
471+
**Benefits:**
472+
- No fresh `ts.Program` creation per file (expensive)
473+
- Shared type information for decorator resolution
474+
- Updates existing `moduleMap` in-place
475+
476+
#### Phase 2: Implement Change Detection
477+
478+
```typescript
479+
// In watch-build.ts, after transpileModule completes:
480+
const oldMeta = compilerCtx.moduleMap.get(filePath)?.cmps[0];
481+
const newMeta = results.moduleFile?.cmps[0];
482+
483+
// Check if public API changed
484+
const apiChanged =
485+
JSON.stringify(oldMeta?.properties) !== JSON.stringify(newMeta?.properties) ||
486+
JSON.stringify(oldMeta?.events) !== JSON.stringify(newMeta?.events) ||
487+
JSON.stringify(oldMeta?.methods) !== JSON.stringify(newMeta?.methods);
488+
489+
// Check if JSDoc changed (only matters if docs output targets exist)
490+
const hasDocsTargets = config.outputTargets.some(isOutputTargetDocs);
491+
const jsDocChanged = hasDocsTargets && (
492+
JSON.stringify(oldMeta?.docs) !== JSON.stringify(newMeta?.docs) ||
493+
JSON.stringify(oldMeta?.docsTags) !== JSON.stringify(newMeta?.docsTags)
494+
);
495+
496+
if (apiChanged) {
497+
// API changed - need full incremental rebuild (types, bundling, etc.)
498+
await triggerRebuild();
499+
} else if (jsDocChanged) {
500+
// Only docs changed - regenerate docs, hot-swap module
501+
compilerCtx.moduleMap.set(filePath, results.moduleFile);
502+
await outputDocs(config, compilerCtx, buildCtx);
503+
devServer.hotSwapModule(filePath, results.code);
504+
} else {
505+
// Internal change only - just hot-swap the module
506+
compilerCtx.moduleMap.set(filePath, results.moduleFile);
507+
devServer.hotSwapModule(filePath, results.code);
508+
}
509+
```
510+
511+
#### Phase 3: Non-Component Files Fast Path
512+
513+
For plain `.ts` files (utilities, services, etc.), we don't need any Stencil transforms:
514+
515+
```typescript
516+
const isComponent = filePath.match(/\.tsx?$/) &&
517+
compilerCtx.moduleMap.get(filePath)?.cmps?.length > 0;
518+
519+
if (!isComponent) {
520+
// Plain TS file - just re-emit via TypeScript
521+
// No decorator extraction, no component transforms needed
522+
const result = ts.transpileModule(source, { compilerOptions });
523+
devServer.hotSwapModule(filePath, result.outputText);
524+
return;
525+
}
526+
```
527+
528+
### Change Detection Matrix
529+
530+
| Change Type | API Changed? | JSDoc Changed? | Action |
531+
|-------------|--------------|----------------|--------|
532+
| Internal logic only ||| Hot-swap module |
533+
| JSDoc comment updated ||| Regen docs + hot-swap |
534+
| New `@Prop()` added || - | Full rebuild |
535+
| Prop type changed || - | Full rebuild |
536+
| Component renamed || - | Full rebuild |
537+
| Style change ||| Existing CSS path |
538+
539+
### What Triggers Full Rebuild
540+
541+
- Component API changes (props, events, methods, states)
542+
- New component added
543+
- Component deleted
544+
- Component tag name changed
545+
- Inheritance chain changes
546+
547+
### Expected Impact
548+
549+
| Change Type | Current | With Fast Path |
550+
|-------------|---------|----------------|
551+
| Internal logic change | ~500ms-1s | **< 50ms** |
552+
| JSDoc change (with docs targets) | ~500ms-1s | **< 100ms** |
553+
| Style change | ~200ms | ~200ms (unchanged) |
554+
| API change (new prop) | ~500ms-1s | ~500ms-1s (unchanged) |
555+
| New component | ~500ms-1s | ~500ms-1s (unchanged) |
556+
557+
**~80% of dev changes are internal logic** → massive improvement for typical workflow.
558+
359559
---
360560

361561
## ⚠️ Notes for Future Agents
@@ -374,4 +574,4 @@ In individual packages or from root. pnpm workspaces handle dependency ordering
374574

375575
---
376576

377-
*Last updated: 2026-04-04*
577+
*Last updated: 2026-04-13*

cspell-wordlist.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,4 +169,5 @@ jsxdev
169169
jsxs
170170
labelable
171171
lightningcss
172-
cooldown
172+
cooldown
173+
regen

packages/cli/src/_test_/merge-flags.spec.ts

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -123,25 +123,14 @@ describe('mergeFlags', () => {
123123
});
124124
});
125125

126-
describe('buildDocs (--docs)', () => {
127-
it('sets buildDocs from --docs flag', () => {
126+
describe('_docsFlag (--docs)', () => {
127+
it('sets _docsFlag from --docs flag', () => {
128128
const config: Config = {};
129129
const flags = createFlags({ docs: true });
130130

131131
const result = mergeFlags(config, flags);
132132

133-
expect(result.buildDocs).toBe(true);
134-
});
135-
});
136-
137-
describe('buildDist (--esm)', () => {
138-
it('sets buildDist from --esm flag', () => {
139-
const config: Config = {};
140-
const flags = createFlags({ esm: true });
141-
142-
const result = mergeFlags(config, flags);
143-
144-
expect(result.buildDist).toBe(true);
133+
expect(result._docsFlag).toBe(true);
145134
});
146135
});
147136

packages/cli/src/_test_/task-migrate.spec.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,7 @@ describe('task-migrate', () => {
254254

255255
await taskMigrate(mockCoreCompiler, config, flags);
256256

257-
expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining('No TypeScript files found'));
257+
expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining('No migrations needed'));
258258
});
259259

260260
it('should handle empty file content', async () => {
@@ -304,9 +304,9 @@ describe('task-migrate', () => {
304304
const result = await detectMigrations(mockCoreCompiler, config);
305305

306306
expect(result.hasMigrations).toBe(true);
307-
expect(result.totalMatches).toBe(1);
308-
expect(result.filesAffected).toBe(1);
309-
expect(result.migrations).toHaveLength(1);
307+
expect(result.totalMatches).toBe(2);
308+
expect(result.filesAffected).toBe(2);
309+
expect(result.migrations).toHaveLength(2);
310310
});
311311

312312
it('should include migration details', async () => {

packages/cli/src/config-flags.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ export const BOOLEAN_CLI_FLAGS = [
1616
'devtools',
1717
'docs',
1818
'dryRun',
19-
'esm',
2019
'help',
2120
'log',
2221
'open',

packages/cli/src/merge-flags.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,10 @@ export const mergeFlags = (config: Config, flags: ConfigFlags): Config => {
3535
merged.watch = flags.watch;
3636
}
3737

38-
// --docs → buildDocs
39-
if (typeof flags.docs === 'boolean') {
40-
merged.buildDocs = flags.docs;
41-
}
42-
43-
// --esm → buildDist
44-
if (typeof flags.esm === 'boolean') {
45-
merged.buildDist = flags.esm;
38+
// --docs → _docsFlag (internal flag to force docs in dev mode)
39+
// This is processed during output target validation to set skipInDev: false on docs targets
40+
if (flags.docs === true) {
41+
merged._docsFlag = true;
4642
}
4743

4844
// --profile → profile

packages/cli/src/migrations/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import ts from 'typescript';
22

3+
import { buildDistDocsRule } from './rules/build-dist-docs';
34
import { encapsulationApiRule } from './rules/encapsulation-api';
45
import { formAssociatedRule } from './rules/form-associated';
56

@@ -105,7 +106,11 @@ export interface MigrationRule {
105106
* Registry of all available migration rules.
106107
* Rules are applied in order, so add new rules at the end.
107108
*/
108-
const migrationRules: MigrationRule[] = [encapsulationApiRule, formAssociatedRule];
109+
const migrationRules: MigrationRule[] = [
110+
encapsulationApiRule,
111+
formAssociatedRule,
112+
buildDistDocsRule,
113+
];
109114

110115
/**
111116
* Get all migration rules for a specific version upgrade.

0 commit comments

Comments
 (0)