Dev mode lifecycle
What happens between hitting save on a .tsx file and seeing the change in the browser — watcher, rebundle, SSR refresh, and the three SSE event types.
ℹ️ What this page covers
The save-to-pixel loop for zfb dev: how the watcher detects your change, how the
orchestrator decides what to rebuild, and how three SSE event types let the browser
react without an unnecessary full page reload. For the overall build step order read
Build pipeline; for how the dependency graph limits
rebuilds to affected pages read Incremental rebuild.
This page describes the SSG / static-HTML dev loop only. For prerender = false
SSR routes, source edits during a zfb dev session do not hot-reload — the V8
SSR bundle is bound to the boot-time JS bundle and is restart-only. The per-tick
rebuild described below does not reload the SSR renderer; it re-renders the
SSG output that the renderer was bootstrapped with. See
No live reload of the SSR bundle
in the SSR guide.
You save a .tsx file. Then what?
The moment you save, the operating system fires a filesystem event on the file’s
path. crates/zfb-watcher is watching every relevant directory — pages/,
components/, content/, layouts/, styles/, data/, public/, and the two
config files zfb.config.json and zfb.config.ts — via the
<code>notify</code> crate. (See DEFAULT_WATCH_ROOTS in
crates/ for the canonical list.)
Editor saves are rarely a single clean event. vim renames a swap file over the
original; VS Code emits several metadata-then-data events in quick succession; a
git checkout fires hundreds at once. The watcher’s debouncer coalesces everything
within a 50ms quiet window into a single Change { path, kind } value. The kind
field is one of ChangeKind::Created, ChangeKind::Modified, or
ChangeKind::Removed; any event kind the OS cannot classify collapses to Modified
so that a real change is never silently dropped.
Once the burst settles and the debounce window closes, the build orchestrator
(crates/zfb-build) receives the change. It calls into crates/zfb-graph with the
changed path and gets back a DirtySet — either All (global files like
zfb.config.ts) or Specific(set_of_page_ids) for the pages that actually import
the changed file. Pages outside the dirty set are not touched. (The full dependency
tracking story lives in Incremental rebuild.)
The orchestrator assembles a RebuildPlan from the dirty set. If the changed path
is inside an islands root (e.g. components/), the plan’s rerun_islands flag is
set. If a CSS source changed, rerun_css is set. Then the
DevAssetPipeline::apply() method runs, in this order:
- Pages re-rendered. The orchestrator calls the renderer for each page in
the dirty set, driving SSG output through the embedded V8 host that was booted
when
zfb devstarted. Each renderedRenderedPage.htmlis compared byte-for-byte against the last-known output. If the bytes are identical (a pure refactor that produced no semantic HTML change), no file is written and no reload signal is sent for that page. The V8 host itself is not reloaded per tick —BuildContext::reload_rendererisNonein dev today (seecrates/), so the renderer stays bound to the boot-time bundle for the whole session.zfb/ src/ commands/ dev. rs - CSS pipeline. When
rerun_cssis true, Tailwind v4 + PostCSS run. If the CSS output is byte-identical to the previous tick, noCssevent is emitted. - Islands re-bundle. When
rerun_islandsis true, the esbuild Go binary subprocess is invoked. It bundles every"use client"component and writes a single combined module to a stable filename —dist/(theassets/ islands. js STABLE_ISLANDS_FILENAMEconstant incrates/, re-exported and consumed by the islands bundler). No content hash in the filename; see Why filenames stay stable in dev.zfb- types/ src/ asset_ urls. rs - Build outcome broadcast. The pipeline returns a
BuildOutcomestruct.outcome_to_events()incrates/inspects the outcome and maps it tozfb- server/ src/ livereload. rs ReloadEventvalues that are broadcast over the SSE channel at/. Every browser tab that has your site open is subscribed to that channel and reacts immediately._ _ zfb/ reload
The three SSE event types
| Outcome trigger | Event | Browser behaviour |
|---|---|---|
pages_written.len() > 0 | Page | Full location.reload() |
css_changed | Css | Hot-swap every <link rel="stylesheet"> — appends ?v=<timestamp> to bust the browser cache without reloading the document |
islands_bundle.is_some() | Islands { component, bundle_url } | Dynamic import() of the new bundle URL (with a cache-busting ?v=<timestamp>). The newly-imported module runs its hydration, which by default re-mounts every [data-zfb-island] element on the current page — no document reload |
When multiple events fire in the same tick, the server emits every applicable event,
and every connected tab is subscribed to the same SSE channel and receives all of
them. The browser processes events in arrival order. On receiving a Page event,
each tab calls location.reload(), which discards the current document — this
makes any Css or Islands events emitted in the same tick moot for that tab. The
“only this tab gets a full reload” pattern does not exist here; the in-place
Css and Islands swaps are moot for every subscribed tab in that tick, not
just the active one.
One detail on Islands: in dev mode today, BuildContext::run_islands reports
components: Vec::new() to outcome_to_events() because the build-side payload
does not currently surface per-island names. The server therefore emits a
single Islands event with component: "" and the stable bundle URL
(/). The client script at / reads only
bundleUrl, appends a fresh timestamp (?v=<timestamp>), and dynamic-imports the
result. The imported bundle’s top-level hydration code then runs and walks every
[data-zfb-island] on the page — so a single islands tick re-hydrates the whole
page, not a single component, by default.
Targeted re-hydration is opt-in. When the client detects a user-provided
window.__zfbIslandsReload(component, swapUrl) function, it delegates the
import to that hook instead of doing a plain dynamic import. Applications that
want to preserve scroll position or component state across an islands hot-swap
install this hook and decide for themselves which components to remount. Without
the hook, the default page-wide re-hydration is what runs.
Why filenames stay stable in dev
Production builds use content-hashed asset URLs (/) so
that deployed CDN responses can be cached indefinitely and a new deploy’s changed
assets get fresh URLs. The hash is determined by file content; it changes every time
the bundle changes.
Dev mode deliberately skips the content hash. The output lands at
dist/ — the same URL every tick. This is the URL-contract
guarantee that makes SSE-driven hot-swap work: when the browser receives an
Islands event, it knows the new bundle is always reachable at the same base URL.
Changing the URL on every rebuild would force a full page reload because the browser
would have no cached reference to swap from.
Content hashing is the production pipeline’s responsibility. In dev the stable name is correct by design, not an oversight.
What this means in practice
Three typical edit scenarios, and which event fires:
Edit a .tsx island component body only. The watcher fires on the component
file. The dependency graph marks pages that consume it dirty; the orchestrator
re-renders affected pages first, then re-bundles islands. If the rendered HTML
changed, a Page event fires and the browser reloads. If the HTML is
byte-identical (e.g. you only changed client-side state logic that never reaches
the server render), only an Islands event fires — the new bundle is
dynamic-imported and its hydration runs, re-mounting every island on the page. To
preserve scroll position or per-component state across that swap, install a
window.__zfbIslandsReload hook (see The three SSE event types).
Edit a page file that consumes an island. Both page and island are dirty. The
orchestrator re-renders the page; the HTML almost certainly changed; a Page event
fires. The browser gets a fresh full-page load with up-to-date server-rendered HTML.
Any concurrent Islands events for that tab are mooted by the reload.
Edit a CSS file only. No pages are dirty, no islands re-bundle. The CSS pipeline
runs; if the output changed, a Css event fires. The browser swaps the stylesheet
in place — no document reload, your scroll position and any client-side state are
preserved.
Related
- Build pipeline — the full pipeline from CLI to
dist/, and how dev mode layers on top of it - Incremental rebuild — the dependency graph and
DirtySetthat limit rebuilds to affected pages - Islands — how
"use client"opts a component into the islands bundle - SSR and Cloudflare Bindings — including
the restart-only limitation for
prerender = falseroutes in dev