Taskboard 7. Offline and PWA
Finish Taskboard through three observable Valyrian.js flows: a local OfflineQueue, NetworkManager events and a prompt-user service worker update.
Table of contents
Stage 7. Add local offline delivery and PWA updates
- Starting state
- Complete Stage 6 so the browser hydrates the prerendered Taskboard with routes, FormStore and PulseStore.
- Applied change
- Create the same inert offline runtime facade during prerender and browser composition. After synchronous mountRouter connects the initial tree, client-entry calls the idempotent runtime.init() once to create OfflineQueue, NetworkManager and SwRuntimeManager and publish their events through named PulseStore operations.
- Observable result
- The prerender and first client tree share the same state and markup. After runtime.init(), Taskboard renders pending, failed, syncing, queue event, network and worker changes. Its controls enqueue tasks, create a local failure, retry or discard it, and apply an available worker update.
- Next step
- Build with the same script and serve the same public directory used in Stage 1.
Run the completed Taskboard
Run the same Stage 1 build and serve cycle. The build adds the manifest, icons and worker without initializing browser managers during prerender. Open Taskboard from that static origin so mountRouter connects first and runtime.init() starts the offline services afterward.
1. OfflineQueue local lifecycle
Queue a normal task and observe sync success. Create a failed task, then choose Retry failed task to add Recovered task or create another failure and choose Discard failed task.
- Input and operation
- FormStore passes a title to enqueueTask. queue.enqueue stores the local create-task operation, and sync() runs the handler while NetworkManager reports online.
- Observable state and events
- queue.state() supplies pending, failed and syncing. The code observes change, sync:success and sync:error and publishes each result to the Taskboard view.
- Failed, retry and discard
- Create failed task moves one local operation to failed. Retry failed task calls retryAll() and sync() after enabling local recovery. Discard failed task calls discardFailed().
2. NetworkManager connectivity lifecycle
- Status and events
- NetworkManager supplies getNetworkStatus().online. ONLINE, OFFLINE and CHANGE all publish the current value through PulseStore.
- User action
- Switch the browser between online and offline and observe Network. Returning ONLINE lets OfflineQueue sync pending operations automatically.
3. SwRuntimeManager update lifecycle
Change the version passed to sw(), rebuild and reload. Choose Apply update when Taskboard reports the available worker.
- Build and initialization
- icons() creates manifest and icon assets from the SVG source and sw() writes the worker. After mountRouter, runtime.init() creates SwRuntimeManager and worker.init() registers /sw.js.
- State and observed events
- worker.state supplies updateAvailable. The code observes registered, updateavailable, updated and error and publishes workerStatus.
- prompt-user action
- When updateavailable fires, Taskboard shows Apply update. The button calls applyUpdate() for the waiting worker.
build.mjs
jsimport fs from "node:fs";
import { v } from "valyrian.js";
import { icons, inline, render, sw } from "valyrian.js/node";
const client = await inline("./src/client-entry.tsx");
const prerender = await inline("./src/prerender-entry.tsx");
fs.writeFileSync("./prerender.cjs", prerender.raw);
await import("./prerender.cjs");
const { routeHtml, initialState } = await globalThis.__TASKBOARD_PRERENDER__;
fs.rmSync("./prerender.cjs");
const serialized = JSON.stringify(initialState)
.replace(/</g, "\\u003c")
.replace(/\u2028/g, "\\u2028")
.replace(/\u2029/g, "\\u2029");
const body =
routeHtml +
"<script>window.__INITIAL_STATE__=" +
serialized +
';</script><script src="/client-entry.js"></script>';
const documentHtml =
"<!doctype html>" +
render(
v("html", { lang: "en" },
v("head", {},
v("meta", { charset: "UTF-8" }),
v("meta", { name: "viewport", content: "width=device-width,initial-scale=1" }),
v("link", { rel: "manifest", href: "/manifest.webmanifest" }),
v("title", {}, "Taskboard"),
),
v("body", { "v-html": body }),
),
);
fs.mkdirSync("./public", { recursive: true });
fs.writeFileSync("./public/client-entry.js", client.raw);
fs.writeFileSync("./public/index.html", documentHtml);
await icons("./src/taskboard-icon.svg", {
iconsPath: "./public",
appName: "Taskboard",
});
sw("./public/sw.js", { version: "1.0.0", name: "Taskboard" });
src/taskboard-icon.svg
svg<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="12"/><path d="M18 20h28v6H18zm0 10h28v6H18zm0 10h18v6H18z" fill="white"/></svg>
src/state.ts
tsimport { createPulseStore } from "valyrian.js/pulses";
export type Task = { id: number; title: string };
export type TaskboardState = {
tasks: Task[];
showDetails: boolean;
nextId: number;
loaded: boolean;
online: boolean;
pending: number;
failed: number;
syncing: boolean;
queueEvent: string;
workerStatus: string;
updateAvailable: boolean;
};
export function createTaskboardStore(
initial: Partial<TaskboardState> = {},
) {
return createPulseStore(
{
tasks: (initial.tasks ?? [{ id: 1, title: "Learn Valyrian.js" }]).map(
(task) => ({ ...task }),
),
showDetails: initial.showDetails ?? false,
nextId: initial.nextId ?? 2,
loaded: initial.loaded ?? false,
online: initial.online ?? true,
pending: initial.pending ?? 0,
failed: initial.failed ?? 0,
syncing: initial.syncing ?? false,
queueEvent: initial.queueEvent ?? "idle",
workerStatus: initial.workerStatus ?? "registering",
updateAvailable: initial.updateAvailable ?? false,
},
{
toggleDetails(state) {
state.showDetails = !state.showDetails;
},
addTask(state, task: Task) {
state.tasks.push(task);
state.nextId = Math.max(state.nextId, task.id + 1);
},
replaceTasks(state, tasks: Task[]) {
state.tasks = tasks;
state.loaded = true;
state.nextId = tasks.reduce(
(nextId, task) => Math.max(nextId, task.id + 1),
1,
);
},
setNetworkStatus(state, online: boolean) {
state.online = online;
},
setQueueState(
state,
queue: { pending: number; failed: number; syncing: boolean },
) {
state.pending = queue.pending;
state.failed = queue.failed;
state.syncing = queue.syncing;
},
setQueueEvent(state, queueEvent: string) {
state.queueEvent = queueEvent;
},
setWorkerState(
state,
workerStatus: string,
updateAvailable: boolean,
) {
state.workerStatus = workerStatus;
state.updateAvailable = updateAvailable;
},
},
);
}
export type TaskboardStore = ReturnType<typeof createTaskboardStore>;
export function snapshotTaskboardState(
state: TaskboardState,
): TaskboardState {
return {
tasks: state.tasks.map((task) => ({ ...task })),
showDetails: state.showDetails,
nextId: state.nextId,
loaded: state.loaded,
online: state.online,
pending: state.pending,
failed: state.failed,
syncing: state.syncing,
queueEvent: state.queueEvent,
workerStatus: state.workerStatus,
updateAvailable: state.updateAvailable,
};
}
src/offline.ts
tsimport { NetworkEvent, NetworkManager } from "valyrian.js/network";
import { OfflineQueue } from "valyrian.js/offline";
import { SwRuntimeManager } from "valyrian.js/sw";
import type { SubmitTask } from "./task-form";
import type { TaskboardStore } from "./state";
export function createOfflineRuntime(store: TaskboardStore) {
let initialized = false;
let queue: OfflineQueue | null = null;
let worker: SwRuntimeManager | null = null;
let recoverFailures = false;
const enqueueTask: SubmitTask = (title) => {
if (queue === null) {
return;
}
queue.enqueue({ type: "create-task", payload: title });
void queue.sync();
};
return {
enqueueTask,
init() {
if (initialized) {
return;
}
initialized = true;
const network = new NetworkManager();
queue = new OfflineQueue({
id: "taskboard",
network,
isRetryable: () => false,
handler: async (operation) => {
if (operation.type === "fail-task" && recoverFailures === false) {
throw new Error("Local task operation failed");
}
store.addTask({
id: store.state.nextId,
title:
operation.type === "fail-task"
? "Recovered task"
: String(operation.payload),
});
},
});
const publishQueue = () => store.setQueueState(queue?.state() ?? {
pending: 0,
failed: 0,
syncing: false,
});
const publishNetwork = () => {
store.setNetworkStatus(network.getNetworkStatus().online);
};
const publishSyncSuccess = () => store.setQueueEvent("sync success");
const publishSyncError = () => store.setQueueEvent("sync error");
queue.on("change", publishQueue);
queue.on("sync:success", publishSyncSuccess);
queue.on("sync:error", publishSyncError);
network.on(NetworkEvent.ONLINE, publishNetwork);
network.on(NetworkEvent.OFFLINE, publishNetwork);
network.on(NetworkEvent.CHANGE, publishNetwork);
publishQueue();
publishNetwork();
worker = new SwRuntimeManager({
swUrl: "/sw.js",
strategy: "prompt-user",
});
const publishWorkerRegistered = () => {
store.setWorkerState("registered", worker?.state.updateAvailable ?? false);
};
const publishWorkerUpdate = () => {
store.setWorkerState("update available", true);
};
const publishWorkerUpdated = () => {
store.setWorkerState("updated", false);
};
const publishWorkerError = () => {
store.setWorkerState("registration failed", false);
};
worker.on("registered", publishWorkerRegistered);
worker.on("updateavailable", publishWorkerUpdate);
worker.on("updated", publishWorkerUpdated);
worker.on("error", publishWorkerError);
void worker.init();
},
createFailure() {
if (queue === null) {
return;
}
recoverFailures = false;
queue.enqueue({ type: "fail-task" });
void queue.sync();
},
retryFailed() {
if (queue === null) {
return;
}
recoverFailures = true;
queue.retryAll();
void queue.sync();
},
discardFailed() {
queue?.discardFailed();
},
applyUpdate() {
worker?.applyUpdate();
},
};
}
export type TaskboardRuntime = ReturnType<typeof createOfflineRuntime>;
src/task-form.tsx
tsximport { FormStore } from "valyrian.js/forms";
import type { TaskboardStore } from "./state";
export type SubmitTask = (title: string) => Promise<void> | void;
export type TaskFormStore = FormStore<{ title: string }>;
export function createTaskForm(
store: TaskboardStore,
submitTask?: SubmitTask,
) {
return new FormStore({
state: { title: "" },
schema: {
type: "object",
required: ["title"],
properties: { title: { type: "string", minLength: 2 } },
},
clean: { title: (value) => String(value).trim() },
onSubmit: async ({ title }) => {
await new Promise((resolve) => setTimeout(resolve, 400));
if (title === "fail") {
throw new Error("Local FormStore submission failed");
}
if (submitTask) {
return submitTask(title);
}
store.addTask({ id: store.state.nextId, title });
},
});
}
export function TaskForm({ form }: { form: TaskFormStore }) {
return (
<form v-form={form}>
<label>
Task title
<input name="title" v-field={form} placeholder="Add a task title" />
</label>
<p v-if={form.validationErrors.title}>{form.validationErrors.title}</p>
<button type="submit" disabled={form.isInflight}>
{form.isInflight ? "Queueing task..." : "Queue task"}
</button>
<p v-if={form.submitError}>{String(form.submitError)}</p>
<p v-if={form.success}>Task queued</p>
</form>
);
}
src/app.tsx
tsximport type { TaskboardRuntime } from "./offline";
import { TaskForm, type TaskFormStore } from "./task-form";
import type { Task, TaskboardStore } from "./state";
export function TaskboardApp({
store,
form,
runtime,
}: {
store: TaskboardStore;
form: TaskFormStore;
runtime: TaskboardRuntime;
}) {
const state = store.state;
return (
<main>
<h1>Taskboard</h1>
<button onclick={() => store.toggleDetails()}>Toggle details</button>
<p v-if={state.showDetails}>Tasks stay in one shared PulseStore.</p>
<TaskForm form={form} />
<p>
Network: {state.online ? "online" : "offline"}, syncing: {state.syncing ? "yes" : "no"}
</p>
<p>Queue: pending {state.pending}, failed {state.failed}, event {state.queueEvent}</p>
<button onclick={() => runtime.createFailure()}>Create failed task</button>
<button onclick={() => runtime.retryFailed()}>Retry failed task</button>
<button onclick={() => runtime.discardFailed()}>Discard failed task</button>
<p>Worker: {state.workerStatus}</p>
<button
v-if={state.updateAvailable}
onclick={() => runtime.applyUpdate()}
>
Apply update
</button>
<ul v-for={state.tasks}>
{(task: Task) => <li key={task.id}>{task.title}</li>}
</ul>
</main>
);
}
src/tasks-screen.tsx
tsximport { request } from "valyrian.js/request";
import { Suspense } from "valyrian.js/suspense";
import { TaskboardApp } from "./app";
import type { TaskboardRuntime } from "./offline";
import type { TaskFormStore } from "./task-form";
import type { Task, TaskboardStore } from "./state";
async function LoadedTasks({
store,
form,
runtime,
}: {
store: TaskboardStore;
form: TaskFormStore;
runtime: TaskboardRuntime;
}) {
if (store.state.loaded === false) {
const tasks = (await request.get("/tasks.json")) as Task[];
store.replaceTasks(tasks);
}
return <TaskboardApp store={store} form={form} runtime={runtime} />;
}
export function TasksScreen({
store,
form,
runtime,
}: {
store: TaskboardStore;
form: TaskFormStore;
runtime: TaskboardRuntime;
}) {
if (store.state.loaded) {
return <TaskboardApp store={store} form={form} runtime={runtime} />;
}
return (
<Suspense
suspenseKey="taskboard:tasks"
fallback={<p>Loading tasks...</p>}
error={(error) => <p>{String(error)}</p>}
>
<LoadedTasks store={store} form={form} runtime={runtime} />
</Suspense>
);
}
src/router.tsx
tsximport { Router } from "valyrian.js/router";
import type { TaskboardRuntime } from "./offline";
import type { TaskFormStore } from "./task-form";
import type { TaskboardStore } from "./state";
import { TasksScreen } from "./tasks-screen";
export function createRouter(
store: TaskboardStore,
form: TaskFormStore,
runtime: TaskboardRuntime,
) {
const router = new Router();
router.add("/", () => (
<>
<button onclick={() => router.go("/about")}>About</button>
<TasksScreen store={store} form={form} runtime={runtime} />
</>
));
router.add("/about", () => (
<main>
<h1>About Taskboard</h1>
<button onclick={() => router.go("/")}>Tasks</button>
</main>
));
router.catch(404, () => <h1>Taskboard page not found</h1>);
return router;
}
src/taskboard.ts
tsimport { createOfflineRuntime } from "./offline";
import { createRouter } from "./router";
import { createTaskForm } from "./task-form";
import {
createTaskboardStore,
type TaskboardState,
} from "./state";
export function createTaskboardApp(
initialState: Partial<TaskboardState> = {},
) {
const store = createTaskboardStore(initialState);
const runtime = createOfflineRuntime(store);
const form = createTaskForm(store, runtime.enqueueTask);
const router = createRouter(store, form, runtime);
return { store, form, router, runtime };
}
src/client-entry.tsx
tsximport { mountRouter } from "valyrian.js/router";
import { createTaskboardApp } from "./taskboard";
import type { TaskboardState } from "./state";
declare global {
interface Window {
__INITIAL_STATE__?: TaskboardState;
}
}
const app = createTaskboardApp(window.__INITIAL_STATE__);
mountRouter("body", app.router);
app.runtime.init();