Readiness: Implemented
The toolchain
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.
What you will be able to do: run the dev server and the production build, know what uf hands Vite and what it leaves alone, work through a hydration failure, and reach past uf to Vite when you need to.
What you need first: a project with its dependencies installed — Your first project, then Dependencies.
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. Editing a Flow file re-transforms
only that module, and the edit reaches the page as a render rather than a
reload: the counter you clicked, the form you half filled and the dialog you
opened are still there. Hot updates lists what each kind of
edit does.
It serves the application the same way a deployment does: one middleware runs
your $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
$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. Underapp.router.basePaththe post includes that prefix. 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 — a page React DevTools cannot attach to is the second, and Where each boundary begins and ends below is the third.
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.
Where each boundary begins and ends
A $loading.js and a $error.js are decisions about part of the page,
and the part is the one thing you cannot see. A <Suspense> renders no element
of its own and neither does an error boundary, so what one of them owns is a run
of nodes among the layout's own — a <nav> before it, a <footer> after it —
with nothing on the page to tell them apart. Reading the directory tells you a
boundary exists; it does not tell you which rectangle goes away when it fires.
So under uf dev the router marks what it renders. Every boundary's children
sit between a pair of <span hidden data-uf-boundary> elements, which carry no
content and are ordinary siblings of the nodes between them:
<div id="uf-root">
<nav class="masthead">…</nav>
<span hidden data-uf-boundary="suspense:0" data-uf-boundary-edge="open"></span>
<article class="post">…</article>
<span hidden data-uf-boundary="suspense:0" data-uf-boundary-edge="close"></span>
<footer class="colophon">…</footer>
</div>
That is enough to answer the question in the inspector you already have —
document.querySelectorAll("[data-uf-boundary]"), or one CSS rule of your own
to outline them — and it is enough for uf to answer it in words. When the same
route's boundaries change, which is the moment you added the file, your
terminal says where it landed:
› 3 boundaries render /docs/:slug
error (uf's own error page), outside every layout — owns div#uf-root
error (app/docs/$error.js), inside 1 layout — owns article.doc, aside.toc
suspense, inside 2 layouts — showing its fallback
It is quiet otherwise: a boundary map printed on every reload is a banner
nobody reads. For the page in front of you right now, __ufBoundaries() in the
console prints the same report and returns it.
The marks are added after the page has hydrated, never before, so the markup the
server sent and the tree React checks it against contain none of them — this
cannot cause a mismatch. And none of it is in a build: the whole thing is behind
import.meta.hot, which is undefined in a bundle, so a visitor downloads no
part of it.
The third boundary — client versus server — is answered in the same terminal and
from the other end: when a save moves a module across the client bundle, uf dev names the module and the chain of imports that put it there. That one comes
out of the analysis rather than out of the page, so it needs no mark.
What actually streamed
A $loading.js is a promise about time: the shell goes out now and this
part of the page follows when it is ready. Whether it kept that promise is the
one thing a browser makes hard to see — the fallback may have flashed past in
40 ms, or never rendered at all because the page resolved first, and the
finished document looks identical either way.
So uf dev says what left. When a route streams, your terminal names each
boundary that arrived after the shell, how long after, how big it was, and which
part of the tree it built:
› /docs/:slug streamed 2 boundaries after its shell, the last 812 ms in
the shell went out in 4.1 kB, with 2 fallbacks in it
+119 ms B:0 replaced p.skeleton with article.post (chunk 2 of 5, 1.4 kB)
+812 ms B:1 replaced div.toc-loading with nav.toc (chunk 4 of 5, 900 B)
p.skeleton is what your $loading.js rendered and article.post is what
replaced it, both read off the wire rather than out of the route table. B:0 is
React's own name for the boundary, printed rather than translated: React numbers
boundaries as it meets them, uf numbers a route's fallbacks, and a <Suspense> a
component wrote itself is in the first list and not the second — so the two
cannot honestly be joined.
Like the boundary map, it is quiet: it speaks the first time a route streams and whenever the shape of that stream changes — a fallback added, a boundary that stopped streaming — and says nothing when only the milliseconds moved, which would be every reload. A page with nothing suspended in it says nothing at all, because there is no order to report about a document that arrived whole.
This is the HTML half of what comes off the wire. The same document carries the Flight payload its tree was rendered from, and the browser hydrates that payload rather than rendering the route's modules again — Server Components has the rest.
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.
Hot updates
uf dev is Vite's hot module replacement, with the pieces a router needs on
top. An edit is sent to the smallest thing that can take it, and the page is
reloaded only when nothing can:
| You edit | What happens |
|---|---|
| A component | React Fast Refresh swaps it in place. Hook state survives unless the hooks it calls changed. |
A route module's loader, metadata or other export | The component half is refreshed, and the router runs the route again with the new loader. State on the page survives. |
| A server component (React Server Components) | The router fetches the payload for the URL on screen again, as router.refresh() does. Client components under it keep their state. |
| A route file added or removed | The route table is replaced in place and the URL on screen is rendered against it. A new page is reachable by a Link straight away. |
| A stylesheet | Vite swaps it without touching the document. |
| A module nothing accepts | The update climbs to the modules that import it, and reloads the page only if it reaches the entry. |
.env | The server restarts; see Environment. |
| A server action added or removed | The page reloads, because the references the browser's modules were compiled with changed. |
A render that cannot happen in place — no router is mounted, or the refresh threw — falls back to a reload, and the console says why before it goes.
The server side announces the renders it asks for as a Vite custom event,
uf:refresh, so code that is not a React component can take part too. A store
that caches server data can drop it when the server's code changes:
import.meta.hot?.on("uf:refresh", () => cache.clear());
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.
--analyze answers the question that comes next, which is why. For every route
it lists each module the route ships to the browser and runs on the server,
what each weighs, and the chain of imports that put it there, such as
app/chart/$page.js → app/chart/Plot.js → node_modules/chart.js. It writes
.uf/build/meta/uf-bundle-analysis.html, a page you open from disk, and the
same analysis as JSON beside it. The command reference has
the schema.
--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.
Where to go next
Testing is next: uf test, which runs what this page builds.
Edit this pagedocs/app/guide/dev/$page.mdx