Files
hemx/docs/recipes/pwa-offline.md
T
slhx agent 78aff362ff docs(recipes): add pwa offline boundary
Document optional PWA/offline support as an adapter boundary: cached shell, domain-command queues, server-canonical replay, conflict/fingerprint behavior, and sync-crate shape without adding a client state graph or service worker to core.

req: canonical_authoring/008

req: runtime/003

req: runtime/004

req: auth/002

req: auth/004

req: failure/004

req: failure/005

req: sync/001

req: sync/007
2026-06-05 10:03:39 +02:00

5.0 KiB

Recipe: optional PWA/offline adapter boundary

This recipe describes how a hemx app can add a cached shell or offline queue without turning core hemx into a client app framework. Offline/PWA support is opt-in adapter territory: reuse generated targets and server-canonical effects, but keep service workers, queues, conflict policy, and local storage outside hemx, hemx-core, hemx-build, hemx-derive, hemx-axum, and the tiny runtime. req: canonical_authoring/008 req: runtime/003 req: runtime/004

Use this only after the normal server-first path works. A hemx app is allowed to fail interactions while offline and recover with a full page once the network is back.

Boundary rule

Keep these concerns outside hemx core:

  • service worker registration and cache policy
  • local persistence stores such as IndexedDB
  • offline mutation queues
  • background sync, retry, and conflict resolution
  • CRDTs or collaborative sync engines
  • analytics for offline queue health

Keep these concerns in app/integration code:

  • deciding which pages/assets are safe to cache
  • deciding which mutations may be queued
  • serializing a domain command for later replay
  • reconciling queued commands with server-canonical effect responses
  • showing generated UI feedback such as "offline", "queued", "synced", or "conflict"

The normal path remains server-first typed handlers and generated effects.

Cached shell

A PWA shell may cache page HTML, CSS, the matching /hemx.js, and explicit island scripts for one release. It must obey the same release-unit policy as docs/recipes/deploy-versioning.md: cached server HTML and cached runtime assets must be compatible with the server that receives later interactions. req: abi/002 req: abi/004

Recommended behavior:

  • cache only content-addressed or release-scoped assets
  • evict cached shells on release/fingerprint mismatch
  • fall back to a full page GET when unsure
  • do not patch cached DOM with selector retargeting

The service worker is app code. hemx core should not register or own it.

Offline mutation queue

If a mutation is safe to queue, store an app-domain command, not a raw DOM patch or runtime opcode:

#[derive(serde::Serialize, serde::Deserialize)]
pub enum OfflineCommand {
    CreateProject { csrf: CsrfToken, name: ProjectName },
}

When the browser is offline, the adapter can add the command to an IndexedDB queue and show generated UI feedback from the app shell:

pub fn queued_project_notice() -> impl IntoEffect {
    (
        dashboard::flash.set("Project will be created when you are back online"),
        dashboard::live_status.set("Offline: 1 change queued"),
    )
}

When the network returns, replay the command to the normal server endpoint. The server still runs auth/session, CSRF, validation, persistence, and returns the canonical generated effects. req: auth/002 req: auth/004 req: failure/004

Do not store EffectBatch as the source of truth for later replay. Effects are UI outcomes for a server decision; queued commands are user intent that the server must validate again.

Reconciliation

The server is authoritative. A replay may succeed, fail validation, fail auth, fail CSRF, or conflict with newer state. The adapter should apply the returned server effects when compatible and otherwise navigate/reload to server-rendered truth.

Suggested outcomes:

  • success: apply generated append/replace/remove/summary effects from the server response
  • validation failure: apply generated form error/focus effects
  • auth or CSRF failure: discard or pause the queue and navigate to sign-in or refresh the page
  • conflict: ask the server for the current page/partial and replace a generated target, or show a generated conflict notice
  • fingerprint mismatch: reload/navigate instead of applying queued effects

This keeps conflict policy in the app and keeps core runtime selectorless. req: failure/005

Optional sync crate shape

A future hemx-sync or app-local adapter may provide helpers around this model, but it should remain optional and explicit:

pub trait OfflineQueue {
    async fn push(&self, command: OfflineCommand) -> Result<(), QueueError>;
    async fn drain(&self, session: CurrentSession) -> Result<(), QueueError>;
}

Such an adapter may reuse generated slots, forms, and keyed resources, but it must not make every app value a client-side atom or introduce a mandatory local state graph. req: sync/001 req: sync/007

Tests

Keep tests at the adapter boundary:

  • offline command is stored as a domain command, not a raw effect
  • queued command replays through the same handler route as an online submit
  • server validation and CSRF checks still run during replay
  • fingerprint/runtime mismatch causes reload/navigation instead of partial apply
  • conflict response uses generated UI feedback or full page refresh
  • no selector targeting or client app store is required for normal forms/lists

For the current v1 tutorial, examples/saas remains the server-first canonical path. Offline/PWA is an optional recipe, not required app scaffolding. req: examples/001 req: test/001