# PWA-Liveview **Repository Path**: mirrors_dwyl/PWA-Liveview ## Basic Information - **Project Name**: PWA-Liveview - **Description**: Mulitpage collaborative offline first LiveView demo with PWA support - **Primary Language**: Unknown - **License**: GPL-3.0 - **Default Branch**: main - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2024-12-30 - **Last Updated**: 2026-01-17 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # Offline first Phoenix LiveView PWA An example of a real-time, collaborative multi-page web app built with `Phoenix LiveView` designed for offline-first ready; it is packaged as a [PWA](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps). While the app supports full offline interaction and local persistence using CRDTs (via `Yjs` and `y-indexeddb`), the core architecture is still grounded in a server-side source of truth. The server database ultimately reconciles all updates, ensuring consistency across clients. This design enables: ✅ Full offline functionality and interactivity ✅ Real-time collaboration between multiple users ✅ Reconciliation with a central, trusted source of truth when back online > A page won't be cached if it is not visited. This is because we don't want ot preload pages as it will capture an uotdated CSRF token. ## Architecture at a glance - Client-side CRDTs (`Yjs`) manage local state changes (e.g. counter updates), even when offline - Server-side database (`Postgres` or `SQLite`) remains authoritative - When the client reconnects, local CRDT updates are synced with the server: - In one page, via `Postgres` and `Phoenix.Sync` wit logical replication - In another, via `SQLite` using a `Phoenix.Channel` message - Offline first solutions naturally offloads the reactive UI logic to JavaScript. We used `SolidJS`. - It uses `Vite` as the bundler. The `vite-plugin-pwa` registers a Service Worker to cache app shell and assets for offline usage. ## How it works ### Optimistic Updates with Centralized Reconciliation Although we leverage Yjs (a CRDT library) under the hood, this isn’t a fully peer-to-peer, decentralized CRDT system. Instead, in this demo we have: - No direct client-to-client replication (not pure lazy/optimistic replication). - No concurrent writes against the same replica—all operations are serialized through the server. - A _centralized authoritative server_. Writes are _serialized_ but actions are concurrent. What we do have is _asynchronous reconciliation_ with an [operation-based CRDT](https://en.wikipedia.org/wiki/Conflict-free_replicated_data_type#Counters) approach: - User actions (e.g. clicking “decrement” on the counter) are applied locally to a `Yjs` document stored in `IndexedDB`. - The same operation (not the full value) is sent to the server via `Phoenix` (either `Phoenix.Sync` or a `Phoenix.Channel`). - `Phoenix` broadcasts that op to all connected clients. - Upon receipt, each client applies the op to its local `Yjs` document—order doesn’t matter, making it commutative. - The server database (`Postgres` or `SQLite`) remains the single source of truth and persists ops in sequence. > In CRDT terms: We use an operation-based CRDT (CRDT Counter) for each shared value Ops commute (order-independent) even though they pass through a central broker. ### Rendering Strategy: SSR vs. Client-Side Hooks To keep the UI interactive both online and offline, we mix `LiveView`’s server-side rendering (SSR) with a client-side reactive framework. We used `SolidJS` because it's lightweight, no virtual DOM, and has simple a simple primitives (`render`, `createSignal`) when we want to inject such a component into the DOM. - Online (`LiveView` SSR or JS-hooks): - The PhxSync page renders a LiveView using `streams` and the "click" event sends data to the client to update the local `Yjs` document. - The YjsCh page renders a JS-hook which initialises a `SolidJS` component. In the JS-hook, the `SolidJS` communicates via a Channel to update the database and the local `Yjs` document. - Offline (Manual Rendering) - We detect the status switch via a server polling. - We retrive the HTML document from the `Cache API`. - We update the current DOM with the cached HTML and inject the correct JS component. - The component reads from and writes to the local `Yjs`+`IndexedDB` replica and remains fully interactive. ### Service Worker & Asset Caching `vite-plugin-pwa` generates a Service Worker that: - Pre-caches the app shell (HTML, CSS, JS) on install. - Intercepts navigations to serve the cached app shell for offline-first startup. This ensures the entire app loads reliably even without network connectivity. ## Results Deployed on `Fly.io`: The standalone PWA is 2.1 MB (page weigth). ## Table of Contents - [Offline first Phoenix LiveView PWA](#offline-first-phoenix-liveview-pwa) - [Architecture at a glance](#architecture-at-a-glance) - [How it works](#how-it-works) - [Optimistic Updates with Centralized Reconciliation](#optimistic-updates-with-centralized-reconciliation) - [Rendering Strategy: SSR vs. Client-Side Hooks](#rendering-strategy-ssr-vs-client-side-hooks) - [Service Worker \& Asset Caching](#service-worker--asset-caching) - [Results](#results) - [Table of Contents](#table-of-contents) - [What?](#what) - [Why?](#why) - [Design goals](#design-goals) - [Common pitfall of combining LiveView with CSR components](#common-pitfall-of-combining-liveview-with-csr-components) - [Tech overview](#tech-overview) - [Implementation highlights](#implementation-highlights) - [About the Yjs-Stock page](#about-the-yjs-stock-page) - [About PWA](#about-pwa) - [Updates life-cycle](#updates-life-cycle) - [Usage](#usage) - [Details of Pages](#details-of-pages) - [Yjs-Ch and PhxSync stock "manager"](#yjs-ch-and-phxsync-stock-manager) - [Pg-Sync-Stock](#pg-sync-stock) - [FlightMap](#flightmap) - [Login](#login) - [Navigation](#navigation) - [Vite](#vite) - [Package.json and `pnpm` workspace (nor not)](#packagejson-and-pnpm-workspace-nor-not) - [Phoenix live\_reload](#phoenix-live_reload) - [HMR in DEV mode](#hmr-in-dev-mode) - ["env" config](#env-config) - [Root layout in :dev/:prod setup](#root-layout-in-devprod-setup) - [Tailwind v4](#tailwind-v4) - [Resolve assets with Vite config](#resolve-assets-with-vite-config) - [Optmise CSS with `lightningCSS` in prod mode](#optmise-css-with-lightningcss-in-prod-mode) - [Client Env](#client-env) - [Static assets](#static-assets) - [`Vite.ex` module](#viteex-module) - [Static copy](#static-copy) - [DEV mode](#dev-mode) - [PROD mode](#prod-mode) - [Performance optimisation: Dynamic CSS loading](#performance-optimisation-dynamic-css-loading) - [VitePWA plugin and Workbox Caching Strategies](#vitepwa-plugin-and-workbox-caching-strategies) - [Yjs](#yjs) - [Misc](#misc) - [Presence through Live-navigation](#presence-through-live-navigation) - [Manifest](#manifest) - [Page Caching](#page-caching) - [Publish](#publish) - [Postgres setup to use Phoenix.Sync](#postgres-setup-to-use-phoenixsync) - [Fly volumes](#fly-volumes) - [Documentation source](#documentation-source) - [Resources](#resources) - [License](#license) - [Enhance](#enhance) ## What? **Context**: we want to experiment PWA collaborative webapps using Phoenix LiveView. What are we building? A three pages webap: 1. We mimic a stock manager in two versions. Every user can pick from the stock which is broadcasted and synced to the databased. The picked amounts are cumulated when offline and the database is synced and state reconciliation. - PgSync-Stock page features `phoenix_sync` in _embedded_ mode streaming logical replicates of a Postgres table. - Yjs-Channel page features 'Sqlite` used as a backup via a Channel. 2. FlightMap. This page proposes an interactive map with a form with two inputs where **two** users can edit collaboratively a form to display markers on the map and then draw a great circle between the two points. ## Why? Traditional Phoenix LiveView applications face several challenges in offline scenarios: LiveView's WebSocket architecture isn't naturally suited for PWAs, as it requires constant connection for functionality. It is challenging to maintain consistent state across network interruptions between the client and the server. Since we need to setup a Service Worker to cache HTML pages and static assets to work offline, we need a different bundler from the one used by default with `LiveView`. ## Design goals - **collaborative** (online): Clients sync via _pubsub updates_ when connected, ensuring real-time consistency. - **optimistic UI**: The function "click on stock" assumes success and will reconciliate later. - **database**: - We use `SQLite` as the "canonical" source of truth for the Yjs-Stock counter. - `Postgres` is used for the `Phoenix_sync` process for the PgSync-Stock counter. - **Offline-First**: The app remains functional offline (through the `Cache` API and reactive JS components), with clients converging to the correct state on reconnection. - **PWA**: Full PWA features, meaning it can be _installed_ as a standalone app and can be _updated_. A `Service Worker` runs in a separate thread and caches the assets. It is setup with `VitePWA`. ## Common pitfall of combining LiveView with CSR components The client-side rendered components are - when online - mounted via hooks under the tag `phx-update="ignore"`. These components have they own lifecycle. They can leak or stack duplicate components if you don't cleanup them properly. The same applies to "subscriptions/observers" primitives from (any) the state manager. You must _unsubscribe_, otherwise you might get multiples calls and weird behaviours. ⭐️ LiveView hooks comes with a handy lifecyle and the `destroyed` callback is essential. `SolidJS` makes this easy as it can return a `cleanupSolid` callback (where you take a reference to the SolidJS component in the hook). You also need to clean _subscriptions_ (when using a store manager). The same applies when you navigate offline; you have to run cleanup functions, both on the components and on the subsriptions/observers from the state manager. ## Tech overview | Component | Role | | -------------------------- | ----------------------------------------------------------------------------------------------------------------- | | Vite | Build and bundling framework | | SQLite | Embedded persistent storage of latest Yjs document | | Postgres | Supports logical replication | | Phoenix LiveView | UI rendering, incuding hooks | | Phoenix.Sync | Relays Postgres streams into LiveView | | PubSub / Phoenix.Channel | Broadcast/notifies other clients of updates / conveys CRDTs binaries on a separate websocket (from te LiveSocket) | | Yjs / Y.Map | Holds the CRDT state client-side (shared) | | y-indexeddb | Persists state locally for offline mode | | Valtio | Holds local ephemeral state | | Hooks | Injects communication primitives and controls JavaScript code | | Service Worker / Cache API | Enable offline UI rendering and navigation by caching HTML pages and static assets | | SolidJS | renders reactive UI using signals, driven by Yjs observers | | Leaflet | Map rendering | | MapTiler | enable vector tiles | | WebAssembly container |  high-performance calculations for map "great-circle" routes use `Zig` code compiled to `WASM` | ### Implementation highlights We use different approaches based on the page requirements: 1. Yjs-Channel: the counter is a reactive component rendered via a hook by `SolidJS`. When offline, we render the component directly. 2. PhxSync: the counter is rendered by LiveView and receives `Psotgres` streams. When offline, we render the exact same component directly. 3. The source of truth is the database. Every client has a local replica (`IndexedDB`) which handles offline changes and gets updates when online. 4. FlightMap. Local state management (`Valtio`) for the collaborative Flight Map page without server-side persistence of the state nor client-side. - **Build tool**: We use Vite as the build tool to bundle and optimize the application and enable PWA features seamlessly. The Service Worker to cache HTML pages and static assets. - **reactive JS components**: Every reactive component works in the following way. Local changes fomr within the component mutate YDoc and an `yjs`-listener will update the component state to render. Any received remote change mutates the `YDoc`, thus triggers the component rendering. - **FlightMap page**: We use a local state manager (`Valtio` using proxies). The inputs (selected airports) are saved to a local state. Local UI changes mutate the state and are sent to the server. The server broadcasts the data. We have state observers which update the UI if the origin is not remote. - **Component Rendering Strategy**: - online: use LiveView hooks - offline: hydrate the HTML with cached documents and run reactive JavaScript components ## About the Yjs-Stock page ```mermaid --- title: "SQLite & Channel & YDoc Implementation" --- flowchart YDoc(YDoc
IndexedDB) Channel[Phoenix Channel] SQLite[(SQLite DB)] Client[Client] Client -->|Local update| YDoc YDoc -->|Send ops| Channel Channel -->|Update counter| SQLite SQLite -->|Return new value| Channel Channel -->|Broadcast| Client Client -->|Remote Update| YDoc YDoc -.->|Reconnect
send stored ops| Channel style YDoc fill:#e1f5fe style Channel fill:#fff3e0 ```
```mermaid --- title: "Postgres & Phoenix_Sync & YDoc Implementation" --- flowchart YDoc[YDoc
IndexedDB] PG[(Postgres DB)] PhoenixSync[Phoenix_Sync
Logical Replication] Client[Client] Client -->|update local| YDoc YDoc -->|Send ops| PG PG -->|Logical replication| PhoenixSync PhoenixSync -->|Stream changes| Client Client -->|Remote Update| YDoc YDoc -.->|Reconnect
send stored ops| PG style YDoc fill:#e1f5fe style PhoenixSync fill:#fff3e0 style PG fill:#f3e5f5 ``` ## About PWA A Progressive Web App (PWA) is a type of web application that provides an app-like experience directly in the browser. It has: - offline support - is "installable": Screenshot 2025-05-08 at 22 02 40
The core components are setup using `Vite` in the _vite.config.js_ file. - **Service Worker**: A background script - separate thread - that acts as a proxy: intercepts network requests and enables offline caching and background sync. We use the `VitePWA` plugin to enable the Service Worker life-cycle (manage updates) - Web App **Manifest** (manifest.webmanifest) A JSON file that defines the app’s name, icons, theme color, start URL, etc., used to install the webapp. We produce the Manifest with `Vite` via in the "vite. - HTTPS (or localhost): Required for secure context: it enables Service Workers and trust. `Vite` builds the SW for us via the `VitePWA` plugin by declarations in "vite.config.js". Check [Vite](#vite) The SW is started by the main script, early, and must preload all the build static assets as the main file starts before the SW runtime caching is active. Since we want offline navigation, we precache the rendered HTML as well. ### Updates life-cycle A Service Worker (SW) runs in a _separate thread_ from the main JS and has a unique lifecycle made of 3 key phases: install / activate / fetch In action: 1. Make a change in the client code, git push/fly deploy: -> a button appears and the dev console shows a push and waiting stage: Screenshot 2025-05-08 at 09 40 28
2. Click the "refresh needed" -> the Service Worker and client claims are updated seamlessly, and the button is in the hidden "normal" state. Screenshot 2025-05-08 at 09 41 55
Service Workers don't automatically update unless: - The sw.js file has changed (based on byte comparison). - The browser checks periodically (usually every 24 hours). - When a new SW is detected: - New SW enters installing state. - It waits until no existing clients are using the old SW. - Then it activates. ```mermaid sequenceDiagram participant User participant Browser participant App participant OldSW as Old Service Worker participant NewSW as New Service Worker Browser->>OldSW: Control App App->>Browser: registerSW() App->>App: code changes Browser->>NewSW: Downloads New SW NewSW->>Browser: waiting phase NewSW-->>App: message: onNeedRefresh() App->>User: Show