Skip to content

A whole lot of changes - #63

Merged
nojaf merged 29 commits into
mainfrom
improve-vite-plugin
Aug 29, 2026
Merged

A whole lot of changes#63
nojaf merged 29 commits into
mainfrom
improve-vite-plugin

Conversation

@nojaf

@nojaf nojaf commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator

Rewrites the plugin in TypeScript, then fixes what the type checker and a read of Vite's own source turned up. 27 commits; each one stands alone.

TypeScript

  • index.js and types.d.ts become index.ts and types.ts, compiled to dist/ by tsc, with strict on. The package ships type declarations, so a vite.config.ts can name PluginOptions.
  • Consuming Vite's own .d.ts means a hook shape change fails the build instead of failing at runtime.
  • oxlint with explicit type annotations required; oxfmt replaces Prettier.

Hooks

  • Migrated to hotUpdate. handleHotUpdate is deprecated and never fires for created or deleted files.
  • One compile per file change, not one per environment. Vite calls hotUpdate once per environment, so a default dev server compiled every edit twice.
  • The dev server starts before the first compile. The wait moved to load, where a failure reaches the browser overlay instead of a terminal with no URL.
  • Compiled output is served from load, so Vite no longer reads the .fs off disk to throw it away.
  • load matches ids carrying a query, and leaves ?raw and ?url alone.
  • Changes are coalesced into batches, each with its own promise, so a file edited during a slow compile gets its own result.
  • An MSBuild input change re-cracks once, not three times.

Correctness

  • vite build fails on an F# error instead of exiting 0 with broken output.
  • vite build --mode staging compiles Release. It used to read env.MODE and put Debug F# in a production bundle.
  • Changing noReflection or exclude invalidates the caches. Neither was part of the cache key, so stale JavaScript was served silently.
  • transform reports map: { mappings: "" } rather than null, which had devtools showing an F# filename containing JavaScript.
  • The daemon answers every message, including ones it cannot serve. A dead message loop used to leave the plugin waiting forever.
  • Plugin options are validated at config load, with a suggestion for a misspelled key.

Packaging

  • No more postinstall. The daemon ships prebuilt as portable IL, so --ignore-scripts installs work and nothing is compiled on the consumer's machine. The .NET 10 SDK is still required, because cracking shells out to dotnet msbuild.
  • Verified by installing the packed tarball into a project with nothing from this repo in it.
  • Note for release: the tarball must be built by Bun. npm pack leaves the workspace catalog: protocol in the manifest and nothing can install the result.

Debugging

  • The daemon's debug server answers JSON under /api: what it cracked, compiled, cached and served, readable by anything that is not a browser. Read-only, and served from a snapshot so it never queues behind a compile.
  • The debug plugin option now starts it too, rather than needing VITE_PLUGIN_FABLE_DEBUG as well.
  • Diagnostics for files under fable_modules are dropped by default; fableModulesDiagnostics brings them back.

Tests and CI

  • The Vite hooks are driven against a stub daemon, and the JSON-RPC wire format is pinned by fixtures both sides are tested against.
  • PR CI runs the tests on Linux, macOS and Windows; formatting, linting and docs run once.

Known issue, not fixed here

  • Under vite build --watch, a failed re-crack throws out of the batch runner and rejects the promise the queue chains on. That surfaces as an unhandled rejection, and any batch queued after it never runs. Dev is unaffected, because there the failure is recorded rather than rethrown.

nojaf added 28 commits August 29, 2026 10:35
index.js and types.d.ts become index.ts and types.ts, compiled to dist/
by tsc. The plugin was already JSDoc-annotated and type-checked through
checkJs, so this is a mechanical conversion rather than a rewrite, done
first so that the Vite hook fixes still to come land in a file the
compiler can help with. The payoff is upgrade safety: consuming vite's
own .d.ts means a Vite major that changes a hook shape fails the build
instead of failing silently at runtime. The package now ships type
declarations too.

Behaviour is unchanged except for the daemon path, which gains a ".."
because the plugin is emitted to dist/ while the daemon is still
published to bin/ at the package root. strict stays off; turning it on
is a separate change with real errors to fix.

Type-checking of docs/scripts/command.js moves to tsconfig.docs.json so
it keeps its checkJs coverage now that the main config emits.

