| name | tanstack-router-pwa-deep-links |
| description | Future TanStack Router + Workbox NavigationRoute contract for snapmatch — currently unshipped and test-skipped until a service worker is explicitly approved and implemented. Triggers on: spa fallback, navigation fallback, deep link offline, NavigationRoute, router pwa, offline route resolution. |
| license | MIT |
Sub-skill of tanstack. Owns the future contract between Workbox navigation handling and TanStack Router. Snapmatch currently emits no service worker, has no vite-plugin-pwa dependency, and keeps the offline-shell Playwright spec skipped. Firebase Hosting handles online SPA rewrites today. Do not describe offline deep links, installation, precaching, or Workbox routing as shipped behavior until implementation and the ASK-FIRST browser test are approved together.
When to invoke
- Configuring Workbox's
NavigationRoute / navigateFallback in the Vite PWA plugin setup.
- Diagnosing a deep link (
/games/sample/3) that 404s offline but works online.
- Deciding what HTML the service worker should hand back as the fallback (the answer: a stable prerendered shell).
- Reviewing
apps/example-game/vite.config.ts for the PWA plugin's Workbox options.
- Anything about
NavigationRoute, navigateFallback, navigateFallbackAllowlist/Denylist.
Owns
The future TanStack Router/Workbox interaction: navigation fallback to the prerendered shell and offline URL resolution after a service worker is approved. This skill owns no shipped runtime today.
Defers to
tanstack (parent) — version pin and routing.
tanstack-start-spa-prerender — for what the prerendered shell is (SPA + full prerender). This skill consumes the shell; that one produces it.
tanstack-router-routing — for how the router resolves the URL once it boots inside the shell.
nitro — for the current Firebase Hosting online rewrite and the optional Pages-mirror 404.html; neither is an offline service worker.
playwright-pwa-offline (Wave 4, forward) — for the end-to-end test that verifies a deep link resolves while offline. This skill defines the contract; that one verifies it.
Snapmatch stack rules
- Pillar 4 (CLI-gate-first) means: keep the offline spec explicitly skipped while no service worker exists. Once the owner approves the exact Playwright design and the PWA implementation lands, the test becomes a required zero-warning gate.
- The service worker NEVER touches IDB. Assets are the SW's job; IDB is application code (see
idb).
- Precache only the app shell — HTML, JS, and CSS bundles, the manifest, icons. Do not precache per-route data. Runtime-cache static assets (images, fonts) with
CacheFirst.
- TanStack Router handles all navigation client-side. The Workbox NavigationRoute fallback points at the prerendered shell, not at per-route HTML files. Per-route HTML files exist (the prerender emits one per route), but the fallback is the single shell that always boots the SPA.
- A deep link to a route the prerender did NOT emit must still work offline — because the SW returns the shell, the SPA boots, the router runs
validateSearch/params.parse, and renders the matching component (or the route-tree's not-found component).
Patterns
Future vite-plugin-pwa Workbox config
import { VitePWA } from "vite-plugin-pwa";
export default defineConfig({
plugins: [
VitePWA({
strategies: "generateSW",
registerType: "autoUpdate",
workbox: {
globPatterns: ["**/*.{js,css,html,svg,png,ico,webmanifest}"],
navigateFallback: "/index.html",
navigateFallbackDenylist: [/^\/assets\//, /\.(?:png|svg|webp|woff2?)$/],
runtimeCaching: [
{
urlPattern: ({ request }) => request.destination === "image" || request.destination === "font",
handler: "CacheFirst",
options: { cacheName: "static-assets", expiration: { maxEntries: 100 } },
},
],
},
}),
],
});
The navigateFallback: "/index.html" is load-bearing: it makes every navigation request — including unknown deep links — resolve to the prerendered shell. The router then takes over inside the shell. The denylist keeps asset URLs from being routed through the fallback.
Intended behavior after the future PWA ships
1. User opens /games/sample/3 (offline, has visited the app before so SW is installed).
2. Browser issues a navigation request → SW intercepts.
3. Workbox NavigationRoute matches → returns "/index.html" from precache.
4. Browser parses the prerendered shell HTML and runs the bundled JS.
5. TanStack Router sees `location.pathname === "/games/sample/3"`, runs the route tree:
a. params.parse via Zod — OK
b. component renders LevelBoard
6. Atoms read from IDB (already hydrated synchronously after the root <Suspense> resolves).
7. Painted, no network round-trip.
The contract has three load-bearing pieces: (a) the SW precaches the shell, (b) the navigation fallback points at the shell, (c) the router resolves any URL client-side. Lose any one and the offline deep link breaks.
Why "fallback to the shell" beats "fallback to the per-route HTML"
The prerender (see tanstack-start-spa-prerender) emits one HTML file per route. The Workbox fallback could point at the matching per-route file — but it shouldn't, because:
- Workbox would need to mirror the route tree to know which HTML file to return for an unknown URL.
- The per-route HTML is just the same shell + a different
<title> and meta. Booting the SPA from the canonical shell is identical for the user once hydration runs.
- A new route added without a redeploy still works offline (the shell boots, the router resolves it).
So: navigationFallback always points at the canonical shell HTML. Per-route prerender output is for first-paint on online navigation.
Future service-worker registration in the app shell
if ("serviceWorker" in navigator) {
import("virtual:pwa-register").then(({ registerSW }) => {
registerSW({ immediate: true });
});
}
Register at startup; autoUpdate strategy keeps the SW current. Versioned precache ensures users on an old SW pick up new shells on next navigation.
Optional Pages-mirror denylist after the future PWA ships
navigateFallback: "/index.html",
navigateFallbackDenylist: [
/^\/assets\//,
/\.(?:png|svg|webp|woff2?|ico)$/,
/^\/sw\.js$/,
],
The 404.html produced by Nitro's github_pages preset (see nitro) is what GH Pages serves on a hard load of an unknown URL — the SW's fallback handles every subsequent navigation locally.
Anti-patterns
- Don't point
navigateFallback at a per-route HTML file — pick the single canonical shell. Otherwise Workbox needs to mirror the route tree and unknown URLs break.
- Don't precache application state — only HTML, JS, CSS, manifest, icons. Firestore owns committed shared state and IDB owns the durable browser replica/outbox (see
idb); the future SW remains asset-only.
- Don't put IDB reads in the service worker — SW context has no access to your atoms or hydration. The SW is asset-only; the app handles state.
- Don't unskip the offline Playwright test while the service worker is absent — first obtain the required test-design approval, implement the Workbox boundary, and then make the contract a required gate.
- Don't omit
navigateFallbackDenylist for asset URLs — without it, a cache miss on an asset returns the shell HTML and breaks <img> / <link> loads.
- Don't switch to
injectManifest strategy unless you have a specific reason — generateSW produces the right Workbox config for the snapmatch contract; injectManifest requires writing the SW by hand and re-implementing the navigation route.
- Don't expect server-side redirects to work offline — there's no server in this repo (see
tanstack-start-spa-prerender). All redirects are client-side via <Link> / useNavigate.
- Don't cache the navigation fallback HTML with stale-while-revalidate — the precache is the source of truth; SWR makes the shell version unpredictable across reloads.
Triggers on
spa fallback, navigation fallback, deep link offline, NavigationRoute, router pwa, offline route resolution