4 Commits

Author SHA1 Message Date
599964c39c Merge branch 'worktree-react' into macros
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 13m13s
2026-03-08 01:45:12 +00:00
b2aaa3786d Add Reactive Islands plan to sx-docs
Design plan for client-side state via signals and islands — a second
sliding bar (reactivity) orthogonal to the existing isomorphism bar.
Covers signal primitives, defisland, shared state, reactive DOM
rendering, SSR hydration, and spec architecture.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 01:44:58 +00:00
2d38a76f0b Merge worktree-zero-tooling-essay into macros
Resolves conflicts by keeping both:
- HEAD: cache invalidation, service worker, sw-post-message
- Worktree: optimistic mutations, offline queue, action endpoint
Plans.sx unified with combined 7c/7d documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 01:33:49 +00:00
5f20a16aa0 Phase 7c + 7d: optimistic data updates and offline mutation queue
7c — Optimistic Data Updates:
- orchestration.sx: optimistic-cache-update/revert/confirm + submit-mutation
- pages.py: mount_action_endpoint at /sx/action/<name> for client mutations
- optimistic-demo.sx: live demo with todo list, pending/confirmed/reverted states
- helpers.py: demo data + add-demo-item action handler

7d — Offline Data Layer:
- orchestration.sx: connectivity tracking, offline-queue-mutation, offline-sync,
  offline-aware-mutation (routes online→submit, offline→queue)
- offline-demo.sx: live demo with notes, connectivity indicator, sync timeline
- helpers.py: offline demo data

Also updates plans.sx: marks Phase 7 fully complete (all 6 sub-phases 7a-7f).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 01:30:12 +00:00
8 changed files with 726 additions and 33 deletions

View File

@@ -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)

View File

@@ -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)
;; --------------------------------------------------------------------------

View File

@@ -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
View 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
View 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")))))

View File

@@ -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 (7a7f) 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."))))

View File

@@ -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)))
;; ---------------------------------------------------------------------------

View File

@@ -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"),
}