Skip to content

feat: incremental per-file watch mode + reliability fixes#8

Open
praveenpuglia wants to merge 3 commits intomasterfrom
praveen/incremental-watch
Open

feat: incremental per-file watch mode + reliability fixes#8
praveenpuglia wants to merge 3 commits intomasterfrom
praveen/incremental-watch

Conversation

@praveenpuglia
Copy link
Copy Markdown
Collaborator

TL;DR

Watch mode used to re-walk the entire package and re-create a fresh ts.Program on every keystroke. This PR reworks it so only the file that changed gets processed, and fixes two reliability issues exposed along the way:

  1. Node 22+ crashed bite at startup due to a removed import-attribute syntax.
  2. Packages that self-import via their public name (e.g. @smallcase/components from inside the components package) silently never updated their .d.ts files.

Verified end-to-end against sc-fe-components: a prop-type edit in Button.tsx now lands in Button.d.ts within ~2s, where previously it never propagated at all.

Motivation

In sc-fe-components each package has build:watch: tsx-transform --watch. Developers run ~dozens of these concurrently (directly or via the bws helper in the consumer repo) and consume the output through Storybook / a consumer dev server. Two things were wrong:

  • Every tick was a full rebuild. On a single save, bite re-walked the whole src/, Babel-transpiled every file, and constructed a fresh ts.createProgram to emit all .d.ts. A one-character change produced a burst of writes across the entire dist/ — enough to make Vite/Storybook HMR fall back to a full reload, and enough CPU pressure that running >10 watchers melted the machine.
  • .d.ts never updated in some packages. Silent: .jsx would update so the component "worked," but prop types were frozen.

What changed

1. Incremental, per-file watch — feat: 8216119

Watch 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)]
    end
Loading
  • Babel side (chokidar) — on change/add of a .ts/.tsx, transpile just that file and write one .jsx/.js. On unlink, remove the corresponding .jsx/.js/.d.ts/.d.ts.map. Assets copy/remove through unchanged.
  • Types side (ts.createWatchProgram) — holds a persistent Program across ticks, re-emitting only affected .d.ts on each edit. On add/unlink of a .ts/.tsx, the watch program is closed and restarted (debounced 200 ms) with the updated root file list, because updateRootFileNames alone does not reliably trigger emission for newly-added files.

Other cleanup in the same commit:

  • Watch mode no longer rm -rf's the output directory when a file errors (previous behavior: one broken save wipes dist/ and process.exit(1)). Diagnostics log, the watcher stays alive.
  • Initial build is await-ed before watchers start, so watch handlers never race with the first emit.
  • In watch mode, the eager full-program declarations pass is skipped — the ts.createWatchProgram does its own initial emit, so running generateDeclarationsNatively first would double the startup cost.
  • Build script now chmod +x's compiled CLI entrypoints so they stay executable when consumed via file:/npm link (tsc doesn't preserve exec bits; published packages only got them via npm publish).

2. Node 22+ compatibility — fix: fff6480

assert { 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 with SyntaxError: Unexpected identifier 'assert'. Switched to the with form.

3. TS5055: package's own dist/ pollutes its program — fix: 5045fe9

This is the subtle one that was silently blocking .d.ts updates 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.
Loading

In the sc-fe-components monorepo this fired 133 times for the components package alone — every single .d.ts was blocked. Consumers (Storybook controls, type-checking in other packages) saw stale types indefinitely, even though .jsx kept updating correctly, making the breakage very easy to miss.

Fix: startTypesWatcher now wraps ts.sys with a filter that treats any path resolving to the package's own outDir as non-existent — compared both textually and via realpath, so symlinked paths like node_modules/@smallcase/components/dist/* are caught even though they're not textually under outDir. Applies to fileExists, readFile, directoryExists, readDirectory, and getDirectories. Writes pass through unchanged, so emission itself still works.

After the fix, against sc-fe-components:

  • TS5055 count: 133 → 0
  • Prop-type edit in Button.tsxButton.d.ts contains the new prop within seconds
  • No behavior change for packages that don't self-import

Non-goals / not in this PR

  • Disk-level caching (.tsbuildinfo) — the persistent Program already covers the main win; on-disk caching is a separate optimization tracked in Todo.md.
  • Fixing the per-package tsconfig.json gaps that cause TS2307 for *.module.css imports — orthogonal, lives in the consumer repo.

Test plan

  • Initial build in watch mode emits all .jsx + .d.ts for a fresh package (local smoke test in /tmp/bite-smoke).
  • Editing .tsx/.ts: only that file's .jsx/.js rewrites, and affected .d.ts re-emits.
  • Adding a new file: .jsx/.js/.d.ts/.d.ts.map all appear.
  • Deleting a source file: matching dist artifacts are cleaned up.
  • Prop-type change on an actual consumer (@smallcase/componentsButton.tsx) propagates to Button.d.ts.
  • TS5055 count on a self-importing package goes to 0.
  • npm run build (one-shot, non-watch) still behaves as before — full walk + full ts.createProgram.
  • chmod +x keeps the CLI executable when consumed via file:/npm link.
  • Dry-run against one more large sc-fe-components package after merge — the user will smoke-test in follow-up.

How to try it locally

From a consumer repo:

# in sc-fe-bite
git checkout praveen/incremental-watch
npm run build

# in the consumer repo (e.g. sc-fe-components)
npm link @smallcase/bite   # or use a file: dep if preferred
# then run your usual watch / storybook flow

🤖 Generated with Claude Code

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant