Build Speed and Fable Caching
How the dev build gets fast startup, and what silently makes it slow again.
The scripts
-
npm run app—scripts/app.js: starts the app in whichever ofdev/dev:oncethe generated JS already belongs to, so there is no mode to keep track of. Use this unless you specifically want one of them.npm run app -- --whichsays what it would pick and why without starting anything; anything else after--goes through to Electron. -
npm run dev—scripts/dev.jsrunsdotnet fable watchforsrc/Mainandsrc/Rendererin parallel, then starts webpack + Electron (scripts/start.js) as soon as both projects' generated JS is safe to load. Hot reload of renderer changes while running. -
npm run dev:once— same launcher with a one-shot compile and no watcher. When nothing changed since the last compile, Fable skips compilation entirely and the app is up in a few seconds. Edits need a rerun. npm run debug—devplus theASSERTSdefine on the renderer.-
npm run compile—scripts/parallel-compile.js: one-shot parallel compile of both projects with thePRODUCTIONdefine. Used bypackanddist. node scripts/dev.js --no-app— either mode without launching Electron (compile check).
Every one of these reaches scripts/start.js, which frees the two ports it
needs before using them (scripts/free-port.js): 8672 for the webpack
dev server and 9222 for the DevTools protocol that scripts/inspect-canvas.js talks to. A session
interrupted in any of the usual ways leaves both held, and the failure that follows is worse than
it sounds: the port is bound after a full Fable compile and after Electron has been told to open,
so the window appears, stays blank, and the log says ready. A stale Electron on 9222 is quieter
still — inspect-canvas connects to it and reports the previous run's canvas. Whoever holds the
port is killed, whatever it is; that is a more reliable test than matching command lines.
scripts/clean-dev.js is still the tool for sweeping a whole abandoned
session, because it also catches a fable watch that holds no port at all and can still recompile
the tree under you.
How Fable decides it can start fast
Fable has no on-disk cache of typed ASTs: a cold compile type-checks every file of the Renderer (~200 including dependencies) and takes on the order of a minute. What it does have:
-
Project cracking cache (
build-fable/<project>/fable_modules/project_cracked.json) — restoring project options takes ~200ms instead of several seconds. -
Up-to-date detection — if every generated
.fs.jsis strictly newer than its.fs, a one-shot compile is skipped entirely, andfable watchruns its--runcommand immediately (recompiling silently in the background to build its watch graph).dev.jsuses that--runhook (scripts/fable-ready.js) as the signal to start Electron, so an unchanged tree starts the app in seconds either way.
What breaks it
-
A stale
.fs.jstimestamp, whichscripts/refresh-stale-output.jsexists to prevent. It pairs each generated file with its source through thesourcesentry of the source map beside it, since the output no longer sits next to the.fs— seescripts/fable-output.js. Fable rewrites an output file only when its content changed, and the up-to-date check above is about timestamps, so the two can disagree. A.fswhose mtime moves without its emitted JS changing — a comment or warning-only edit, agit checkout, a rebase — leaves an output that is perfectly current and still fails the check. A recompile does not fix it: nothing changed, so nothing is written, so every subsequent startup pays the full ~1 minute again, indefinitely.So
dev.jsandparallel-compile.jsboth runrefresh-stale-output.jsafter a successful compile, when every output is current by construction, and it prints what it touched. To run it by hand, or on a directory of your own:node scripts/refresh-stale-output.js [dir ...] -
Switching build modes.
fable watchimplicitly adds theDEBUGdefine (this is what enables Elmish HMR), sodev,dev:once,debug(ASSERTS) andcompile(PRODUCTION) are four different builds. Each switch between them invalidates the up-to-date state and costs one full recompile; staying in one mode stays fast.npm run appavoids the question by going wherever the tree already is.Which mode the tree is in is recorded per project in
fable_modules, and only one cache exists at a time: watch writesproject_cracked_debug.json, every other mode writesproject_cracked.jsonholding the defines it used.npm run app -- --whichreads them out.Note for anyone verifying a change compiles:
npm run compileleaves the tree inPRODUCTION, so the nextdevordev:oncepays a full recompile.node scripts/dev.js --once --no-appis the same check without that cost. -
Changed compiler options. Any option change — defines,
--verbose, source maps — is recorded in the cracking cache and defeats output reuse for the next run. In particular a--verbosediagnostic run makes the following plain run recompile.
If the cache seems wedged, dotnet fable clean in the project directory removes the generated
files and caches for a genuinely fresh start.