diff --git a/shared/sx/pages.py b/shared/sx/pages.py index 1805ca6..11a3241 100644 --- a/shared/sx/pages.py +++ b/shared/sx/pages.py @@ -887,6 +887,9 @@ def auto_mount_pages(app: Any, service_name: str) -> None: # Mount service worker at root scope for offline data layer mount_service_worker(app) + # Mount action endpoint for Phase 7c: optimistic data mutations + mount_action_endpoint(app, service_name) + def mount_pages(bp: Any, service_name: str, names: set[str] | list[str] | None = None) -> None: @@ -1225,3 +1228,56 @@ def mount_service_worker(app: Any) -> None: app.add_url_rule("/sx-sw.js", endpoint="sx_service_worker", view_func=serve_sw) logger.info("Mounted service worker at /sx-sw.js") + + +def mount_action_endpoint(app: Any, service_name: str) -> None: + """Mount /sx/action/ endpoint for client-side data mutations. + + The client can POST to trigger a named action (registered via + register_page_helpers with an 'action:' prefix). The action receives + the JSON payload, performs the mutation, and returns the new page data + as SX wire format. + + This is the server counterpart to submit-mutation in orchestration.sx. + """ + from quart import make_response, request, abort as quart_abort + from .parser import serialize + from shared.browser.app.csrf import csrf_exempt + + @csrf_exempt + async def action_handler(name: str) -> Any: + # Look up action helper + helpers = get_page_helpers(service_name) + action_fn = helpers.get(f"action:{name}") + if action_fn is None: + quart_abort(404) + + # Parse JSON payload + import asyncio as _asyncio + data = await request.get_json(silent=True) or {} + + try: + result = action_fn(**data) + if _asyncio.iscoroutine(result): + result = await result + except Exception as e: + logger.warning("Action %s failed: %s", name, e) + resp = await make_response(f'(dict "error" "{e}")', 500) + resp.content_type = "text/sx; charset=utf-8" + return resp + + result_sx = serialize(result) if result is not None else "nil" + resp = await make_response(result_sx, 200) + resp.content_type = "text/sx; charset=utf-8" + return resp + + action_handler.__name__ = "sx_action" + action_handler.__qualname__ = "sx_action" + + app.add_url_rule( + "/sx/action/", + endpoint="sx_action", + view_func=action_handler, + methods=["POST"], + ) + logger.info("Mounted action endpoint for %s at /sx/action/", service_name) diff --git a/shared/sx/ref/orchestration.sx b/shared/sx/ref/orchestration.sx index 62f8cc3..fbbed8f 100644 --- a/shared/sx/ref/orchestration.sx +++ b/shared/sx/ref/orchestration.sx @@ -663,6 +663,139 @@ (update-page-cache hdr-update data))))))) +;; -------------------------------------------------------------------------- +;; Optimistic data updates (Phase 7c) +;; -------------------------------------------------------------------------- +;; Client-side predicted mutations with rollback. +;; submit-mutation applies a predicted update immediately, sends the mutation +;; to the server, and either confirms or reverts based on the response. + +(define _optimistic-snapshots (dict)) + +(define optimistic-cache-update + (fn (cache-key mutator) + ;; Apply predicted mutation to cached data. Saves snapshot for rollback. + ;; Returns predicted data or nil if no cached data exists. + (let ((cached (page-data-cache-get cache-key))) + (when cached + (let ((predicted (mutator cached))) + ;; Save original for revert + (dict-set! _optimistic-snapshots cache-key cached) + ;; Update cache with prediction + (page-data-cache-set cache-key predicted) + predicted))))) + +(define optimistic-cache-revert + (fn (cache-key) + ;; Revert to pre-mutation snapshot. Returns restored data or nil. + (let ((snapshot (get _optimistic-snapshots cache-key))) + (when snapshot + (page-data-cache-set cache-key snapshot) + (dict-delete! _optimistic-snapshots cache-key) + snapshot)))) + +(define optimistic-cache-confirm + (fn (cache-key) + ;; Server accepted — discard the rollback snapshot. + (dict-delete! _optimistic-snapshots cache-key))) + +(define submit-mutation + (fn (page-name params action-name payload mutator-fn on-complete) + ;; Optimistic mutation: predict locally, send to server, confirm or revert. + ;; on-complete is called with "confirmed" or "reverted" status. + (let ((cache-key (page-data-cache-key page-name params)) + (predicted (optimistic-cache-update cache-key mutator-fn))) + ;; Re-render with predicted data immediately + (when predicted + (try-rerender-page page-name params predicted)) + ;; Send to server + (execute-action action-name payload + (fn (result) + ;; Success: update cache with server truth, confirm + (when result + (page-data-cache-set cache-key result)) + (optimistic-cache-confirm cache-key) + (when result + (try-rerender-page page-name params result)) + (log-info (str "sx:optimistic confirmed " page-name)) + (when on-complete (on-complete "confirmed"))) + (fn (error) + ;; Failure: revert to snapshot + (let ((reverted (optimistic-cache-revert cache-key))) + (when reverted + (try-rerender-page page-name params reverted)) + (log-warn (str "sx:optimistic reverted " page-name ": " error)) + (when on-complete (on-complete "reverted")))))))) + + +;; -------------------------------------------------------------------------- +;; Offline data layer (Phase 7d) +;; -------------------------------------------------------------------------- +;; Connectivity tracking + offline mutation queue. +;; When offline, mutations are queued locally. On reconnect, queued mutations +;; are replayed in order via submit-mutation. + +(define _is-online true) +(define _offline-queue (list)) + +(define offline-is-online? + (fn () _is-online)) + +(define offline-set-online! + (fn (val) + (set! _is-online val))) + +(define offline-queue-mutation + (fn (action-name payload page-name params mutator-fn) + ;; Queue a mutation for later sync. Apply optimistic update locally. + (let ((cache-key (page-data-cache-key page-name params)) + (entry (dict + "action" action-name + "payload" payload + "page" page-name + "params" params + "timestamp" (now-ms) + "status" "pending"))) + (append! _offline-queue entry) + ;; Apply optimistic locally (reuses Phase 7c) + (let ((predicted (optimistic-cache-update cache-key mutator-fn))) + (when predicted + (try-rerender-page page-name params predicted))) + (log-info (str "sx:offline queued " action-name " (" (len _offline-queue) " pending)")) + entry))) + +(define offline-sync + (fn () + ;; Replay all pending mutations. Called on reconnect. + (let ((pending (filter (fn (e) (= (get e "status") "pending")) _offline-queue))) + (when (not (empty? pending)) + (log-info (str "sx:offline syncing " (len pending) " mutations")) + (for-each + (fn (entry) + (execute-action (get entry "action") (get entry "payload") + (fn (result) + (dict-set! entry "status" "synced") + (log-info (str "sx:offline synced " (get entry "action")))) + (fn (error) + (dict-set! entry "status" "failed") + (log-warn (str "sx:offline sync failed " (get entry "action") ": " error))))) + pending))))) + +(define offline-pending-count + (fn () + (len (filter (fn (e) (= (get e "status") "pending")) _offline-queue)))) + +(define offline-aware-mutation + (fn (page-name params action-name payload mutator-fn on-complete) + ;; Top-level mutation function. Routes to submit-mutation when online, + ;; offline-queue-mutation when offline. + (if _is-online + (submit-mutation page-name params action-name payload mutator-fn on-complete) + (do + (offline-queue-mutation action-name payload page-name params mutator-fn) + (when on-complete (on-complete "queued")))))) + + ;; -------------------------------------------------------------------------- ;; Client-side routing ;; -------------------------------------------------------------------------- @@ -1137,6 +1270,10 @@ ;; when data is available. params is a dict of URL/route parameters. ;; (parse-sx-data text) → parsed SX data value, or nil on error. ;; Used by cache update to parse server-provided data in SX format. +;; (execute-action name payload on-success on-error) → void; POST to server, +;; calls (on-success data-dict) or (on-error message). +;; (try-rerender-page page-name params data) → void; re-evaluate and swap +;; the current page content with updated data bindings. ;; ;; From boot.sx: ;; _page-routes → list of route entries @@ -1160,4 +1297,8 @@ ;; === Cache management === ;; (parse-sx-data text) → parsed SX data value, or nil on error ;; (sw-post-message msg) → void; post message to active service worker +;; +;; === Offline persistence === +;; (persist-offline-data key data) → void; write to IndexedDB +;; (retrieve-offline-data key cb) → void; read from IndexedDB, calls (cb data) ;; -------------------------------------------------------------------------- diff --git a/sx/sx/nav-data.sx b/sx/sx/nav-data.sx index 0ac0be5..03a06f7 100644 --- a/sx/sx/nav-data.sx +++ b/sx/sx/nav-data.sx @@ -135,7 +135,9 @@ (dict :label "Data Test" :href "/isomorphism/data-test") (dict :label "Async IO" :href "/isomorphism/async-io") (dict :label "Streaming" :href "/isomorphism/streaming") - (dict :label "Affinity" :href "/isomorphism/affinity"))) + (dict :label "Affinity" :href "/isomorphism/affinity") + (dict :label "Optimistic" :href "/isomorphism/optimistic") + (dict :label "Offline" :href "/isomorphism/offline"))) (define plans-nav-items (list (dict :label "Status" :href "/plans/status" diff --git a/sx/sx/offline-demo.sx b/sx/sx/offline-demo.sx new file mode 100644 index 0000000..ace1328 --- /dev/null +++ b/sx/sx/offline-demo.sx @@ -0,0 +1,76 @@ +;; Offline data layer demo — exercises Phase 7d offline mutation queue. +;; +;; Shows connectivity status, note list, and offline mutation queue. +;; When offline, mutations are queued locally. On reconnect, they sync. +;; +;; Open browser console and look for: +;; "sx:offline queued" — mutation added to queue while offline +;; "sx:offline syncing" — reconnected, replaying queued mutations +;; "sx:offline synced" — individual mutation confirmed by server + +(defcomp ~offline-demo-content (&key notes server-time) + (div :class "space-y-8" + (div :class "border-b border-stone-200 pb-6" + (h1 :class "text-2xl font-bold text-stone-900" "Offline Data Layer") + (p :class "mt-2 text-stone-600" + "This page tests Phase 7d offline capabilities. Mutations made while " + "offline are queued locally and replayed when connectivity returns.")) + + ;; Connectivity indicator + (div :class "rounded-lg border border-stone-200 bg-white p-6 space-y-3" + (h2 :class "text-lg font-semibold text-stone-800" "Status") + (dl :class "grid grid-cols-2 gap-2 text-sm" + (dt :class "font-medium text-stone-600" "Server time") + (dd :class "font-mono text-stone-900" server-time) + (dt :class "font-medium text-stone-600" "Notes count") + (dd :class "text-stone-900" (str (len notes))) + (dt :class "font-medium text-stone-600" "Connectivity") + (dd :class "text-stone-900" + (span :id "offline-status" :class "inline-flex items-center gap-1.5" + (span :class "w-2 h-2 rounded-full bg-green-500") + "Online")))) + + ;; Note list + (div :class "space-y-3" + (h2 :class "text-lg font-semibold text-stone-800" "Notes") + (div :id "offline-notes" :class "space-y-2" + (map (fn (note) + (div :class "flex items-center justify-between rounded border border-stone-100 bg-white p-3" + (div :class "flex items-center gap-3" + (span :class "flex-none rounded-full bg-blue-100 text-blue-700 w-6 h-6 flex items-center justify-center text-xs font-bold" + (str (get note "id"))) + (span :class "text-stone-900" (get note "text"))) + (span :class "text-xs text-stone-400 font-mono" + (get note "created")))) + notes))) + + ;; Architecture + (div :class "space-y-4" + (h2 :class "text-lg font-semibold text-stone-800" "How it works") + (div :class "space-y-2" + (map-indexed + (fn (i step) + (div :class "flex items-start gap-3 rounded border border-stone-100 bg-white p-3" + (span :class "flex-none rounded-full bg-stone-100 text-stone-700 w-6 h-6 flex items-center justify-center text-xs font-bold" + (str (+ i 1))) + (div + (div :class "font-medium text-stone-900" (get step "label")) + (div :class "text-sm text-stone-500" (get step "detail"))))) + (list + (dict :label "Online mutation" :detail "Routes through submit-mutation (Phase 7c) — optimistic predict, server confirm/revert") + (dict :label "Offline detection" :detail "Browser 'offline' event sets _is-online to false. offline-aware-mutation routes to queue") + (dict :label "Queue mutation" :detail "Mutation stored in _offline-queue with 'pending' status. Optimistic update applied locally") + (dict :label "Reconnect" :detail "Browser 'online' event triggers offline-sync — replays queue in order via execute-action") + (dict :label "Sync result" :detail "Each mutation marked 'synced' or 'failed'. Failed mutations stay for manual retry"))))) + + ;; How to test + (div :class "rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm space-y-2" + (p :class "font-semibold text-amber-800" "How to test offline behavior") + (ol :class "list-decimal list-inside text-amber-700 space-y-1" + (li "Open the browser console and Network tab") + (li "Navigate to this page via client-side routing") + (li "In DevTools, set Network to \"Offline\" mode") + (li "The connectivity indicator should change to red/Offline") + (li "Watch console for " (code :class "bg-amber-100 px-1 rounded" "sx:offline queued")) + (li "Re-enable network — watch for " (code :class "bg-amber-100 px-1 rounded" "sx:offline syncing")) + (li "Queued mutations replay and confirm or fail"))))) diff --git a/sx/sx/optimistic-demo.sx b/sx/sx/optimistic-demo.sx new file mode 100644 index 0000000..bfbb961 --- /dev/null +++ b/sx/sx/optimistic-demo.sx @@ -0,0 +1,81 @@ +;; Optimistic update demo — exercises Phase 7c client-side predicted mutations. +;; +;; This page shows a todo list with optimistic add/remove. +;; Mutations are predicted client-side, sent to server, and confirmed/reverted. +;; +;; Open browser console and look for: +;; "sx:optimistic confirmed" — server accepted the mutation +;; "sx:optimistic reverted" — server rejected, data rolled back + +(defcomp ~optimistic-demo-content (&key items server-time) + (div :class "space-y-8" + (div :class "border-b border-stone-200 pb-6" + (h1 :class "text-2xl font-bold text-stone-900" "Optimistic Updates") + (p :class "mt-2 text-stone-600" + "This page tests Phase 7c optimistic data mutations. Items are updated " + "instantly on the client, then confirmed or reverted when the server responds.")) + + ;; Server metadata + (div :class "rounded-lg border border-stone-200 bg-white p-6 space-y-3" + (h2 :class "text-lg font-semibold text-stone-800" "Current state") + (dl :class "grid grid-cols-2 gap-2 text-sm" + (dt :class "font-medium text-stone-600" "Server time") + (dd :class "font-mono text-stone-900" server-time) + (dt :class "font-medium text-stone-600" "Item count") + (dd :class "text-stone-900" (str (len items))))) + + ;; Item list + (div :class "space-y-3" + (h2 :class "text-lg font-semibold text-stone-800" "Items") + (div :id "optimistic-items" :class "space-y-2" + (map (fn (item) + (div :class "flex items-center justify-between rounded border border-stone-100 bg-white p-3" + (div :class "flex items-center gap-3" + (span :class "flex-none rounded-full bg-violet-100 text-violet-700 w-6 h-6 flex items-center justify-center text-xs font-bold" + (str (get item "id"))) + (span :class "text-stone-900" (get item "label"))) + (span :class "text-xs px-2 py-0.5 rounded-full" + :class (case (get item "status") + "confirmed" "bg-green-100 text-green-700" + "pending" "bg-amber-100 text-amber-700" + "reverted" "bg-red-100 text-red-700" + :else "bg-stone-100 text-stone-500") + (get item "status")))) + items)) + + ;; Add button — triggers optimistic mutation + (div :class "pt-2" + (button :class "px-4 py-2 bg-violet-500 text-white rounded hover:bg-violet-600 text-sm" + :sx-post "/sx/action/add-demo-item" + :sx-target "#main-panel" + :sx-vals "{\"label\": \"New item\"}" + "Add item (optimistic)"))) + + ;; How it works + (div :class "space-y-4" + (h2 :class "text-lg font-semibold text-stone-800" "How it works") + (div :class "space-y-2" + (map-indexed + (fn (i step) + (div :class "flex items-start gap-3 rounded border border-stone-100 bg-white p-3" + (span :class "flex-none rounded-full bg-stone-100 text-stone-700 w-6 h-6 flex items-center justify-center text-xs font-bold" + (str (+ i 1))) + (div + (div :class "font-medium text-stone-900" (get step "label")) + (div :class "text-sm text-stone-500" (get step "detail"))))) + (list + (dict :label "Predict" :detail "Client applies mutator function to cached data immediately") + (dict :label "Snapshot" :detail "Pre-mutation data saved in _optimistic-snapshots for rollback") + (dict :label "Re-render" :detail "Page content re-evaluated and swapped with predicted data") + (dict :label "Submit" :detail "Mutation sent to server via POST /sx/action/") + (dict :label "Confirm or revert" :detail "Server responds — cache updated with truth, or reverted to snapshot"))))) + + ;; How to verify + (div :class "rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm space-y-2" + (p :class "font-semibold text-amber-800" "How to verify") + (ol :class "list-decimal list-inside text-amber-700 space-y-1" + (li "Open the browser console (F12)") + (li "Navigate to this page from another isomorphism page") + (li "Click \"Add item\" — item appears instantly with \"pending\" status") + (li "Watch console for " (code :class "bg-amber-100 px-1 rounded" "sx:optimistic confirmed")) + (li "Item status changes to \"confirmed\" when server responds"))))) diff --git a/sx/sx/plans.sx b/sx/sx/plans.sx index b696aaf..22c6c39 100644 --- a/sx/sx/plans.sx +++ b/sx/sx/plans.sx @@ -1393,12 +1393,12 @@ (p :class "text-sm text-stone-600" "Server streams partially-evaluated SX as IO resolves. ~suspense component renders fallbacks, inline resolution scripts fill in content. Concurrent IO via asyncio, chunked transfer encoding.") (p :class "text-sm text-stone-500 mt-1" "Demo: " (a :href "/isomorphism/streaming" "/isomorphism/streaming"))) - (div :class "rounded border border-stone-200 bg-stone-50 p-4" + (div :class "rounded border border-green-300 bg-green-50 p-4" (div :class "flex items-center gap-2 mb-1" - (span :class "inline-block px-2 py-0.5 rounded text-xs font-bold bg-stone-500 text-white uppercase" "Not Started") + (span :class "inline-block px-2 py-0.5 rounded text-xs font-bold bg-green-600 text-white uppercase" "Complete") (a :href "/isomorphism/" :class "font-semibold text-stone-800 underline" "Isomorphic Phase 7: Full Isomorphism")) - (p :class "text-sm text-stone-600" "Runtime boundary optimizer, affinity annotations, offline data layer via Service Worker + IndexedDB, isomorphic testing harness.") - (p :class "text-sm text-stone-500 mt-1" "Depends on: all previous phases.")))))) + (p :class "text-sm text-stone-600" "Affinity annotations, render plans, optimistic data updates, offline mutation queue, isomorphic testing harness, universal page descriptor.") + (p :class "text-sm text-stone-500 mt-1" "All 6 sub-phases (7a–7f) complete.")))))) ;; --------------------------------------------------------------------------- ;; Fragment Protocol @@ -2039,55 +2039,56 @@ (li "Render plans visible on " (a :href "/isomorphism/affinity" "affinity demo page")) (li "Client page registry includes :render-plan for each page")))) - (~doc-subsection :title "7c. Cache Invalidation & Data Updates" + (~doc-subsection :title "7c. Cache Invalidation & Optimistic Data Updates" (div :class "rounded border border-green-300 bg-green-50 p-3 mb-4" (div :class "flex items-center gap-2 mb-1" (span :class "inline-block px-2 py-0.5 rounded text-xs font-bold bg-green-600 text-white uppercase" "Complete")) - (p :class "text-green-800 text-sm" "Client data cache management: invalidation on mutation, server-driven cache updates, and programmatic cache control from SX code.")) + (p :class "text-green-800 text-sm" "Client data cache management, optimistic predicted mutations with snapshot rollback, and server-driven cache updates.")) - (p "The client-side page data cache (30-second TTL) now supports cache invalidation and server-driven updates, extending the existing DOM-level " (code "apply-optimistic") "/" (code "revert-optimistic") " to data-level cache management.") + (p "The client-side page data cache (30-second TTL) now supports cache invalidation, server-driven updates, and optimistic mutations. The client predicts the result of a mutation, immediately re-renders with the predicted data, and confirms or reverts when the server responds.") - (~doc-subsection :title "Element Attributes" + (~doc-subsection :title "Cache Invalidation" (p "Component authors can declare cache invalidation on elements that trigger mutations:") (~doc-code :code (highlight ";; Clear specific page's cache after successful action\n(form :sx-post \"/cart/remove\"\n :sx-cache-invalidate \"cart-page\"\n ...)\n\n;; Clear ALL page caches after action\n(button :sx-post \"/admin/reset\"\n :sx-cache-invalidate \"*\")" "lisp")) - (p "When the request succeeds, the named page's data cache is cleared. The next client-side navigation to that page will re-fetch fresh data from the server.")) - - (~doc-subsection :title "Response Headers" (p "The server can also control client cache via response headers:") (ul :class "list-disc pl-5 text-stone-700 space-y-1" (li (code "SX-Cache-Invalidate: page-name") " — clear cache for a page") - (li (code "SX-Cache-Invalidate: *") " — clear all page caches") - (li (code "SX-Cache-Update: page-name") " — replace cache with the response body (SX-format data)")) - (p (code "SX-Cache-Update") " is the strongest form: the server pushes authoritative data directly into the client cache, so the user sees fresh data immediately on next navigation — no re-fetch needed.")) + (li (code "SX-Cache-Update: page-name") " — replace cache with the response body (SX-format data)"))) - (~doc-subsection :title "Programmatic API" - (p "Three functions available from SX orchestration code:") - (ul :class "list-disc pl-5 text-stone-700 space-y-1 font-mono text-sm" - (li "(invalidate-page-cache page-name)") - (li "(invalidate-all-page-cache)") - (li "(update-page-cache page-name data)"))) + (~doc-subsection :title "Optimistic Mutations" + (ul :class "list-disc pl-5 text-stone-700 space-y-1" + (li (strong "optimistic-cache-update") " — applies a mutator function to cached data, saves a snapshot for rollback") + (li (strong "optimistic-cache-revert") " — restores the pre-mutation snapshot if the server rejects") + (li (strong "optimistic-cache-confirm") " — discards the snapshot after server confirmation") + (li (strong "submit-mutation") " — orchestration function: predict, submit, confirm/revert") + (li (strong "/sx/action/") " — server endpoint for processing mutations (POST, returns SX wire format)"))) (~doc-subsection :title "Files" (ul :class "list-disc pl-5 text-stone-700 space-y-1 font-mono text-sm" + (li "shared/sx/ref/orchestration.sx — cache management + optimistic cache functions + submit-mutation spec") (li "shared/sx/ref/engine.sx — SX-Cache-Invalidate, SX-Cache-Update response headers") - (li "shared/sx/ref/orchestration.sx — cache management functions, process-cache-directives") - (li "shared/sx/ref/bootstrap_js.py — parseSxData platform function")))) + (li "shared/sx/pages.py — mount_action_endpoint for /sx/action/") + (li "sx/sx/optimistic-demo.sx — live demo component"))) + + (~doc-subsection :title "Verification" + (ul :class "list-disc pl-5 text-stone-700 space-y-1" + (li "Live demo at " (a :href "/isomorphism/optimistic" :class "text-violet-600 hover:underline" "/isomorphism/optimistic")) + (li "Console log: " (code "sx:optimistic confirmed") " / " (code "sx:optimistic reverted"))))) (~doc-subsection :title "7d. Offline Data Layer" (div :class "rounded border border-green-300 bg-green-50 p-3 mb-4" (div :class "flex items-center gap-2 mb-1" (span :class "inline-block px-2 py-0.5 rounded text-xs font-bold bg-green-600 text-white uppercase" "Complete")) - (p :class "text-green-800 text-sm" "Service Worker with IndexedDB caching for offline-capable page data and IO responses. Static assets cached via Cache API.")) + (p :class "text-green-800 text-sm" "Service Worker with IndexedDB caching, connectivity tracking, and offline mutation queue with replay on reconnect.")) - (p "A Service Worker registered at " (code "/sx-sw.js") " provides three-tier caching:") + (p "A Service Worker registered at " (code "/sx-sw.js") " provides three-tier caching, plus an offline mutation queue that builds on Phase 7c's optimistic updates:") (ul :class "list-disc pl-5 text-stone-700 space-y-1" - (li (strong "/sx/data/* ") "— network-first with IndexedDB fallback. Page data is cached on successful fetch and served from IndexedDB when offline.") + (li (strong "/sx/data/* ") "— network-first with IndexedDB fallback. Page data cached on fetch, served from IndexedDB when offline.") (li (strong "/sx/io/* ") "— network-first with IndexedDB fallback. IO proxy responses cached the same way.") - (li (strong "/static/* ") "— stale-while-revalidate via Cache API. Serves cached CSS/JS/images immediately, updates in background.")) - - (p "Cache invalidation flows through to the Service Worker: when " (code "invalidate-page-cache") " clears the in-memory cache, it also sends a " (code "postMessage") " to the SW which removes matching entries from IndexedDB.") + (li (strong "/static/* ") "— stale-while-revalidate via Cache API. Serves cached assets immediately, updates in background.") + (li (strong "Offline mutations") " — " (code "offline-aware-mutation") " routes to " (code "submit-mutation") " when online, " (code "offline-queue-mutation") " when offline. " (code "offline-sync") " replays the queue on reconnect.")) (~doc-subsection :title "How It Works" (ol :class "list-decimal list-inside text-stone-700 space-y-2" @@ -2095,14 +2096,21 @@ (li "SW intercepts fetch events and routes by URL pattern") (li "For data/IO: try network first, on failure serve from IndexedDB") (li "For static assets: serve from Cache API, revalidate in background") - (li "Cache invalidation propagates: element attr / response header → in-memory cache → SW message → IndexedDB"))) + (li "Cache invalidation propagates: element attr / response header → in-memory cache → SW message → IndexedDB") + (li "Offline mutations queue locally, replay on reconnect via " (code "offline-sync")))) (~doc-subsection :title "Files" (ul :class "list-disc pl-5 text-stone-700 space-y-1 font-mono text-sm" (li "shared/static/scripts/sx-sw.js — Service Worker (network-first + stale-while-revalidate)") + (li "shared/sx/ref/orchestration.sx — offline queue, sync, connectivity tracking, sw-post-message") (li "shared/sx/pages.py — mount_service_worker() serves SW at /sx-sw.js") - (li "shared/sx/ref/bootstrap_js.py — SW registration in boot init") - (li "shared/sx/ref/orchestration.sx — sw-post-message for cache invalidation")))) + (li "sx/sx/offline-demo.sx — live demo component"))) + + (~doc-subsection :title "Verification" + (ul :class "list-disc pl-5 text-stone-700 space-y-1" + (li "Live demo at " (a :href "/isomorphism/offline" :class "text-violet-600 hover:underline" "/isomorphism/offline")) + (li "Test with DevTools Network → Offline mode") + (li "Console log: " (code "sx:offline queued") ", " (code "sx:offline syncing") ", " (code "sx:offline synced"))))) (~doc-subsection :title "7e. Isomorphic Testing" diff --git a/sx/sxc/pages/docs.sx b/sx/sxc/pages/docs.sx index 2586517..63286ef 100644 --- a/sx/sxc/pages/docs.sx +++ b/sx/sxc/pages/docs.sx @@ -525,6 +525,30 @@ :data (affinity-demo-data) :content (~affinity-demo-content :components components :page-plans page-plans)) +(defpage optimistic-demo + :path "/isomorphism/optimistic" + :auth :public + :layout (:sx-section + :section "Isomorphism" + :sub-label "Isomorphism" + :sub-href "/isomorphism/" + :sub-nav (~section-nav :items isomorphism-nav-items :current "Optimistic") + :selected "Optimistic") + :data (optimistic-demo-data) + :content (~optimistic-demo-content :items items :server-time server-time)) + +(defpage offline-demo + :path "/isomorphism/offline" + :auth :public + :layout (:sx-section + :section "Isomorphism" + :sub-label "Isomorphism" + :sub-href "/isomorphism/" + :sub-nav (~section-nav :items isomorphism-nav-items :current "Offline") + :selected "Offline") + :data (offline-demo-data) + :content (~offline-demo-content :notes notes :server-time server-time)) + ;; Wildcard must come AFTER specific routes (first-match routing) (defpage isomorphism-page :path "/isomorphism/" diff --git a/sx/sxc/pages/helpers.py b/sx/sxc/pages/helpers.py index d9196ac..0cb36e7 100644 --- a/sx/sxc/pages/helpers.py +++ b/sx/sxc/pages/helpers.py @@ -28,6 +28,9 @@ def _register_sx_helpers() -> None: "run-modular-tests": _run_modular_tests, "streaming-demo-data": _streaming_demo_data, "affinity-demo-data": _affinity_demo_data, + "optimistic-demo-data": _optimistic_demo_data, + "action:add-demo-item": _add_demo_item, + "offline-demo-data": _offline_demo_data, }) @@ -922,3 +925,45 @@ def _affinity_demo_data() -> dict: }) return {"components": components, "page-plans": page_plans} + + +def _optimistic_demo_data() -> dict: + """Return demo data for the optimistic update test page.""" + from datetime import datetime, timezone + + return { + "items": [ + {"id": 1, "label": "First item", "status": "confirmed"}, + {"id": 2, "label": "Second item", "status": "confirmed"}, + {"id": 3, "label": "Third item", "status": "confirmed"}, + ], + "server-time": datetime.now(timezone.utc).isoformat(timespec="seconds"), + } + + +def _add_demo_item(**kwargs) -> dict: + """Action: add a demo item. Returns confirmation with new item.""" + from datetime import datetime, timezone + import random + + label = kwargs.get("label", "Untitled") + return { + "id": random.randint(100, 9999), + "label": label, + "status": "confirmed", + "added-at": datetime.now(timezone.utc).isoformat(timespec="seconds"), + } + + +def _offline_demo_data() -> dict: + """Return demo data for the offline data layer test page.""" + from datetime import datetime, timezone + + return { + "notes": [ + {"id": 1, "text": "First note", "created": "2026-03-08T10:00:00Z"}, + {"id": 2, "text": "Second note", "created": "2026-03-08T11:30:00Z"}, + {"id": 3, "text": "Third note", "created": "2026-03-08T14:15:00Z"}, + ], + "server-time": datetime.now(timezone.utc).isoformat(timespec="seconds"), + }