Browse learning paths

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

js
import 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

ts
import { 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

ts
import { 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

tsx
import { 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

tsx
import 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

tsx
import { 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

tsx
import { 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

ts
import { 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

tsx
import { 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();