Also wire up the root build: bun install runs the daemon and plugin
builds, so a fresh clone works without knowing to run them by hand. This
matters more than before, since dist/ is generated and gitignored where
index.js used to be committed. The plugin's own postinstall is now only
the consumer install hook and delegates to build:daemon, so the root no
longer reaches into another package's lifecycle script.

Rewrite ROADMAP.md around a review of the plugin against the Vite 8.2.2
and Fable 5.14 sources. Completed items are removed rather than ticked
off, and the remaining ones now carry source references: the HMR
pipeline (handleHotUpdate is deprecated and never fires for created or
deleted files), daemon process handling, builds that exit 0 on F#
errors, and replacing postinstall with a prebuilt daemon package.
Spawning the daemon through a shell hid every startup failure. With
shell: true a missing dotnet became "the shell exited 127" rather than a
spawn error, so the JSON-RPC request never settled and buildStart hung
forever. Since Vite awaits buildStart before httpServer.listen, the dev
server printed no URL and no error at all. Dropping the shell also fixes
paths containing spaces and makes kill() reach the daemon instead of a
wrapper.

Alongside that: stderr was piped but never read, which would deadlock the
daemon once the 64KB pipe buffer filled, and there was no error or exit
handler, so a daemon that died took every pending request down with it
silently. Requests are now raced against process failure and report what
went wrong. SIGINT stops the daemon too; Vite only installs a SIGTERM
listener, and while Ctrl+C signals the whole process group anyway, a
SIGINT aimed at this process alone would orphan it.

The daemon then moves behind a FableDaemon interface in its own module.
It owns the child process for its lifetime, so process handling, the
JSON-RPC endpoint and the positional wire format stop leaking into the
plugin: three call sites indexing fields[0]/[1]/[2] out of an any[]
become four typed methods, and PluginState drops dotnetProcess and
endpoint for a single daemon field. That also gives the plugin a seam to
run against a stub, which is what makes the HMR batching race testable
without spawning dotnet and compiling F# for real.

Behaviour is otherwise unchanged.
There were no JavaScript-side tests at all, which is uncomfortable given
the plugin's remaining bugs are timing bugs in the HMR path. Those are
hard to reach through the real daemon: reproducing them means controlling
when a compile finishes, and a real compile takes seconds and finishes
when it likes.

So the daemon is injected rather than constructed. fablePlugin keeps its
signature and now delegates to createFablePlugin, which takes the factory
as an argument; tests pass a stub that answers from canned data, records
its calls, and can be told to fail or to hold a compile open until the
test releases it. The whole suite runs with dotnet absent from PATH and
never spawns a process, so it is fast and needs no toolchain.

Seventeen tests cover fsproj selection, Debug/Release, option pass-through,
project cracking and file watching, transform output with JSX on and off,
hot update recompiles, overlay errors, change coalescing, MSBuild-triggered
re-cracks, and daemon disposal. One is a characterization test: builds
currently survive an unavailable daemon, and it says so and points at the
roadmap item that will change it.

Sources move to src/ and tests to tests/ so the two are distinguishable,
with tsconfig.test.json type-checking both while the build config emits
only src/. Wired into bun run test:plugin, the root test script alongside
dotnet test, and both CI workflows.

Also drop a leftover console.log that dumped the whole HMR error payload
to the terminal; the test output made it obvious.
Formatting was checked but nothing linted the TypeScript, so the only
feedback on the plugin sources came from tsc. oxlint now runs over the
repository with @nojaf/oxlint-plugin-annotate-non-primitives, which asks
for a written type wherever it is not obvious from the initializer.
Inference is convenient while writing and unhelpful while reading, and
this code is read far more than it is written.

