Deployment updates
Recover stale tabs when production deployments replace routes and hashed assets.
Compatibility
Production build checks
Use deploymentPlugin with watchDeployment for long-lived production tabs. The manifest contains a deterministic content version, a shared application fingerprint and dependency fingerprints for lazy file routes. Identical builds keep their identity. Development and server builds export null. Manual/eager route tables without granular metadata use conservative whole-build recovery.
import { defineConfig } from "vite";
import { deploymentPlugin, fileRouter } from "@kasperrt/smolter/react/vite";
export default defineConfig({
plugins: [fileRouter(), deploymentPlugin()],
});
// Preact uses the same exports from /preact/vite.
// With applicationPlugins(), add deploymentPlugin() beside it.Connect the browser router
Install before creating fetchers, or pass deployment to mountApplication for bootstrap coverage. Navigation and Back/Forward compare shared code and destination dependencies. Unrelated route changes preserve client navigation; incompatible or unknown destinations recover through document navigation. Routine checks run in the background: navigation and Back/Forward never await a manifest request. A discovered incompatibility applies on the next eligible navigation. A late response does not reload the successfully rendered page. Completed checks, including failures, are reused for 30 seconds (checkInterval); missing-chunk recovery bypasses that interval. Concurrent callers share a request. Hash-only/external links skip checks. Cleanup removes listeners. No polling runs.
import deployment from "virtual:smolter/deployment";
import { createDataRouter } from "@kasperrt/smolter/react";
import { watchDeployment } from "@kasperrt/smolter/react/client";
const router = createDataRouter(routes, { browser: true });
watchDeployment(router, deployment);
// Preact application integration:
const router = await mountApplication(routes, root, { deployment });Serve production documents and manifests fresh
Serve smolter-manifest.json from the same deployed release as its HTML and assets. Set Cache-Control: no-store on the manifest and no-cache on HTML; keep content-hashed assets immutable. Install this route before SPA/SSR fallbacks and generic static-file middleware. The browser also uses fetch cache: no-store. If your CDN ignores these headers, configure its cache rules explicitly. Keep old assets during rollout when possible: the guard cannot run if the initial JavaScript entry itself is missing.
app.get("/smolter-manifest.json", (_request, response) => {
response.set("Cache-Control", "no-store");
response.sendFile(path.resolve("dist/client/smolter-manifest.json"));
});
app.use("/assets", express.static("dist/client/assets", {
immutable: true,
maxAge: "1y",
}));Bases, CDNs and multiple applications
By default the public manifest URL uses Vite base. Set manifestURL explicitly for a relative base, a reverse proxy or multiple applications sharing a domain. Each application must read its own manifest. The emitted filename remains smolter-manifest.json; your server maps the public URL to that file. Cross-origin asset hosts must allow CORS for manifest reads. Packaged/offline applications can omit the plugin and helper.
deploymentPlugin({ manifestURL: "/admin/smolter-manifest.json" })Failures, forms and reload loops
Failed, invalid or timed-out checks leave navigation untouched. The default 2000 ms timeout bounds the background request, not navigation. The helper never submits or replays mutations. Vite chunk-load failures trigger another version check, but only a confirmed newer build permits recovery, and recovery waits until tracked submissions settle. A confirmed redirect-destination failure is reconsidered after its action and root refresh finish; a newer navigation, history operation or mutation invalidates that queued recovery. A sessionStorage marker permits at most one reload per old build and manifest URL. If storage is blocked or the same stale HTML survives a reload, normal errors remain visible instead of creating a reload loop. React/Preact applications retain the previous usable route while a chunk-recovery check is pending. Successful built-in document recovery keeps that view until navigation; failed, unchanged or unavailable recovery exposes the route error. A custom reload callback releases the error after returning because it may decline navigation. Bare routers without application view retention keep their ordinary error behavior. Unsaved in-memory state is lost on document reload; supply reload(destination) to implement your own confirmation policy. A deployment rolled out across inconsistent servers still requires a consistent host/CDN deployment policy. Idle intent-prefetch failures do not reload the current page or consume the recovery marker. Recovery waits for navigation to record a destination; overlapping checks honor the newest navigation. New history operations invalidate pending checks even when returning to the current route. Starting a mutation cancels earlier recovery checks. Redirect chunk recovery uses the committed router URL, including query and hash, rather than the URL of the original click.
watchDeployment(router, deployment && {
...deployment,
timeout: 1500,
reload: (destination) => {
if (confirm("A new version is available. Reload?")) {
location.assign(destination.href);
}
},
});Type the virtual module
Include this declaration in the application TypeScript project. No physical deployment module needs to be created.
declare module "virtual:smolter/deployment" {
const deployment: import("@kasperrt/smolter/preact/client").DeploymentOptions | null;
export default deployment;
}Fetcher mutation ownership
Install watchDeployment before creating fetchers; mountApplication does this automatically. Independent fetcher submissions invalidate older recovery checks. While any tracked fetcher submission is pending, document recovery is suppressed and its marker remains unused. Ordinary client navigation can continue. A confirmed chunk failure for the current destination can recover after all tracked submissions settle; ordinary successful navigation is never reloaded merely because a submission finishes. A later eligible navigation can also recover. Replacing a watcher preserves protection for its already tracked fetchers. When they settle, the current watcher can recover an eligible failed destination. Calling an old cleanup function cannot stop the replacement watcher. Fetchers created before the first watcher installation are not tracked.
Compatibility boundaries
Only dependencies visible to the browser build graph can be fingerprinted. Route CSS/assets and raw route files participate; independently deployed server dependencies, runtime configuration and external assets need a compatibility key change when incompatible. Preserve old hashed assets during rollouts. Missing chunks can require recovery even when route fingerprints match. New route code is never injected into an old runtime based only on a fingerprint.
deploymentPlugin({ compatibility: "server-contract-v2" });
watchDeployment(router, deployment && { ...deployment, checkInterval: 30_000 });When to change the compatibility key
Usually no key is needed: changes visible to the browser build already change its fingerprints. Use a stable contract identifier only for incompatible changes outside that graph, then rebuild and deploy the frontend and its manifest with the new key. For example, an independently deployed API changing { name } to { displayName } is invisible to an unchanged browser build. A new key makes old tabs eligible for recovery; the newly deployed frontend must already understand the new response. It does not repair incompatible code, detect backend deployments automatically, or replace coordinated rollout and backward compatibility. Do not change the key for ordinary data updates.
deploymentPlugin({ compatibility: "profile-contract-v2" });