The tools
Dev and build
Both are Vite. uf decides what Vite is handed, starts it on a JavaScript host, and drives it over a protocol — it does not fork it, wrap it or reimplement it.
How it fits together
uf dev Rust: reads uf.config.js, resolves the host,
│ builds the Vite config, owns the terminal
▼
node/bun/deno @uniflowed/vite's driver: starts Vite, reports
│ back one JSON event per line
▼
Vite 8 dev server, HMR, plugin pipeline, rollup build
│
▼
uf transform one long-lived process; every Flow module goes
through it
Two things follow from this shape. Vite's plugins keep working, because it is
really Vite. And the Flow transform is not a plugin that shells out per file —
@uniflowed/vite spawns uf transform once and pipes every module through it,
so the process start-up cost is paid once rather than thousands of times.
uf dev
uf devBinds loopback on the port from dev.port. React Fast Refresh works through
component declarations. Editing a Flow file re-transforms only that module.
It serves the application the same way a deployment does: one middleware runs
your _uf.middleware.js, then a server action, then a route handler, then the
renderer — for every method and whatever the client said it would accept. So a
_uf.route.js answers a browser here exactly as it does under uf start, and a
redirect() from a loader is a 307 with a Location rather than a document
that happens to redirect itself.
Editing a .env file restarts the server with the new values, and says which
file changed. Environment says why that is a restart rather than
an update.
Strict Mode, and React DevTools
uf dev hydrates your application inside React's <StrictMode>. uf build
does not, so nothing a visitor runs is doubled — this is a check that runs on
your machine and nowhere else.
What it buys is the two mistakes nothing else can see. A component whose render is not pure is rendered twice, so it disagrees with itself while you are looking at it rather than in the one production render that interleaved with something. And every effect that mounts after the first paint — a navigation, a branch that opened, a row that appeared — is set up, torn down and set up again, so an effect whose cleanup does not undo its setup leaks immediately instead of after an hour of use.
One thing to know, because the sentence everybody has heard is not quite true here: React skips the mount/unmount/mount pass for the root that hydrated, since the DOM it would tear down is the markup your server just sent. The render is still doubled on that first load; the effect check starts with the next thing that mounts.
If you would rather not:
export default defineConfig({
app: { react: { strictMode: false } },
});
There is no separate Strict Mode overlay to go and look at, and that is on purpose. What the doubled render usually produces on a page uf server-rendered is a hydration mismatch, which already goes to your terminal — When hydration fails below is what it prints. Anything else it produces is an ordinary React error, and arrives wherever your errors already do.
React DevTools works here, and works on purpose rather than by accident: uf
installs the __REACT_DEVTOOLS_GLOBAL_HOOK__ React registers itself with before
any module in the document runs, keeps one copy of react-dom so there is one
renderer to show, and transforms every module in React's development
configuration so the panel has props, hooks and source positions to show you.
The extension's own hook is never replaced when it is there. None of it is in a
build.
And the page checks that it worked, because "it worked when I tried it" is how
this breaks quietly. After hydration uf reads the hook back: if nothing
installed it, or if two copies of react-dom each registered a renderer so the
panel can only show one of the two trees, the reason is printed in your terminal
with what to do about it. A page where DevTools is fine says nothing.
What the browser can say back
Two paths under /__uf/ exist only while uf dev is running, and both of them
lead to your terminal:
/__uf/vitals— wherevitalsBeacon()posts by default, so the five numbers a page is judged on turn up beside the reload that caused them. See Web vitals./__uf/diagnostic— where a diagnostic only the browser can produce is posted. A hydration mismatch is the loudest one — When hydration fails below is what it says — and a page React DevTools cannot attach to is the other.
Both print with the severity, the page they came from and a code frame when there is a position to draw one around, which is the point of them: every other uf diagnostic arrives in the terminal you already have open, and one that lives only in a browser window has to be noticed by somebody who knows to look. Nothing on either path leaves the machine, and a built application has neither.
To reach the dev server from another device, --host binds a routable address —
but only if dev.allowedHosts in your config is non-empty. That is deliberate:
a dev server on 0.0.0.0 with no host allow-list is how a local project ends up
readable from the coffee shop's network. The threat model is written down in
docs/security.md.
When hydration fails
React reports a mismatch with one sentence and a list of six things that might have caused it. The list is a good list, and it is not an answer: the node that actually differed is still somewhere in a page of two thousand of them.
uf has both trees, so it says which one. @uniflowed/router's client entry
keeps a copy of the server's markup for the moment between the parser finishing
and hydrateRoot starting — the only moment it exists — and when React reports
a mismatch, an overlay shows the node, the two values and the component:
Hydration mismatch in <Posted>
at main:nth-child(1) > p:nth-child(2) > text()[0]
server 3 minutes ago
client 5 minutes ago
The two renders computed the same text from a value that is different every
time it is read — a clock, a random number, or a date formatted in a locale.
Decide the value once, above the tree, and pass it down, so both renders read
the same number instead of each working one out.
The same text goes to three places: the overlay, the console — so a headless
run sees it too — and the terminal you started uf dev in, through
/__uf/diagnostic above. One formatter writes all three, so they cannot drift
into describing the same failure differently. Three causes are named where the
difference gives them away — a value that moves, a reading only a browser can
make, and markup that cannot nest — and anything else is reported as unknown
rather than guessed at, because a wrong diagnosis costs more than none.
It is a development tool and it is not in your bundle. The overlay is imported
dynamically behind import.meta.hot, so a production build has no path to the
module at all.
Writing your own import.meta.hot
import.meta.hot is Vite's handle on hot module replacement while the dev
server is running, and undefined in a build — Vite replaces the expression
outright, so the branch behind it is statically dead. Reach through it with
optional chaining, or bind it to a name first:
import.meta.hot?.accept((next) => { /* … */ }); // or
const hot = import.meta.hot;
if (hot) hot.accept((next) => { /* … */ });
Not if (import.meta.hot) { import.meta.hot.accept(…) }, which every Vite
guide writes and uf check rejects. Flow keys a refinement by a lookup — an
identifier, this or super, plus a path of properties — and import.meta is
a meta-property, not an identifier, so testing it refines nothing and the body
of the if still sees ImportMetaHot | void. That is upstream Flow's
behaviour rather than a uf choice, and uf does not patch the Flow it vendors.
vite/hot-needs-optional-chaining reports the guard and says this, and
uf lint --fix-unsafe will insert the ?. for you.
uf build
uf buildBuilds the client bundle, builds the server bundle, then prerenders every route
that can be rendered without a request. What comes out is static HTML plus
hashed assets under dist/.
--size-report prints what each chunk cost, so a dependency that doubled the
bundle is visible in the same command that built it.
--compile adds one file to that output and it is the interesting one: an
executable that is the site. Everything above goes inside it — the documents,
the assets, the route handlers, the renderer — so deploying becomes copying one
file. There is no runtime to install beside it and no node_modules to restore:
$ uf build --compile
standalone
binary dist/docs
runtime bun
bytes 59.26 MB
embedded assets 67
embedded bytes 1.81 MB
$ scp dist/docs server:/srv/docs && ssh server /srv/docs --port 8080
uf: 67 embedded files
uf: listening on http://127.0.0.1:8080
Building one needs bun installed; running one needs nothing at all.
It is not the only way to serve a build, and it is the most self-contained of
three: uf preview checks a build through Vite's own server before you deploy
it, and uf start serves the same build over uf's server on a host that
already has a JavaScript runtime. All three answer identically — that is the
point of having more than one — and Commands says which to
reach for.
Reaching past uf
uf generates the Vite config; it does not hide it. A plugins array at the top
level of uf.config.js is appended to the plugins uf installs, and dev and
build are passed through to Vite's own options — so a Vite plugin, an alias or
a server option can be added without leaving uf:
// @flow
import { defineConfig } from "@uniflowed/config";
import svgr from "vite-plugin-svgr";
export default defineConfig({
app: { router: { entry: "app.js", root: "app" } },
plugins: [svgr()],
dev: { port: 4000 },
});
uf inspect --json prints what uf resolved, including the config it handed the
driver, which is the thing to look at when Vite is doing something you did not
ask for.