That flagged 90 places, all in code added over the last few commits, and
fixing them pushed the tests towards named types rather than repeated
inline shapes. The rule is scoped to **/*.ts through an override: it also
fired on docs/scripts/command.js and the changelog updater, where
annotations are not expressible at all, so applying it there could never
be satisfied.

The changelog updater moves to TypeScript, which costs nothing because
bun runs it directly, and gains a type check it never had via
tsconfig.scripts.json. docs/scripts/command.js stays JavaScript: the docs
pages load it straight through an import map with no bundler anywhere in
that pipeline, so porting it would mean introducing a build step to
satisfy a lint rule. It remains checked through checkJs.

Every lint script now runs oxlint before its type checks, at the root and
in the package, with the type checks split into lint:types so the root
does not run oxlint twice. The CI step was called "TypeScript check",
which is no longer what it does, so it is now just "Lint".
A project that did not compile still built successfully and exited 0.
projectChanged caught everything, buildStart caught it again, and
logError went through logger.warn so Vite never even set its error flag.
The transform hook then returned nothing for the files Fable had not
produced, which left Vite parsing the F# source as JavaScript: the user
got a syntax error pointing at `module Foo` rather than the type error
that actually caused it.

Builds now stop. Any error-severity diagnostic aborts, as does a failed
crack or compile, and an F# file Fable never compiled is reported through
this.error instead of reaching the JavaScript parser. Warnings still let
the build through, and logError reports through logger.error so Vite
records that something went wrong.

Dev deliberately keeps the old behaviour: the server stays up after a
failed compile so the browser overlay can show the diagnostic and the
next edit can fix it. Only build mode is fatal.

Covered by four tests, verified to fail without the change. Two of them
were initially green for the wrong reasons and are worth knowing about:
bun types expect().rejects.toThrow() as returning void, so awaiting it
does nothing, and the transform assertion matched a TypeError raised by
the harness itself, whose message quotes the call expression and so
contained the string being matched. The plugin context stub now
implements error() with Rollup's throwing semantics.
handleHotUpdate is on Vite's deprecation list, and it is also narrower
than it looks: Vite only ever dispatches it for updates, so files being
created or deleted never reached the plugin at all. hotUpdate receives
all three, and brings this.environment.hot with it, which settles the
server.hot deprecation at the same time.

The bigger problem was underneath. Every in-flight change awaited one
shared promise, so a file edited while another was still compiling got
answered by the previous compile's diagnostics. It was reported to the
browser before it had been compiled, and its own diagnostics were then
dropped on the floor because the promise had already settled. Changes now
land in coalescing batches that each carry their own result, with
compiles chained so only one runs at a time. That replaces the RxJS
pipeline with a small queue, so rxjs and promise.withresolvers are gone;
withResolvers itself is a local helper rather than the native one, which
only exists from Node 22 while Vite still supports 20.19.

Four smaller faults went with it. Editing a signature file did nothing
until an implementation file happened to be touched. Editing an fsproj
re-cracked the project but told the browser nothing, leaving stale
modules loaded. A changed file that nothing imports returned an empty
array, which Vite reads as "handled, do nothing", so the edit vanished
silently. And only the edited module was invalidated, never the files
whose output changed downstream of it.

That last one needs care in both directions. Fable recompiles everything
downstream of an edit, but nearly all of that output is identical to what
was already served, and reporting it drags modules that cannot accept a
hot update into the update — one dead end turns the whole thing into a
page reload. Comparing against the previous output means an F# React
component now hot-updates through Fast Refresh instead of reloading the
page. Plain modules still reload, correctly: Fable emits no accept
handlers, so there is no boundary to stop at.

Covered by ten plugin tests, including the race, and a daemon test that
breaks a signature file and asserts the error lands on the
implementation. The sample project gains a Greeting.fsi/Greeting.fs pair
rendered into the page heading so the signature-file path can be tried by
hand.
strict was still off from the checkJs era, so the type checker had been
running over the plugin all along with the useful half disabled. Turning
it on surfaced seven errors, one of them real: configResolved derived the
project directory from resolvedConfig.configFile, which is optional, so a
project without a Vite config file — or one created programmatically —
reached fs.readdir(undefined). It now uses resolvedConfig.root, which is
always resolved and is anyway the correct directory when root differs
from where the config file lives.

The rest were the same shape: state.fsproj is string | null and was going
straight to the daemon. requireFsproj turns a missing project into an
error at the point of use, so a build fails saying so instead of cracking
null. Two tests cover the fix, because every existing test passed an
explicit fsproj and discovery therefore never ran.

oxlint warnings now fail rather than being printed and ignored. A dead
interface had already survived a green CI run that way.

The sample gets its Bun runtime from its own bunfig.toml instead of
`bunx --bun` in every script, so the scripts are plain `vite`. Bun reads
bunfig.toml from the directory a command starts in and does not walk up,
so a [run] block at the repo root looks right and does nothing — hence
the second file rather than one at the root.

Vite DevTools now runs, which needed patches/crossws@0.4.12.patch: its
Node WebSocket adapter refuses to start when Bun is in globalThis, and
the path DevTools takes hardcodes that adapter even though devframe ships
a Bun transport. Reported as devframes/devframe#317; the patch is pinned
to that exact version until it lands. vite-plugin-inspect stays alongside
it with build: true, because the DevTools panel is injected client-side
behind an OTP fragment and is invisible from a terminal, whereas Inspect
writes .vite-inspect/reports/ as plain JSON — same information, readable
without a browser.

CLAUDE.md, with AGENTS.md symlinked to it, records only what cannot be
worked out from the repo: the local Vite and Fable checkouts to verify
hook contracts against, how to read transform output without a browser,
and the traps that cost real time here — Vite's SPA fallback answering
200 for any path, bunfig not propagating, and bun typing
expect().rejects.toThrow() as void so awaiting it does nothing.
Two hook-contract bugs, plus the CI and script gaps found alongside them.

The transform hook returned `map: null`, which in the Rollup and Vite
contract means "I did not move code, keep the previous mapping". That is
false here — the hook replaces F# with JavaScript — and downstream stages
took it at face value, generating a map whose `sources` named a `.fs`
file while its `sourcesContent` held the compiled JavaScript. Devtools
duly showed an F# filename containing JavaScript, which is worse than no
map at all. `{ mappings: "" }` is how Vite's own plugins say a mapping was
lost. Real F#-to-JS source maps stay blocked on Fable, where
FileWriter.AddSourceMapping is a no-op.

compileProject built its compiled-output map by iterating the source file
list and indexing the daemon's response with an already-normalised path,
while fsharpFileChanged normalised the daemon's keys on the way in. The
two sets are not the same — signature files are reported as sources and
never compiled — and the mismatched lookup would yield undefined for
every entry if the daemon ever reported a non-POSIX path. Both paths now
key off what the daemon returned.

CI ran the plugin tests but never `dotnet test`, so the daemon suite,
including the signature-file test added with the hotUpdate work, was not
running there at all. Added as its own step in both workflows.

Also adds a top-level `ci` script that runs lint, formatting and tests in
parallel, for use before committing.

The roadmap's claim that `.fsx` files are matched but never compiled was
wrong and is dropped rather than acted on: the daemon filters only `.fsi`,
so a script listed in the fsproj compiles like any other file, and one
that is not listed fails exactly as a stray `.fs` would.
The MSBuild configuration was derived from env.MODE, which only equals
"production" for the default build mode. Any custom mode compiled the F#
in Debug and then bundled it as production output — `vite build --mode
staging` shipped a Debug build with no sign anything was off.

It now follows the command, which was already being captured on the very
next line for isBuild. A `configuration` option overrides it for the case
where you do want the other one, such as a production bundle that keeps
assertions.

configuration is typed "Debug" | "Release" rather than string, in the
plugin state and in the daemon request, so a typo cannot reach MSBuild.

Also corrects the fsproj recipe, which still said the plugin looks for a
project next to vite.config.js. Discovery moved to the Vite root when
strict mode surfaced that configFile is optional, and the two differ
whenever root is set.
noReflection and exclude both change what Fable emits, but neither was
part of the design time build cache key. Setting either left the cached
build valid, so the previous output was reused and stale JavaScript was
served with nothing to indicate it — the kind of thing people work around
by deleting obj/ and never find out about. Both are now in the key, with
their own invalidation reasons so the log names the option that changed.

The cached record also carries a format version. Protobuf deserialises
fields that did not exist yet as their defaults, so a cache written before
this change would report noReflection = false and an empty exclude, which
falsely matches anyone running the defaults and leaves the same bug in
place. The version makes those caches invalid once instead.

Options themselves were never validated: they went through Object.assign
and anything unrecognised was merged in and ignored. In a vite.config.js
that is invisible, so a misspelled option looked like it had no effect.
resolveOptions now owns the defaults and rejects unknown or badly typed
keys, suggesting the intended one where it can.

Vite validates its own config by hand and pulls in no schema library, so
this does the same rather than adding a runtime dependency.

Type declarations shipped already but were half useful: PluginOptions was
never re-exported, so a vite.config.ts could not name what it was passing.
It and FableConfiguration are exported now, behind an exports map, and
stripInternal keeps the test-only seam out of the published surface.

Documents every option, including noReflection and exclude, which had
never been mentioned anywhere despite working.
`transform.filter` was a bare `/\.(fs|fsx)$/`, so any id with a query
never reached the handler and the raw F# went on to the JavaScript
parser. Widen the filter with `makeIdFiltersToMatchWithQuery`, the
convention Vite's own plugins follow, and drop the query before looking
the id up in `compilableFiles`, which is keyed by file path.

`?raw` and `?url` are excluded. Those ask for the file rather than the
module it compiles to, and `vite:asset` has already answered them by the
time a pre transform runs; compiling over that answer would replace the
F# source string with the compiled module.
The mailbox loop replied from inside each message arm, so an exception
that escaped one killed the agent with the request unanswered.
`PostAndAsyncReply` has no timeout, so the plugin then waited inside
`buildStart` forever — no URL, no overlay, nothing on screen — and every
later request queued behind a loop that was gone. `mailbox.Error` was
subscribed with `fun _ -> ()`, so none of it was reported.

Serving a message is now a separate function the loop wraps in a
try/with: the pending reply channel gets the message's own `Error` case
and the loop carries on with the unchanged model. Recursion happens
outside that try, so a long-lived agent does not stack one exception
handler per message served.

Failures are also made visible. The daemon logs to stderr as well as its
`ILogger`, which is a `NullLogger` unless VITE_PLUGIN_FABLE_DEBUG is set,
and stdout is reserved for the JSON-RPC framing; the plugin already
forwards stderr to the Vite logger. If the agent itself dies the model is
gone and nothing can be served again, so the process exits instead of
accepting requests it will never answer.

Also collapse `dotnet_msbuild_with_defines` into `dotnet_msbuild`.
Nothing ever passed a non-empty `defines`, so the `DefineConstants`
branch was unreachable.
Vite awaits `buildStart` before `httpServer.listen`, so cracking the
project there kept the dev server off its port for the whole first
compile: no URL printed, no overlay, nothing to look at while Fable ran.

The daemon now starts in `configureServer`, which runs earlier and whose
result nothing awaits, and the wait moves to `transform`. That is per
request and already filtered to `.fs`, so the server boots at once and
only a request for F# blocks. `hotUpdate` waits too — an edit landing
before the first crack would otherwise be dropped as "not ours", since
the project's file list is still empty. `vite build` keeps cracking in
`buildStart`, where blocking is what you want.

A failed crack is now reported from `transform` instead of logged as a
warning, so the reason reaches the browser overlay rather than only the
terminal, and Vite no longer hands raw F# to its JavaScript parser.

Dev has no plugin context to call `this.addWatchFile` on, so watching
goes through the server's watcher, and only for files outside the root —
the same rule `ensureWatchedFile` applies, since the dev watcher already
covers everything under it.

The test harness follows Vite's real hook order, and `boot` returns
without waiting for the first compile so a test can observe what the
plugin does while the daemon is still cracking.
The plugin transformed JSX and left Fast Refresh to chance. Refresh only
survived because Vite's oxc pass sniffs the emitted code for a
`react/jsx-runtime` import, which the plugin's own transform happened to
inject. Nothing said so, and nothing said what to do when it did not
hold.

Ownership of the JSX transform is not a preference: `vite:oxc` forces
`lang: "js"` for any id whose extension is not a JavaScript one, which
disables JSX parsing, so JSX inside a `.fs` module is a parse error
there. The plugin has to finish the job, and now says why in a comment.
It also deliberately does not ask for `refresh`, because `vite:oxc` runs
over the output afterwards and applies it — asking here as well registers
every component twice.

Drop `jsx: "preserve"`. It cannot produce an importable module in any
configuration: with plugin-react present the module fails to parse, and
without it Vite's import analysis rejects it. It now fails when the
config loads instead of as a 500 on first request.

Warn when `.fs` components will not Fast Refresh. The check reads the
resolved `oxc.jsxRefreshInclude`, which plugin-react sets from `include`
whether or not the React Compiler is on, rather than the `refresh` flag,
which is also false during a build and when `compiler: true` hands
refresh to `vite:react-compiler`.

Documentation, verified against a running fsdocs server:

- `react({ compiler: true })` works on Fable output and is the more
  robust setup, since it applies refresh explicitly rather than by sniff.
- Migrate the theme to `light-dark()`. fsdocs only sets `data-theme` once
  the toggle has written to localStorage, so overriding colours under
  `[data-theme="dark"]` left a pale header on a dark page for anyone
  arriving in OS dark mode. Panels now derive from `--panel-background`,
  added in fsdocs 22.0.1.
- Style tables, which fsdocs ships no CSS for at all. Scoped away from
  `table.pre`, which is how fsdocs renders code snippets.
- Work around three fsdocs markdown quirks: `\|` is never unescaped in a
  table cell, `**` around a span starting with a code span does not
  become `<strong>`, and an unlabelled fence is parsed as F#.
- Fix in-page links. fsdocs keeps the heading's case and replaces
  non-alphanumerics with `-`, so GitHub-style lowercase anchors never
  resolved.

Add a `docs` script running `fsdocs watch` with the same flags CI builds
with.
A plain `vite dev` on the five-file sample printed 26 `[fable]` lines
before the page loaded, and none of it could be turned off: `logDebug`
called `logger.info` with a dimmed colour, so "debug" output reached the
user exactly like everything else. Paths were absolute where Vite prints
them relative, `transform` logged a line per file on every request, and
the wording read like a progress trace ("about to type-checked",
"dependent file X changed." during a crack where nothing had changed).

The default is now one line per compile, naming the project or the files
and how long it took, alongside diagnostics, errors and warnings. Paths
are relative to the Vite root, and stay absolute only when they fall
outside it, where a run of `../..` would be worse.

`debug` brings the detail back — every hook, every file transformed,
where `fable-library` resolved, the cracking and type-checking timings,
and the daemon's own output. It is a plugin option so it can be turned on
in a config and committed, rather than remembered as an env var at the
call site. `VITE_PLUGIN_FABLE_DEBUG` still works and does one thing more:
it starts the daemon's log viewer, which lives in the compiler process
and so is beyond the option's reach.

Also fold `logCritical` into `logError`, which it duplicated byte for
byte, and move the log-viewer banner off `console.log` onto Vite's
logger, where it gets a prefix and a timestamp instead of printing at
import time.

Finally, stop telling people to check their .NET SDK when the daemon
crashes mid-session. That advice fits a daemon that never started; a
process that dies later is a different bug, and now says so.
Every edit to a `.fs` file was compiled twice. `handleHMRUpdate` takes a
single timestamp per file change and then calls `hotUpdate` for every
environment in `server.environments`, which in dev means `client` and
`ssr` even for a project that never renders on the server.

The coalescing window cannot help: the calls arrive one after another,
so the first compile has already finished by the time the second one
lands. Widening the window would mean holding every edit for the length
of a full compile.

Keying the in-flight compile on the timestamp Vite already computed
makes one filesystem change mean one compile. Each environment still
resolves the resulting files against its own module graph, so this
deduplicates the work rather than skipping the non-client environments,
and SSR keeps behaving as it should.

Documentation had drifted from the code:

- `how.md` claimed config resolution does the initial compile and that
  `handleHotUpdate` alerts the browser. Neither is true. It now covers
  why the daemon starts in `configureServer` without being awaited, why
  `vite build` blocks instead, and what happens on an edit, including
  the batching and the per-environment fan-out above.
- The `<script type="module" src>` limitation still holds, but for a
  reason worth stating: `isJSRequest` tests a fixed extension list that
  excludes `.fs`, so a bare request never reaches the transform
  pipeline, while an `import` gets `?import` appended and does.
- "Transpiled F# files are not written to disk" was only half true.
  Your own sources are held in memory; compiled `fable_modules` output
  is cached under `obj/`.
- `getting-started.md` still showed the ten-line startup log the plugin
  no longer prints, and `debug.md` did not mention the `debug` option.
Deduplicating the compile for a file change kept a single entry, on the
assumption that Vite finishes handing a change to every environment before
the next change arrives. It does not: the watcher calls onFileChange without
awaiting it, so saving two files at once interleaves their fan-outs and the
second change overwrites the first before its remaining environments ask for
it. Those then queued a compile of their own.

Two files saved together compiled three times instead of once. Keep the
in-flight changes in a map, capped so it stays a dedup window rather than a
cache. Entries cannot be dropped when their compile settles: an environment
only asks after the previous one's hotUpdate returned, which is already after
the compile it awaited.
The plugin answered for a .fs file from transform, which meant Vite read the
whole file off disk first so that transform could throw the contents away.
A load answers from the compiled output directly, and states what the module
is: rolldown otherwise infers a module type from the .fs extension, since
vite:oxc only declares it for ids plugin-react claims.

An F# file Fable did not compile is now an error in dev as well as in a
build. Warning and returning nothing left Vite to read the file and hand the
F# to the JavaScript parser, so the page broke anyway, just with a syntax
error pointing at "module Foo" rather than at the file missing from the
fsproj.

Fable's File remembers the hash of the source it read so a later read can be
skipped. The daemon built a fresh set for every compile and threw that away,
so every file in the project, fable_modules included, was read and hashed
again on every edit. It now holds on to them and forgets only the files the
plugin reports as changed, which is safe because Vite watches the whole root
and the plugin adds the sources outside it to the watcher itself. A re-crack
forgets everything, since which files the project has is decided there. The
cache is concurrent because the checker type-checks in parallel and calls the
reader from several threads.

The Inspect report no longer has a __load__ step holding the F# source, so
the notes that describe reading it were updated.
The design time build cache key took its list of dependent files from
MSBuildAllProjects. Since MSBuild 16.9 an import no longer adds itself to
that property, so the list was the fsproj plus a handful of SDK targets and
nothing else: editing a Directory.Build.props neither invalidated the cache
nor re-cracked the project, because nothing knew the file existed. The
convention imports are now asked for by name instead, and the packages props
only when central package management is actually on, so watching it cannot
re-crack for an edit that changes nothing.

The keys themselves were built once per daemon lifetime. Which files an
evaluation depends on is decided by the evaluation, so a key kept across
cracks describes the project as it was, and a file added to the build was
never noticed. They are now forgotten at the start of every crack, which
still leaves them memoised for the length of one, where the same project can
be visited more than once.

A dev server calls watchChange on top of calling hotUpdate for every
environment, so one touch of an fsproj arrived three times and cracked the
project three times over, each one a full design time build. Deduplicating a
change across environments already existed for source files; re-cracks now go
through the same window, and watchChange defers to hotUpdate in dev, where it
is the better of the two because it can reload the browser afterwards.

Reading msbuild's stdout to the end before touching stderr can deadlock: a
child that fills the other pipe blocks writing while this side blocks
reading, and nothing between the daemon and the plugin times out. Both pipes
are now drained together. A non-empty stderr also no longer fails the call,
since msbuild and NuGet write warnings there on runs that succeed, and a real
failure is reported on stdout with the exit code set, which is why the old
message quoted stderr and named no reason at all.
The roadmap had grown to include descriptions of work that has already
landed: the crossws patch and why it is needed, the Inspect setup, the
reasons an item could not be fixed locally before it was filed upstream.
That reads as a plan but is really a history, and history belongs in the
changelog. What remains is open work, one unanswered question, and the
decisions recorded so they do not get re-litigated. Items are renumbered so
the list no longer has holes where finished ones used to be.

Nothing outside the file points at it any more, because it will be deleted
once its items are gone. A comment that says "see roadmap item N" is worse
than no comment once that is true, and the numbers shift on every renumber
anyway, so daemon.ts now states the hazard it was pointing at.

The NoCache question is answered rather than left open. Upstream the flag
only decides whether Fable's own CacheInfo is read and written, and whether
fable_modules is deleted wholesale, and that delete is guarded by the
evaluateOnly argument the daemon passes. Nothing else reads it, so turning
Fable's caching off in favour of the daemon's own costs no correctness. That
argument is now named at the call site rather than passed as a bare true,
since deleting a user's fable_modules is a lot to hang on an unlabelled
boolean.
Fable restores the sources of a project's package dependencies into
fable_modules and compiles them alongside the project's own files, so
their diagnostics arrived mixed in with the ones the user can act on.
Nobody using the plugin wrote that code or can edit it, which makes the
noise unactionable.

The new fableModulesDiagnostics option reports them again, as a
debugging aid for when a package itself looks broken. It covers errors
too, so with the option off a package that fails to compile no longer
fails the build: nothing is printed and vite build exits 0. That is a
deliberate trade, documented on the option, in the recipes and in the
test that pins it.

The filter sits where diagnostics enter the plugin, so the crack's
response, the compile's response, the build failure and the HMR overlay
all see the same set.
The daemon holds the only copy of what it cracked, compiled and cached,
and none of it could be read. Emitted JavaScript meant running a build
and reading .vite-inspect, diagnostics existed only as terminal lines,
and the design time build cache decision was logged and thrown away.
The one thing that did serve it, the log viewer, serves HTML.

The debug server now answers JSON under /api: status, project, files
(including the JavaScript emitted for one file), diagnostics unfiltered,
the cache decision with the input that invalidated it, the last hundred
JSON-RPC requests, and the log with a resume cursor. The log viewer page
is unchanged.

It is read-only on purpose. The daemon only knows which files changed
because the plugin tells it, so a second writer would break the promise
that keeps SourceFileCache correct. It also never asks the message loop:
that loop publishes an immutable snapshot after each message it serves
and the endpoints read that, so a request cannot queue behind a compile.
Every response carries a revision that increments per served message,
which is how a caller tells whether it is looking at its own edit.

The debug plugin option starts it too. It used to be plugin-side only,
so reaching the daemon's own output needed VITE_PLUGIN_FABLE_DEBUG as
well. VITE_PLUGIN_FABLE_DEBUG_PORT moves it off 9014, and a running
daemon announces itself in a temp file that is swept of daemons whose
process is gone.

Roadmap item 2 goes on hold rather than proceeding: it proposed deleting
this server in favour of a DevTools panel, and a browser panel cannot
serve any of the above. A panel is still wanted, but it now has to earn
its keep on its own terms.
postinstall ran dotnet publish on the consumer's machine, and package
managers increasingly refuse to run lifecycle scripts by default. When
it was skipped the install still succeeded, no bin/ was produced, and
the failure surfaced much later as a confusing buildStart error.

The package now carries a framework-dependent publish of Fable.Daemon,
which is portable IL and runs anywhere the .NET runtime does, so there
is nothing to trust and nothing to compile at install time. The .NET
SDK is still required: reading an fsproj means asking dotnet msbuild
about it. What ships is no longer ReadyToRun, which costs roughly 0.7s
of JIT per dev server start and per build; roadmap item 4 keeps the
measurements and the per-RID option that would win it back.

Two things had to move for prebuilt bits to work at all. The debug
assets were never copied to the publish output, and Debug.fs resolved
them through __SOURCE_DIRECTORY__, which is the folder the daemon was
compiled in. Both were correct only while every consumer compiled it
themselves; shipping prebuilt would have baked in a stranger's home
directory. They now travel with the assembly and are found next to it.

Verified by packing the tarball and installing it with --ignore-scripts
into a project with nothing from this repo in it: the build succeeds and
the debug server serves the installed copy of its assets.

cracking.fsx keeps shipping, and works against the bundled bin/ without
anything being built first. Its hard-coded fable-library path had been
wrong in this repo since the move to bun isolated installs, unnoticed
because cracking is an MSBuild evaluation that never reads it.

Note for whoever publishes: the tarball has to be built by bun. The
manifest depends on the workspace catalog, npm pack leaves "catalog:"
in place, and nothing can install the result.
The plugin spawns a child process, normalises paths and shells out to
MSBuild, none of which behaves the same everywhere, and all of it was
only ever exercised on Linux. The tests now run on all three, with
fail-fast off so a Windows-only failure is not hidden by whichever job
went red first. Formatting, linting and the docs build stay on one
runner: they say the same thing on every OS, and fmt:check has line
endings to lose.

Two tests could not have passed on Windows. The debug server test got a
pid that is not running by spawning /bin/sh to exit, which needs a shell
that differs per OS; it now reads the process table and picks a number
that is not in it, which also avoids pid 0 reporting itself alive on
macOS. The plugin tests built every path as `${sampleProject}/Math.fs`,
and on a Windows path.resolve that is a mixed-separator string matching
nothing the plugin had normalised. Normalising the root is also more
faithful, since Vite hands the plugin posix ids.
@nojaf
nojaf marked this pull request as ready for review August 29, 2026 19:37
@nojaf nojaf changed the title Convert the Vite plugin to TypeScript A whole lot of changes Aug 29, 2026
@nojaf
nojaf merged commit bfe1436 into main Aug 29, 2026
4 checks passed
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