Tutorial 34 of 54 · for anyone whose visitors lose their signal · Markdown

Keep pages for reading offline

You'll make an app installable, keep every page a visitor opened for when the network goes and tell an open page when a new build is ready. It takes one configuration section, one page and one island.

Before you start

Install from crates.io. You need no Node and no package manager.

cargo install snapfire_compiler
cargo install snapfire_fsr_cli
fsr --version

Every command and screenshot on this page was captured with fsr 0.x.

Start the app#

$ fsr new trails
$ fsr use trails/app react

The island at the end reads the store through React, so the app needs React.

Give it a few pages to keep. The trails live in a plain module:

app/src/trails.ts
export interface Trail {
  id: string;
  name: string;
  km: number;
  notes: string[];
}

export const trails: Trail[] = [
  { id: "ridge", name: "Long Ridge", km: 14.2, notes: ["The stile after the second gate is broken.", "No water between the car park and the beacon."] },
  { id: "marsh", name: "Saltmarsh Loop", km: 8.5, notes: ["The causeway floods two hours either side of high tide."] },
  { id: "beech", name: "Beech Hangers", km: 11.0, notes: ["Steep and slippery after rain on the descent."] },
];
app/routes/page.loader.ts
import { trails } from "@src/trails";

export async function load() {
  return { trails: trails.map((t) => ({ id: t.id, name: t.name, km: t.km })) };
}
app/routes/page.tsx
import { Link } from "@snapfire/fsr/components";
import type { RootProps } from "@generated/client";

export default function Index({ trails }: RootProps) {
  return (
    <ul>
      {trails.map((t) => (
        <li key={t.id}>
          <Link href={`/trail/${t.id}`}>{t.name}</Link> {t.km} km
        </li>
      ))}
    </ul>
  );
}
app/routes/trail/[id]/page.loader.ts
import type { Ctx } from "@snapfire/fsr/host";
import { fail } from "@snapfire/fsr/host";
import { trails } from "@src/trails";

export async function load({ params }: Ctx<"/trail/{id}">) {
  const trail = trails.find((t) => t.id === params.id);
  if (trail === undefined) fail("not_found", "there is no trail with that id");
  return { trail };
}
app/routes/trail/[id]/page.tsx
import type { TrailIdProps } from "@generated/client";

export default function Trail({ trail }: TrailIdProps) {
  return (
    <article>
      <h1>{trail.name}</h1>
      <ul>
        {trail.notes.map((note) => (
          <li key={note}>{note}</li>
        ))}
      </ul>
    </article>
  );
}

Add the [pwa] section#

config/app.toml
[pwa]
name = "Trails"
short_name = "Trails"
theme_color = "#2f5d3a"
background_color = "#f4f1ea"
offline = "/offline"

The host now answers /manifest.webmanifest from those fields. start_url defaults to / and display to standalone. Each PNG, WebP or SVG in app/icons/ whose name carries its size becomes an icon, so put two there:

app/icons/android-chrome-192x192.png
app/icons/android-chrome-512x512.png

Every page now links the manifest and carries the theme colour. Outside development the head also names the worker:

<link rel="manifest" href="/manifest.webmanifest"><meta name="theme-color" content="#2f5d3a"><meta name="sf:worker" content="/sw.js">

boot() in app/src/main.ts registers it. You write no worker code.

Write the offline page#

offline names a route. The worker fetches it at install and shows it for any page it never kept:

app/routes/offline/page.tsx
import { Link } from "@snapfire/fsr/components";

export default function Offline() {
  return (
    <article>
      <h1>No signal</h1>
      <p>You never opened this page with a connection, so there is no copy of it here.</p>
      <Link href="/">The trails you opened</Link> are still readable.
    </article>
  );
}

Show the connection and the update#

The client keeps two store keys current. online follows the browser's online and offline events. updateReady turns true once a newer build's worker has taken over the page. An island reads them like any other key:

app/src/ui/Status.tsx
import { online, updateReady } from "@snapfire/fsr/client";
import { useStore } from "@snapfire/fsr/client/react";
import { Link } from "@snapfire/fsr/components";

export default function Status() {
  const [connected] = useStore(online, true);
  const [ready] = useStore(updateReady, false);
  return (
    <div className="status">
      {connected ? null : <p className="offline">Offline: you are reading saved pages.</p>}
      {ready ? (
        <p className="update">
          A new version is ready. <Link href="/" full>Open it</Link>
        </p>
      ) : null}
    </div>
  );
}

full makes following the link a full document load, so that load runs the new build. Place the island in the layout so every page has it:

app/routes/layout.tsx
import type { ReactNode } from "react";
import { Link } from "@snapfire/fsr/components";
import Status from "@src/ui/Status";

export default function Layout({ children }: { children: ReactNode }) {
  return (
    <div className="shell">
      <header className="bar">
        <Link href="/" className="brand">
          Trails
        </Link>
        <Status />
      </header>
      <main className="content">{children}</main>
    </div>
  );
}

Run the production shape#

fsr dev links the manifest and registers no worker, so a rebuild is never hidden behind a cache:

$ curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3000/sw.js
404

To see the worker, build and serve with RELEASE_ENV=production. That reads config/production.toml, which turns server.dev off and asks for a session key:

$ RELEASE_ENV=production fsr build trails/app
$ RELEASE_ENV=production C5_SESSION__KEY=$(openssl rand -hex 32) fsr serve trails/app

Check that the worker is there now:

$ curl -sI http://127.0.0.1:3000/sw.js | head -3
HTTP/1.1 200 OK
content-type: text/javascript; charset=utf-8
cache-control: no-cache

The worker at /sw.js is written at boot from what the host serves:

RequestAnswered
A module or stylesheet of the buildFrom the build's own cache, filled at install with every hashed name
The client, the vendor modules, the manifest and offlineFetched at install and kept
Any other GET on this originFrom the network. A response that is not private or no-store is kept. When the network fails the kept copy answers. A page never kept shows offline
/_sf/, /__fsr/, a POSTNever touched

A visitor holding a session gets private documents, so nothing of theirs is kept.

Read it offline#

Open / and wait a moment for the worker to install. Then click through to Long Ridge. Stop the server, switch the browser offline in its developer tools and load each page in full:

  • /trail/ridge reads as it did, with "Offline: you are reading saved pages." in the header.
  • /trail/marsh, which you never opened, shows "No signal".
  • / shows "No signal" too. You opened it before the worker was running, so the worker never saw it. A page counts once the worker is in control, which is from the second page on.

The header line follows the browser's own online and offline events. A stopped server alone doesn't change it.

Ship a new build#

Start the server again and change something the browser loads, such as the stylesheet:

app/styles/app.css
.status .offline,
.status .update {
  margin: 8px 0 0;
  font-size: 14px;
}

Build again, restart fsr serve and load a page. The browser finds a new /sw.js, which installs the new build's cache beside the old one. The new worker takes over, updateReady turns true and the header says "A new version is ready. Open it". Follow the link and the banner is gone.

A client navigation does not check for a new worker. Clicking between trails after the deploy shows no banner until a page is loaded in full.

A change that only reaches the server, such as a loader's data, leaves every module's hash the same. The worker is the same file, so no update is announced.

Next up: 400. Test a loader.