feat: incremental per-file watch mode + reliability fixes#8
Open
praveenpuglia wants to merge 3 commits intomasterfrom
Open
feat: incremental per-file watch mode + reliability fixes#8praveenpuglia wants to merge 3 commits intomasterfrom
praveenpuglia wants to merge 3 commits intomasterfrom
Conversation
Reworks watch mode to process exactly the file that changed, instead of re-walking the whole src tree and re-creating a ts.Program on every tick. Two independent watchers run side-by-side: - chokidar for per-file Babel transform + asset copy, and dist artifact cleanup on unlink (.jsx/.js, .d.ts, .d.ts.map). - ts.createWatchProgram for incremental .d.ts emission — holds a persistent Program across ticks so type edits in one file only retrigger that file's declarations. On add/unlink, the watch program is closed and restarted (debounced 200ms) with the updated root file list; updateRootFileNames alone did not reliably trigger emission for newly-added files. Also fixes a couple of long-standing footguns: - Watch mode no longer rm -rf's the output directory when a file errors. Previously a single broken save would wipe dist and process.exit(1), killing the dev loop. Diagnostics are now logged and the watcher stays alive. - Initial build is awaited before watchers start, so the watch handler can never race with the first emit. - In watch mode, the eager full-program declarations pass is skipped — the ts.createWatchProgram instance does the initial .d.ts emit on its own, so running generateDeclarationsNatively first would double the startup cost. - Build script now sets +x on the compiled cli entrypoints so they stay executable when consumed via file: / npm link (tsc doesn't preserve bits, and published packages only got +x via npm publish).
`assert { type: 'json' }` was deprecated in Node 20 and fully removed
in Node 22. Running bite under a newer Node (e.g. via a consumer's
volta pin) crashed at startup with SyntaxError. Switch to the `with`
form, which is what Node 22+ accepts.
Packages that self-import via their public name (e.g. a file inside
the components package doing `import { Foo } from '@smallcase/
components'`) caused TypeScript to resolve through `node_modules/
<name>` — a workspace symlink back into the same repo — and pull
the package's own `dist/*.d.ts` files into the program as inputs.
On emit, TS refused to overwrite those inputs with TS5055 ("would
overwrite input file"), so `.d.ts` files never updated no matter
what changed in source. This silently broke prop-type propagation
to consumers.
Wrap `ts.sys` with a filter that treats any path under the
package's own `outDir` as non-existent (`fileExists`, `readFile`,
`directoryExists`, `readDirectory`, `getDirectories` all return the
empty/false result). Compares both textually and via `realpath` so
resolutions that reach the package through a symlink are caught
too — the symlink case was what blocked `dist/index.d.ts` specifically.
Writes pass through unchanged, so bite's own emission still works.
Verified against the sc-fe-components monorepo: TS5055 count went
from 133 to 0, and incremental prop-type edits now propagate to
`.d.ts` within seconds.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TL;DR
Watch mode used to re-walk the entire package and re-create a fresh
ts.Programon every keystroke. This PR reworks it so only the file that changed gets processed, and fixes two reliability issues exposed along the way:@smallcase/componentsfrom inside the components package) silently never updated their.d.tsfiles.Verified end-to-end against sc-fe-components: a prop-type edit in
Button.tsxnow lands inButton.d.tswithin ~2s, where previously it never propagated at all.Motivation
In
sc-fe-componentseach package hasbuild:watch: tsx-transform --watch. Developers run ~dozens of these concurrently (directly or via thebwshelper in the consumer repo) and consume the output through Storybook / a consumer dev server. Two things were wrong:src/, Babel-transpiled every file, and constructed a freshts.createProgramto emit all.d.ts. A one-character change produced a burst of writes across the entiredist/— enough to make Vite/Storybook HMR fall back to a full reload, and enough CPU pressure that running >10 watchers melted the machine..d.tsnever updated in some packages. Silent:.jsxwould update so the component "worked," but prop types were frozen.What changed
1. Incremental, per-file watch —
feat:8216119Watch mode now runs two independent watchers side-by-side:
flowchart LR subgraph Before[Before: full rebuild every tick] E1[file edit] --> D1[debounce 500ms] D1 --> W1[walk entire srcDir] W1 --> B1[Babel-transpile every .ts/.tsx] B1 --> T1[fresh ts.createProgram] T1 --> Em1[emit every .d.ts] Em1 --> Dist1[(dist/* all rewritten)] end subgraph After[After: incremental] E2[file edit] --> CH[chokidar] CH -->|change/add of one file| BF[Babel-transpile that one file] BF --> DJ[(one .jsx/.js written)] CH -.->|asset or unlink| AS[copy or remove single artifact] E2 -.-> TS[persistent ts.createWatchProgram] TS -->|affected files only| DT[(affected .d.ts emitted)] endchange/addof a.ts/.tsx, transpile just that file and write one.jsx/.js. Onunlink, remove the corresponding.jsx/.js/.d.ts/.d.ts.map. Assets copy/remove through unchanged.ts.createWatchProgram) — holds a persistentProgramacross ticks, re-emitting only affected.d.tson each edit. Onadd/unlinkof a.ts/.tsx, the watch program is closed and restarted (debounced 200 ms) with the updated root file list, becauseupdateRootFileNamesalone does not reliably trigger emission for newly-added files.Other cleanup in the same commit:
rm -rf's the output directory when a file errors (previous behavior: one broken save wipesdist/andprocess.exit(1)). Diagnostics log, the watcher stays alive.await-ed before watchers start, so watch handlers never race with the first emit.ts.createWatchProgramdoes its own initial emit, so runninggenerateDeclarationsNativelyfirst would double the startup cost.chmod +x's compiled CLI entrypoints so they stay executable when consumed viafile:/npm link(tsc doesn't preserve exec bits; published packages only got them vianpm publish).2. Node 22+ compatibility —
fix:fff6480assert { type: 'json' }was deprecated in Node 20 and fully removed in Node 22. Running bite under a newer Node (via a consumer's volta pin, for instance) crashed at startup withSyntaxError: Unexpected identifier 'assert'. Switched to thewithform.3. TS5055: package's own
dist/pollutes its program —fix:5045fe9This is the subtle one that was silently blocking
.d.tsupdates in self-importing packages. The cycle:sequenceDiagram participant Src as src/Foo.tsx participant TS as TypeScript Program participant NM as node_modules/@smallcase/components participant Dist as packages/components/dist/ Src->>TS: import { Bar } from '@smallcase/components' TS->>NM: resolve package NM-->>TS: symlink -> packages/components/ (the same repo!) TS->>Dist: read dist/index.d.ts for types Dist-->>TS: file found — add to program inputs Note over TS,Dist: TS now has dist/*.d.ts in its input set TS->>Dist: emit dist/Foo.d.ts Dist-->>TS: TS5055 — would overwrite an input file Note over TS: Skips the write. .d.ts never updates.In the sc-fe-components monorepo this fired 133 times for the components package alone — every single
.d.tswas blocked. Consumers (Storybook controls, type-checking in other packages) saw stale types indefinitely, even though.jsxkept updating correctly, making the breakage very easy to miss.Fix:
startTypesWatchernow wrapsts.syswith a filter that treats any path resolving to the package's ownoutDiras non-existent — compared both textually and viarealpath, so symlinked paths likenode_modules/@smallcase/components/dist/*are caught even though they're not textually underoutDir. Applies tofileExists,readFile,directoryExists,readDirectory, andgetDirectories. Writes pass through unchanged, so emission itself still works.After the fix, against sc-fe-components:
Button.tsx→Button.d.tscontains the new prop within secondsNon-goals / not in this PR
.tsbuildinfo) — the persistentProgramalready covers the main win; on-disk caching is a separate optimization tracked inTodo.md.tsconfig.jsongaps that causeTS2307for*.module.cssimports — orthogonal, lives in the consumer repo.Test plan
.jsx+.d.tsfor a fresh package (local smoke test in/tmp/bite-smoke)..tsx/.ts: only that file's.jsx/.jsrewrites, and affected.d.tsre-emits..jsx/.js/.d.ts/.d.ts.mapall appear.@smallcase/components→Button.tsx) propagates toButton.d.ts.TS5055count on a self-importing package goes to 0.npm run build(one-shot, non-watch) still behaves as before — full walk + fullts.createProgram.chmod +xkeeps the CLI executable when consumed viafile:/npm link.How to try it locally
From a consumer repo:
🤖 Generated with Claude Code