zfb

Type to search...

to open search from anywhere

Dev mode lifecycle

CreatedJun 1, 2026Takeshi Takatsudo

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/zfb/src/commands/dev.rs 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:

  1. 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 dev started. Each rendered RenderedPage.html is 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_renderer is None in dev today (see crates/zfb/src/commands/dev.rs), so the renderer stays bound to the boot-time bundle for the whole session.
  2. CSS pipeline. When rerun_css is true, Tailwind v4 + PostCSS run. If the CSS output is byte-identical to the previous tick, no Css event is emitted.
  3. Islands re-bundle. When rerun_islands is true, the esbuild Go binary subprocess is invoked. It bundles every "use client" component and writes a single combined module to a stable filenamedist/assets/islands.js (the STABLE_ISLANDS_FILENAME constant in crates/zfb-types/src/asset_urls.rs, re-exported and consumed by the islands bundler). No content hash in the filename; see Why filenames stay stable in dev.
  4. Build outcome broadcast. The pipeline returns a BuildOutcome struct. outcome_to_events() in crates/zfb-server/src/livereload.rs inspects the outcome and maps it to ReloadEvent values that are broadcast over the SSE channel at /__zfb/reload. Every browser tab that has your site open is subscribed to that channel and reacts immediately.

The three SSE event types

Outcome triggerEventBrowser behaviour
pages_written.len() > 0PageFull location.reload()
css_changedCssHot-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 (/assets/islands.js). The client script at /__zfb/livereload.js 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 (/assets/islands-abc12345.js) 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/assets/islands.js — 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.

  • Build pipeline — the full pipeline from CLI to dist/, and how dev mode layers on top of it
  • Incremental rebuild — the dependency graph and DirtySet that 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 = false routes in dev

Revision History