Compare commits
4 Commits
dba5bf05fa
...
599964c39c
| Author | SHA1 | Date | |
|---|---|---|---|
| 599964c39c | |||
| b2aaa3786d | |||
| 2d38a76f0b | |||
| 5f20a16aa0 |
@@ -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/<name> 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/<name>",
|
||||
endpoint="sx_action",
|
||||
view_func=action_handler,
|
||||
methods=["POST"],
|
||||
)
|
||||
logger.info("Mounted action endpoint for %s at /sx/action/<name>", service_name)
|
||||
|
||||
@@ -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)
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
@@ -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"
|
||||
@@ -157,7 +159,9 @@
|
||||
(dict :label "SX CI Pipeline" :href "/plans/sx-ci"
|
||||
:summary "Build, test, and deploy in s-expressions — CI pipelines as SX components.")
|
||||
(dict :label "Live Streaming" :href "/plans/live-streaming"
|
||||
:summary "SSE and WebSocket transports for re-resolving suspense slots after initial page load — live data, real-time collaboration.")))
|
||||
:summary "SSE and WebSocket transports for re-resolving suspense slots after initial page load — live data, real-time collaboration.")
|
||||
(dict :label "Reactive Islands" :href "/plans/reactive-islands"
|
||||
:summary "Client-side state via signals and islands — a sliding bar between hypermedia and React, orthogonal to the server/client rendering bar.")))
|
||||
|
||||
(define bootstrappers-nav-items (list
|
||||
(dict :label "Overview" :href "/bootstrappers/")
|
||||
|
||||
76
sx/sx/offline-demo.sx
Normal file
76
sx/sx/offline-demo.sx
Normal file
@@ -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")))))
|
||||
81
sx/sx/optimistic-demo.sx
Normal file
81
sx/sx/optimistic-demo.sx
Normal file
@@ -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/<name>")
|
||||
(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")))))
|
||||
327
sx/sx/plans.sx
327
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/<name>") " — 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/<name>")
|
||||
(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"
|
||||
|
||||
@@ -2465,3 +2473,260 @@
|
||||
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "shared/sx/ref/boundary.sx")
|
||||
(td :class "px-3 py-2 text-stone-700" "SSE/WS IO primitive declarations")))))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Reactive Islands: Client State via Signals
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~plan-reactive-islands-content ()
|
||||
(~doc-page :title "Reactive Islands"
|
||||
|
||||
;; -----------------------------------------------------------------------
|
||||
;; Context
|
||||
;; -----------------------------------------------------------------------
|
||||
|
||||
(~doc-section :title "Context" :id "context"
|
||||
(p "SX already has a sliding bar for " (em "where") " rendering happens — server-side HTML, SX wire format for client rendering, or any point between. This is the isomorphism bar. It controls the render boundary.")
|
||||
(p "There is a second bar, orthogonal to the first: " (em "how state flows.") " On one end, all state lives on the server — every user action is a round-trip, every UI update is a fresh render. This is the htmx model. On the other end, state lives on the client — signals, subscriptions, fine-grained DOM updates without server involvement. This is the React model.")
|
||||
(p "These two bars are independent. You can have server-rendered HTML with client state (SSR + hydrated React). You can have client-rendered components with server state (current SX). The combination creates four quadrants:")
|
||||
|
||||
(div :class "overflow-x-auto mt-4 mb-4"
|
||||
(table :class "w-full text-sm text-left"
|
||||
(thead
|
||||
(tr :class "border-b border-stone-200"
|
||||
(th :class "py-2 px-3 font-semibold text-stone-700" "")
|
||||
(th :class "py-2 px-3 font-semibold text-stone-700" "Server State")
|
||||
(th :class "py-2 px-3 font-semibold text-stone-700" "Client State")))
|
||||
(tbody :class "text-stone-600"
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "py-2 px-3 font-semibold text-stone-700" "Server Rendering")
|
||||
(td :class "py-2 px-3" "Pure hypermedia (htmx)")
|
||||
(td :class "py-2 px-3" "SSR + hydrated islands (Next.js)"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "py-2 px-3 font-semibold text-stone-700" "Client Rendering")
|
||||
(td :class "py-2 px-3" "SX wire format (current)")
|
||||
(td :class "py-2 px-3 font-semibold text-violet-700" "Reactive islands (this plan)")))))
|
||||
|
||||
(p "Today SX occupies the bottom-left quadrant — client-rendered components with server state. This plan adds the bottom-right: " (strong "reactive islands") " with client-local signals. A page can mix all four quadrants. Most content stays hypermedia. Interactive regions opt into reactivity. The author controls where each component sits on both bars."))
|
||||
|
||||
;; -----------------------------------------------------------------------
|
||||
;; The Spectrum
|
||||
;; -----------------------------------------------------------------------
|
||||
|
||||
(~doc-section :title "The Spectrum" :id "spectrum"
|
||||
(p "Four levels of client interactivity. Each is independently valuable. Each is opt-in per component.")
|
||||
|
||||
(~doc-subsection :title "Level 0: Pure Hypermedia"
|
||||
(p "The default. " (code "sx-get") ", " (code "sx-post") ", " (code "sx-swap") ". Server renders everything. Client swaps fragments. No client state. No JavaScript state management. This is where 90% of a typical application should live."))
|
||||
|
||||
(~doc-subsection :title "Level 1: Local DOM Operations"
|
||||
(p "Imperative escape hatches for micro-interactions too small for a server round-trip: toggling a menu, switching a tab, showing a tooltip. " (code "toggle!") ", " (code "set-attr!") ", " (code "on-event") ". No reactive graph. Just do the thing directly."))
|
||||
|
||||
(~doc-subsection :title "Level 2: Reactive Islands"
|
||||
(p (code "defisland") " components with local signals. Fine-grained DOM updates — no virtual DOM, no diffing, no component re-renders. A signal change updates only the DOM nodes that read it. Islands are isolated by default. The server can render their initial state. This is the core of this plan."))
|
||||
|
||||
(~doc-subsection :title "Level 3: Connected Islands"
|
||||
(p "Islands that share state. Pass the same signal to multiple islands and they stay synchronized. Or use a named store for islands that are distant in the DOM tree. This is where SX starts to feel like React — but only in the regions that need it. The surrounding page remains hypermedia.")))
|
||||
|
||||
;; -----------------------------------------------------------------------
|
||||
;; Signals
|
||||
;; -----------------------------------------------------------------------
|
||||
|
||||
(~doc-section :title "Signals" :id "signals"
|
||||
(p "The core primitive. A signal is a container for a value that notifies subscribers when it changes. Signals are first-class values — they can be created anywhere, passed as arguments, stored in dicts, shared between islands.")
|
||||
|
||||
(~doc-code :code (highlight ";; Create a signal with an initial value\n(define count (signal 0))\n\n;; Read the current value — subscribes the current reactive context\n(deref count) ;; → 0\n\n;; Write a new value — notifies all subscribers\n(reset! count 5) ;; count is now 5\n\n;; Update via function\n(swap! count inc) ;; count is now 6\n\n;; Derived signal — recomputes when dependencies change\n(define doubled (computed (fn () (* 2 (deref count)))))\n(deref doubled) ;; → 12, auto-updates when count changes\n\n;; Side effect — runs when dependencies change\n(effect (fn ()\n (log (str \"Count is now: \" (deref count)))))" "lisp"))
|
||||
|
||||
(~doc-subsection :title "Why signals, not useState"
|
||||
(p (code "useState") " ties state to a component instance. Signals are independent values. This matters:")
|
||||
(ul :class "space-y-2 text-stone-600"
|
||||
(li (strong "No positional hooks.") " Signals can be created in conditionals, loops, closures. No rules of hooks.")
|
||||
(li (strong "Fine-grained updates.") " A signal change updates only the DOM nodes that read it — not the entire component tree. No virtual DOM diffing.")
|
||||
(li (strong "Shareable.") " Pass a signal to another island and both stay synchronized. No Context, no Provider, no useSelector.")
|
||||
(li (strong "Composable.") " " (code "computed") " derives new signals from existing ones. The dependency graph builds itself.")))
|
||||
|
||||
(~doc-subsection :title "Reactive context"
|
||||
(p "Inside an island's rendering, " (code "deref") " subscribes the current DOM node to the signal. Outside an island (server render, static component), " (code "deref") " just returns the current value — no subscription, no overhead. The reactive context is the island boundary.")
|
||||
(p "This means the same component code works in both contexts. " (code "(span (deref count))") " renders " (code "<span>0</span>") " on the server and creates a reactive text binding on the client.")))
|
||||
|
||||
;; -----------------------------------------------------------------------
|
||||
;; Islands
|
||||
;; -----------------------------------------------------------------------
|
||||
|
||||
(~doc-section :title "Islands" :id "islands"
|
||||
(p (code "defisland") " is like " (code "defcomp") " but creates a reactive boundary. Inside an island, signals are tracked. Outside, everything is static hypermedia.")
|
||||
|
||||
(~doc-code :code (highlight "(defisland ~counter (&key initial)\n (let ((count (signal (or initial 0))))\n (div :class \"counter\"\n (span :class \"text-2xl font-bold\" (deref count))\n (div :class \"flex gap-2 mt-2\"\n (button :on-click (fn (e) (swap! count inc)) \"+\")\n (button :on-click (fn (e) (swap! count dec)) \"-\")))))" "lisp"))
|
||||
|
||||
(p "The island is self-contained. " (code "count") " is local state. The buttons modify it. The span updates. Nothing outside the island is affected. No server round-trip.")
|
||||
|
||||
(~doc-subsection :title "Island lifecycle"
|
||||
(ol :class "space-y-2 text-stone-600 list-decimal list-inside"
|
||||
(li (strong "Definition: ") (code "defisland") " registers a reactive component in the environment, same as " (code "defcomp") " but with an island flag.")
|
||||
(li (strong "Server render: ") "Server evaluates the island with initial values. " (code "deref") " returns initial value. Output is HTML with " (code "data-sx-island") " and " (code "data-sx-state") " attributes.")
|
||||
(li (strong "Hydration: ") "Client finds " (code "data-sx-island") " elements. Creates signals from serialized state. Re-renders the island body in a reactive context. Morphs existing DOM to preserve structure.")
|
||||
(li (strong "Updates: ") "User interaction triggers signal changes. Subscribed DOM nodes update directly. No re-render of the full island — just the affected nodes.")
|
||||
(li (strong "Disposal: ") "When the island is removed from the DOM (navigation, swap), all signals and effects created within it are cleaned up.")))
|
||||
|
||||
(~doc-subsection :title "Hydration protocol"
|
||||
(p "The server renders an island as static HTML annotated with state:")
|
||||
(~doc-code :code (highlight ";; Server output:\n<div data-sx-island=\"counter\" data-sx-state='{\"count\": 0}'>\n <span class=\"text-2xl font-bold\">0</span>\n <div class=\"flex gap-2 mt-2\">\n <button>+</button>\n <button>-</button>\n </div>\n</div>\n\n;; Client hydration:\n;; 1. Find data-sx-island elements\n;; 2. Parse data-sx-state into signals\n;; 3. Re-render island body in reactive context\n;; 4. Morph against existing DOM (preserves focus, scroll, etc.)" "html"))))
|
||||
|
||||
;; -----------------------------------------------------------------------
|
||||
;; Connecting Islands
|
||||
;; -----------------------------------------------------------------------
|
||||
|
||||
(~doc-section :title "Connecting Islands" :id "connecting"
|
||||
(p "Islands are isolated by default. Connect them by sharing signals.")
|
||||
|
||||
(~doc-subsection :title "Shared signals via props"
|
||||
(p "The simplest pattern: create a signal outside the islands, pass it to both.")
|
||||
(~doc-code :code (highlight ";; Page-level: create shared state, pass to multiple islands\n(let ((count (signal 0)))\n (<>\n ;; These two islands share the same count signal\n (~counter-controls :count count)\n (div :class \"my-8\"\n (p \"This is static hypermedia content between the islands.\"))\n (~counter-display :count count)))\n\n(defisland ~counter-controls (&key count)\n (div :class \"flex gap-2\"\n (button :on-click (fn (e) (swap! count inc)) \"+\")\n (button :on-click (fn (e) (swap! count dec)) \"-\")))\n\n(defisland ~counter-display (&key count)\n (div :class \"text-4xl font-bold\" (deref count)))" "lisp"))
|
||||
(p "Click \"+\" in " (code "~counter-controls") " and " (code "~counter-display") " updates instantly. No server. No event bus. Just a shared reference."))
|
||||
|
||||
(~doc-subsection :title "Named stores"
|
||||
(p "For islands that are distant in the DOM tree — or defined in different .sx files — passing props is impractical. Named stores provide ambient shared state:")
|
||||
(~doc-code :code (highlight ";; Declare a named store (page-level)\n(def-store cart\n {:items (signal (list))\n :count (computed (fn () (length (deref items))))\n :total (computed (fn () (reduce + 0\n (map (fn (i) (get i \"price\")) (deref items)))))})\n\n;; Any island can subscribe by name\n(defisland ~cart-badge ()\n (let ((s (use-store cart)))\n (span :class \"badge\" (deref (get s \"count\")))))\n\n(defisland ~cart-drawer ()\n (let ((s (use-store cart)))\n (ul (map (fn (item)\n (li (get item \"name\") \" — £\" (get item \"price\")))\n (deref (get s \"items\"))))))" "lisp"))
|
||||
(p "Stores are created once per page. " (code "use-store") " returns the store's signal dict. Multiple islands reading the same store share reactive state without prop threading."))
|
||||
|
||||
(~doc-subsection :title "Mixing with hypermedia"
|
||||
(p "Islands coexist with server-driven content. A single page can have:")
|
||||
(ul :class "space-y-2 text-stone-600"
|
||||
(li "A navigation bar rendered by the server via " (code "sx-get") " / OOB swap")
|
||||
(li "A product list rendered server-side with " (code "sx-swap"))
|
||||
(li "An \"Add to Cart\" island with local quantity state and optimistic update")
|
||||
(li "A cart badge island connected to the same cart store")
|
||||
(li "A checkout form that submits via " (code "sx-post") " — back to server"))
|
||||
(p "The server handles auth, data, routing. Islands handle interactions that need instant feedback. This is not a compromise — it is the architecture.")))
|
||||
|
||||
;; -----------------------------------------------------------------------
|
||||
;; Reactive DOM Rendering
|
||||
;; -----------------------------------------------------------------------
|
||||
|
||||
(~doc-section :title "Reactive DOM Rendering" :id "reactive-rendering"
|
||||
(p "The existing " (code "renderDOM") " function walks the AST and creates DOM nodes. Inside an island, it becomes signal-aware:")
|
||||
|
||||
(~doc-subsection :title "Text bindings"
|
||||
(p "When " (code "renderDOM") " encounters " (code "(deref sig)") " in a text position, it creates a text node and subscribes it to the signal:")
|
||||
(~doc-code :code (highlight ";; Source\n(span (deref count))\n\n;; renderDOM creates:\n;; const span = document.createElement('span')\n;; const text = document.createTextNode(sig.value)\n;; effect(() => text.nodeValue = sig.value)\n;; span.appendChild(text)" "lisp"))
|
||||
(p "When " (code "count") " changes, only the text node updates. The span is untouched. No diffing."))
|
||||
|
||||
(~doc-subsection :title "Attribute bindings"
|
||||
(p "Signal reads in attribute values create reactive attribute bindings:")
|
||||
(~doc-code :code (highlight ";; Source\n(div :class (str \"panel \" (if (deref open?) \"visible\" \"hidden\")))\n\n;; renderDOM creates the div, then:\n;; effect(() => div.className = \"panel \" + (sig.value ? \"visible\" : \"hidden\"))" "lisp")))
|
||||
|
||||
(~doc-subsection :title "Conditional fragments"
|
||||
(p "When a signal appears inside control flow (" (code "if") ", " (code "when") ", " (code "cond") "), the entire branch becomes a reactive fragment:")
|
||||
(~doc-code :code (highlight ";; Source\n(when (deref show-details?)\n (~product-details :product product))\n\n;; renderDOM creates a marker node, then:\n;; effect(() => {\n;; if (sig.value)\n;; insert rendered fragment after marker\n;; else\n;; remove fragment\n;; })" "lisp"))
|
||||
(p "This is equivalent to SolidJS's " (code "Show") " component — but it falls out naturally from the evaluator. No special component needed."))
|
||||
|
||||
(~doc-subsection :title "List rendering"
|
||||
(p "Reactive lists use keyed reconciliation:")
|
||||
(~doc-code :code (highlight "(map (fn (item)\n (li :key (get item \"id\")\n (get item \"name\")))\n (deref items))" "lisp"))
|
||||
(p "When " (code "items") " changes, the " (code "map") " re-runs. Keyed elements are reused and reordered. Unkeyed elements are morphed. This reuses the existing morph algorithm from SxEngine.")))
|
||||
|
||||
;; -----------------------------------------------------------------------
|
||||
;; Server Integration
|
||||
;; -----------------------------------------------------------------------
|
||||
|
||||
(~doc-section :title "Server Integration" :id "server"
|
||||
(p "Islands are server-renderable. The server evaluates the island body with initial signal values. " (code "deref") " returns the value, " (code "reset!") " and " (code "swap!") " are no-ops. The output is static HTML annotated for client hydration.")
|
||||
|
||||
(~doc-subsection :title "SSR render mode"
|
||||
(p "The Python evaluator already handles " (code "defcomp") ". For " (code "defisland") ", it additionally:")
|
||||
(ol :class "space-y-1 text-stone-600 list-decimal list-inside"
|
||||
(li "Tracks signals created during body evaluation")
|
||||
(li "Wraps output in a " (code "data-sx-island") " container")
|
||||
(li "Serializes signal initial values to " (code "data-sx-state"))
|
||||
(li "Renders the body as static HTML (no subscriptions)")))
|
||||
|
||||
(~doc-subsection :title "Server-to-island updates"
|
||||
(p "The server can update island state via the existing OOB swap mechanism. A handler response can include " (code "sx-swap-oob") " targeting an island's container, providing new state:")
|
||||
(~doc-code :code (highlight ";; Server handler response includes:\n(div :id \"cart-island\" :sx-swap-oob \"cart-island\"\n :data-sx-state '{\"items\": [{\"name\": \"Widget\", \"price\": 9.99}]}')\n\n;; Client: receives OOB swap, re-hydrates island with new state" "lisp"))
|
||||
(p "This bridges server and client state. The server pushes updates; the island incorporates them into its signal graph. Both directions work.")))
|
||||
|
||||
;; -----------------------------------------------------------------------
|
||||
;; Spec Architecture
|
||||
;; -----------------------------------------------------------------------
|
||||
|
||||
(~doc-section :title "Spec Architecture" :id "spec"
|
||||
(p "Following the SX-first principle: signal semantics are defined in " (code ".sx") " spec files, then bootstrapped to JavaScript (and eventually Python for SSR).")
|
||||
|
||||
(~doc-subsection :title "signals.sx — the signal runtime"
|
||||
(p "New spec file defining:")
|
||||
(ul :class "space-y-1 text-stone-600 list-disc pl-5"
|
||||
(li (code "signal") " — create a signal container")
|
||||
(li (code "computed") " — derived signal with automatic dependency tracking")
|
||||
(li (code "effect") " — side effect that runs when dependencies change, returns cleanup handle")
|
||||
(li (code "deref") " — read signal value (subscribes in reactive context)")
|
||||
(li (code "reset!") " — write signal value (notifies subscribers)")
|
||||
(li (code "swap!") " — update signal via function")
|
||||
(li (code "batch") " — group multiple signal writes into one notification pass")
|
||||
(li (code "dispose") " — tear down an effect or computed")
|
||||
(li "Dependency tracking algorithm: during effect/computed evaluation, " (code "deref") " calls register the signal as a dependency. Re-evaluation clears old deps and rebuilds."))
|
||||
(p "Signals are pure computation — no DOM, no IO. The spec is host-agnostic."))
|
||||
|
||||
(~doc-subsection :title "Changes to existing specs"
|
||||
(ul :class "space-y-2 text-stone-600 list-disc pl-5"
|
||||
(li (strong "eval.sx: ") (code "defisland") " special form — like " (code "defcomp") " but sets an island flag on the component.")
|
||||
(li (strong "adapter-dom.sx: ") "Reactive " (code "renderDOM") " mode — when inside an island context, " (code "deref") " creates subscriptions and " (code "renderList") " wraps control flow in effects.")
|
||||
(li (strong "adapter-html.sx: ") "SSR rendering of islands — wraps output in " (code "data-sx-island") " container, serializes signal state.")
|
||||
(li (strong "boundary.sx: ") "Signal primitives declaration — " (code "signal") ", " (code "computed") ", " (code "effect") " as pure primitives; " (code "def-store") ", " (code "use-store") " as page helpers.")))
|
||||
|
||||
(~doc-subsection :title "Bootstrap"
|
||||
(p (code "bootstrap_js.py") " generates the signal runtime into " (code "sx.js") ". The reactive rendering extensions fold into the existing " (code "renderDOM") " function. No separate file — signals are part of the core evaluator.")
|
||||
(p (code "bootstrap_py.py") " generates SSR support: " (code "defisland") " handling, " (code "deref") " as plain read, state serialization. The Python side never tracks subscriptions — it only renders the initial state.")))
|
||||
|
||||
;; -----------------------------------------------------------------------
|
||||
;; Files
|
||||
;; -----------------------------------------------------------------------
|
||||
|
||||
(~doc-section :title "Files" :id "files"
|
||||
(div :class "overflow-x-auto rounded border border-stone-200"
|
||||
(table :class "w-full text-left text-sm"
|
||||
(thead (tr :class "border-b border-stone-200 bg-stone-100"
|
||||
(th :class "px-3 py-2 font-medium text-stone-600" "File")
|
||||
(th :class "px-3 py-2 font-medium text-stone-600" "Change")))
|
||||
(tbody
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "shared/sx/ref/signals.sx")
|
||||
(td :class "px-3 py-2 text-stone-700" "New — signal runtime spec: signal, computed, effect, deref, reset!, swap!, batch, dispose, dependency tracking"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "shared/sx/ref/eval.sx")
|
||||
(td :class "px-3 py-2 text-stone-700" "defisland special form, def-store, use-store"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "shared/sx/ref/adapter-dom.sx")
|
||||
(td :class "px-3 py-2 text-stone-700" "Reactive renderDOM — signal-aware text/attribute/fragment bindings"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "shared/sx/ref/adapter-html.sx")
|
||||
(td :class "px-3 py-2 text-stone-700" "SSR island rendering — data-sx-island wrapper, state serialization"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "shared/sx/ref/boundary.sx")
|
||||
(td :class "px-3 py-2 text-stone-700" "Signal and store primitive declarations"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "shared/sx/ref/bootstrap_js.py")
|
||||
(td :class "px-3 py-2 text-stone-700" "Transpile signals.sx into sx.js"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "shared/sx/ref/bootstrap_py.py")
|
||||
(td :class "px-3 py-2 text-stone-700" "Transpile island SSR support into sx_ref.py"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "shared/static/scripts/sx.js")
|
||||
(td :class "px-3 py-2 text-stone-700" "Rebootstrap — signal runtime + reactive rendering"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "shared/sx/ref/sx_ref.py")
|
||||
(td :class "px-3 py-2 text-stone-700" "Rebootstrap — island SSR"))))))
|
||||
|
||||
;; -----------------------------------------------------------------------
|
||||
;; Design Principles
|
||||
;; -----------------------------------------------------------------------
|
||||
|
||||
(~doc-section :title "Design Principles" :id "principles"
|
||||
(ol :class "space-y-3 text-stone-600 list-decimal list-inside"
|
||||
(li (strong "Islands are opt-in.") " " (code "defcomp") " remains the default. Components are inert unless you choose " (code "defisland") ". No reactive overhead for static content.")
|
||||
(li (strong "Signals are values, not hooks.") " Create them anywhere. Pass them as arguments. Store them in dicts. No rules about calling order or conditional creation.")
|
||||
(li (strong "Fine-grained, not component-grained.") " A signal change updates the specific DOM node that reads it. The island does not re-render. There is no virtual DOM and no diffing beyond the morph algorithm already in SxEngine.")
|
||||
(li (strong "The server is still the authority.") " Islands handle client interactions. The server handles auth, data, routing. The server can push state into islands via OOB swaps. Islands can submit data to the server via " (code "sx-post") ".")
|
||||
(li (strong "Spec-first.") " Signal semantics live in " (code "signals.sx") ". Bootstrapped to JS and Python. The same primitives will work in future hosts — Go, Rust, native.")
|
||||
(li (strong "No build step.") " Reactive bindings are created at runtime during DOM rendering. No JSX compilation, no Babel transforms, no Vite plugins. This is slower than SolidJS's compiled output. For islands — small, focused interactive regions — it does not matter."))
|
||||
|
||||
(p :class "mt-4" "The recommendation from the " (a :href "/essays/client-reactivity" :class "text-violet-700 underline" "Client Reactivity") " essay was: \"Tier 4 probably never.\" This plan is what happens when the answer changes. The design avoids every footgun that essay warns about — no useState cascading to useEffect cascading to Context cascading to a state management library. Signals are one primitive. Islands are one boundary. The rest is composition."))))
|
||||
|
||||
|
||||
@@ -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/<slug>"
|
||||
@@ -581,6 +605,7 @@
|
||||
"social-sharing" (~plan-social-sharing-content)
|
||||
"sx-ci" (~plan-sx-ci-content)
|
||||
"live-streaming" (~plan-live-streaming-content)
|
||||
"reactive-islands" (~plan-reactive-islands-content)
|
||||
:else (~plans-index-content)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user