Two fundamental environment bugs fixed: 1. env-set! was used for both binding creation (let, define, params) and mutation (set!). Binding creation must NOT walk the scope chain — it should set on the immediate env. Only set! should walk. Fix: introduce env-bind! for all binding creation. env-set! now exclusively means "mutate existing binding, walk scope chain". Changed across spec (eval.sx, cek.sx, render.sx) and all web adapters (dom, html, sx, async, boot, orchestration, forms). 2. makeLambda/makeComponent/makeMacro/makeIsland used merge(env) to flatten the closure into a plain object, destroying the prototype chain. This meant set! inside closures couldn't reach the original binding — it modified a snapshot copy instead. Fix: store env directly as closure (no merge). The prototype chain is preserved, so set! walks up to the original scope. Tests: 499/516 passing (96.7%), up from 485/516. Fixed: define self-reference, let scope isolation, set! through closures, counter-via-closure pattern, recursive functions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1415 lines
61 KiB
Plaintext
1415 lines
61 KiB
Plaintext
;; ==========================================================================
|
|
;; orchestration.sx — Engine orchestration (browser wiring)
|
|
;;
|
|
;; Binds the pure engine logic to actual browser events, fetch, DOM
|
|
;; processing, and lifecycle management. This is the runtime that makes
|
|
;; the engine go.
|
|
;;
|
|
;; Dependency is one-way: orchestration → engine, never reverse.
|
|
;;
|
|
;; Depends on:
|
|
;; engine.sx — parse-trigger-spec, get-verb-info, build-request-headers,
|
|
;; process-response-headers, parse-swap-spec, parse-retry-spec,
|
|
;; next-retry-ms, resolve-target, apply-optimistic,
|
|
;; revert-optimistic, find-oob-swaps, swap-dom-nodes,
|
|
;; swap-html-string, morph-children, handle-history,
|
|
;; preload-cache-get, preload-cache-set, classify-trigger,
|
|
;; should-boost-link?, should-boost-form?, parse-sse-swap,
|
|
;; default-trigger, filter-params, PRELOAD_TTL
|
|
;; adapter-dom.sx — render-to-dom
|
|
;; render.sx — shared registries
|
|
;; ==========================================================================
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; Engine state
|
|
;; --------------------------------------------------------------------------
|
|
|
|
(define _preload-cache (dict))
|
|
(define _css-hash "")
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; Event dispatch helpers
|
|
;; --------------------------------------------------------------------------
|
|
|
|
(define dispatch-trigger-events :effects [mutation io]
|
|
(fn (el (header-val :as string))
|
|
;; Dispatch events from SX-Trigger / SX-Trigger-After-Swap headers.
|
|
;; Value can be JSON object (name → detail) or comma-separated names.
|
|
(when header-val
|
|
(let ((parsed (try-parse-json header-val)))
|
|
(if parsed
|
|
;; JSON object: keys are event names, values are detail
|
|
(for-each
|
|
(fn ((key :as string))
|
|
(dom-dispatch el key (get parsed key)))
|
|
(keys parsed))
|
|
;; Comma-separated event names
|
|
(for-each
|
|
(fn ((name :as string))
|
|
(let ((trimmed (trim name)))
|
|
(when (not (empty? trimmed))
|
|
(dom-dispatch el trimmed (dict)))))
|
|
(split header-val ",")))))))
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; CSS tracking
|
|
;; --------------------------------------------------------------------------
|
|
|
|
(define init-css-tracking :effects [mutation io]
|
|
(fn ()
|
|
;; Read initial CSS hash from meta tag
|
|
(let ((meta (dom-query "meta[name=\"sx-css-classes\"]")))
|
|
(when meta
|
|
(let ((content (dom-get-attr meta "content")))
|
|
(when content
|
|
(set! _css-hash content)))))))
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; Request execution
|
|
;; --------------------------------------------------------------------------
|
|
|
|
(define execute-request :effects [mutation io]
|
|
(fn (el (verbInfo :as dict) (extraParams :as dict))
|
|
;; Gate checks then delegate to do-fetch.
|
|
;; verbInfo: dict with "method" and "url" (or nil to read from element).
|
|
;; Re-read from element in case attributes were morphed since binding.
|
|
;; Returns a promise.
|
|
(let ((info (or (get-verb-info el) verbInfo)))
|
|
(if (nil? info)
|
|
(promise-resolve nil)
|
|
(let ((verb (get info "method"))
|
|
(url (get info "url")))
|
|
;; Media query gate
|
|
(if (let ((media (dom-get-attr el "sx-media")))
|
|
(and media (not (browser-media-matches? media))))
|
|
(promise-resolve nil)
|
|
;; Confirm gate
|
|
(if (let ((confirm-msg (dom-get-attr el "sx-confirm")))
|
|
(and confirm-msg (not (browser-confirm confirm-msg))))
|
|
(promise-resolve nil)
|
|
;; Prompt
|
|
(let ((prompt-msg (dom-get-attr el "sx-prompt"))
|
|
(prompt-val (if prompt-msg (browser-prompt prompt-msg) nil)))
|
|
(if (and prompt-msg (nil? prompt-val))
|
|
(promise-resolve nil)
|
|
;; Validation gate
|
|
(if (not (validate-for-request el))
|
|
(promise-resolve nil)
|
|
(do-fetch el verb verb url
|
|
(if prompt-val
|
|
(assoc (or extraParams (dict)) "SX-Prompt" prompt-val)
|
|
extraParams))))))))))))
|
|
|
|
|
|
(define do-fetch :effects [mutation io]
|
|
(fn (el (verb :as string) (method :as string) (url :as string) (extraParams :as dict))
|
|
;; Execute the actual fetch. Manages abort, headers, body, loading state.
|
|
(let ((sync (dom-get-attr el "sx-sync")))
|
|
;; Abort previous if sync mode (per-element)
|
|
(when (= sync "replace")
|
|
(abort-previous el))
|
|
|
|
;; Abort any in-flight request targeting the same swap target,
|
|
;; but only when trigger and target are different elements.
|
|
;; This ensures rapid navigation (click A then B) cancels A's fetch,
|
|
;; while polling (element targets itself) doesn't abort its own requests.
|
|
(let ((target-el (resolve-target el)))
|
|
(when (and target-el (not (identical? el target-el)))
|
|
(abort-previous-target target-el)))
|
|
|
|
(let ((ctrl (new-abort-controller)))
|
|
(track-controller el ctrl)
|
|
;; Also track against the swap target for cross-element cancellation
|
|
(let ((target-el (resolve-target el)))
|
|
(when target-el
|
|
(track-controller-target target-el ctrl)))
|
|
|
|
;; Build request
|
|
(let ((body-info (build-request-body el method url))
|
|
(final-url (get body-info "url"))
|
|
(body (get body-info "body"))
|
|
(ct (get body-info "content-type"))
|
|
(headers (build-request-headers el
|
|
(loaded-component-names) _css-hash))
|
|
(csrf (csrf-token)))
|
|
|
|
;; Merge extra params as headers
|
|
(when extraParams
|
|
(for-each
|
|
(fn ((k :as string)) (dict-set! headers k (get extraParams k)))
|
|
(keys extraParams)))
|
|
|
|
;; Content-Type
|
|
(when ct
|
|
(dict-set! headers "Content-Type" ct))
|
|
|
|
;; CSRF
|
|
(when csrf
|
|
(dict-set! headers "X-CSRFToken" csrf))
|
|
|
|
;; Preload cache check
|
|
(let ((cached (preload-cache-get _preload-cache final-url))
|
|
(optimistic-state (apply-optimistic el))
|
|
(indicator (show-indicator el))
|
|
(disabled-elts (disable-elements el)))
|
|
|
|
;; Loading indicators
|
|
(dom-add-class el "sx-request")
|
|
(dom-set-attr el "aria-busy" "true")
|
|
(dom-dispatch el "sx:beforeRequest" (dict "url" final-url "method" method))
|
|
|
|
;; Fetch
|
|
(fetch-request
|
|
(dict "url" final-url
|
|
"method" method
|
|
"headers" headers
|
|
"body" body
|
|
"signal" (controller-signal ctrl)
|
|
"cross-origin" (cross-origin? final-url)
|
|
"preloaded" cached)
|
|
;; Success callback
|
|
(fn ((resp-ok :as boolean) (status :as number) get-header (text :as string))
|
|
(do
|
|
(clear-loading-state el indicator disabled-elts)
|
|
(revert-optimistic optimistic-state)
|
|
(if (not resp-ok)
|
|
(do
|
|
(dom-dispatch el "sx:responseError"
|
|
(dict "status" status "text" text))
|
|
;; If the error response has SX content, swap it in
|
|
;; (e.g. 404 pages) instead of just retrying
|
|
(if (and text (> (len text) 0))
|
|
(handle-fetch-success el final-url verb extraParams
|
|
get-header text)
|
|
(handle-retry el verb method final-url extraParams)))
|
|
(do
|
|
(dom-dispatch el "sx:afterRequest"
|
|
(dict "status" status))
|
|
(handle-fetch-success el final-url verb extraParams
|
|
get-header text)))))
|
|
;; Error callback
|
|
(fn (err)
|
|
(do
|
|
(clear-loading-state el indicator disabled-elts)
|
|
(revert-optimistic optimistic-state)
|
|
(when (not (abort-error? err))
|
|
(dom-dispatch el "sx:requestError"
|
|
(dict "error" err))))))))))))
|
|
|
|
|
|
(define handle-fetch-success :effects [mutation io]
|
|
(fn (el (url :as string) (verb :as string) (extraParams :as dict) get-header (text :as string))
|
|
;; Route a successful response through the appropriate handler.
|
|
(let ((resp-headers (process-response-headers get-header)))
|
|
;; CSS hash update
|
|
(let ((new-hash (get resp-headers "css-hash")))
|
|
(when new-hash (set! _css-hash new-hash)))
|
|
|
|
;; Triggers (before swap)
|
|
(dispatch-trigger-events el (get resp-headers "trigger"))
|
|
|
|
;; Cache directives — process before navigation so cache is
|
|
;; ready when the target page loads.
|
|
(process-cache-directives el resp-headers text)
|
|
|
|
(cond
|
|
;; Redirect
|
|
(get resp-headers "redirect")
|
|
(browser-navigate (get resp-headers "redirect"))
|
|
|
|
;; Refresh
|
|
(get resp-headers "refresh")
|
|
(browser-reload)
|
|
|
|
;; Location (SX-Location header)
|
|
(get resp-headers "location")
|
|
(fetch-location (get resp-headers "location"))
|
|
|
|
;; Normal response — route by content type
|
|
:else
|
|
(let ((target-el (if (get resp-headers "retarget")
|
|
(dom-query (get resp-headers "retarget"))
|
|
(resolve-target el)))
|
|
(swap-spec (parse-swap-spec
|
|
(or (get resp-headers "reswap")
|
|
(dom-get-attr el "sx-swap"))
|
|
(dom-has-class? (dom-body) "sx-transitions")))
|
|
(swap-style (get swap-spec "style"))
|
|
(use-transition (get swap-spec "transition"))
|
|
(ct (or (get resp-headers "content-type") "")))
|
|
|
|
;; Dispatch by content type
|
|
(if (contains? ct "text/sx")
|
|
(handle-sx-response el target-el text swap-style use-transition)
|
|
(handle-html-response el target-el text swap-style use-transition))
|
|
|
|
;; Post-swap triggers
|
|
(dispatch-trigger-events el (get resp-headers "trigger-swap"))
|
|
|
|
;; History
|
|
(handle-history el url resp-headers)
|
|
|
|
;; Settle phase (after small delay): triggers + sx-on-settle hooks
|
|
(set-timeout
|
|
(fn ()
|
|
;; Server-driven settle triggers
|
|
(when (get resp-headers "trigger-settle")
|
|
(dispatch-trigger-events el
|
|
(get resp-headers "trigger-settle")))
|
|
;; sx-on-settle: evaluate SX expression after swap settles
|
|
(process-settle-hooks el))
|
|
20)
|
|
|
|
;; Lifecycle event
|
|
(dom-dispatch el "sx:afterSwap"
|
|
(dict "target" target-el "swap" swap-style)))))))
|
|
|
|
|
|
(define handle-sx-response :effects [mutation io]
|
|
(fn (el target (text :as string) (swap-style :as string) (use-transition :as boolean))
|
|
;; Handle SX-format response: strip components, extract CSS, render, swap.
|
|
(let ((cleaned (strip-component-scripts text)))
|
|
(let ((final (extract-response-css cleaned)))
|
|
(let ((trimmed (trim final)))
|
|
(when (not (empty? trimmed))
|
|
(let ((rendered (sx-render trimmed))
|
|
(container (dom-create-element "div" nil)))
|
|
(dom-append container rendered)
|
|
;; Process OOB swaps
|
|
(process-oob-swaps container
|
|
(fn (t oob (s :as string))
|
|
(dispose-islands-in t)
|
|
(swap-dom-nodes t oob s)
|
|
(sx-hydrate t)
|
|
(process-elements t)))
|
|
;; Select if specified
|
|
(let ((select-sel (dom-get-attr el "sx-select"))
|
|
(content (if select-sel
|
|
(select-from-container container select-sel)
|
|
(children-to-fragment container))))
|
|
;; Dispose old islands before swap
|
|
(dispose-islands-in target)
|
|
;; Swap
|
|
(with-transition use-transition
|
|
(fn ()
|
|
(swap-dom-nodes target content swap-style)
|
|
(post-swap target)))))))))))
|
|
|
|
|
|
(define handle-html-response :effects [mutation io]
|
|
(fn (el target (text :as string) (swap-style :as string) (use-transition :as boolean))
|
|
;; Handle HTML-format response: parse, OOB, select, swap.
|
|
(let ((doc (dom-parse-html-document text)))
|
|
(when doc
|
|
(let ((select-sel (dom-get-attr el "sx-select")))
|
|
;; Dispose old islands before swap
|
|
(dispose-islands-in target)
|
|
(if select-sel
|
|
;; Select from parsed document
|
|
(let ((html (select-html-from-doc doc select-sel)))
|
|
(with-transition use-transition
|
|
(fn ()
|
|
(swap-html-string target html swap-style)
|
|
(post-swap target))))
|
|
;; Full body content
|
|
(let ((container (dom-create-element "div" nil)))
|
|
(dom-set-inner-html container (dom-body-inner-html doc))
|
|
;; Process OOB swaps
|
|
(process-oob-swaps container
|
|
(fn (t oob (s :as string))
|
|
(dispose-islands-in t)
|
|
(swap-dom-nodes t oob s)
|
|
(post-swap t)))
|
|
;; Hoist head elements
|
|
(hoist-head-elements container)
|
|
;; Swap remaining content
|
|
(with-transition use-transition
|
|
(fn ()
|
|
(swap-dom-nodes target (children-to-fragment container) swap-style)
|
|
(post-swap target))))))))))
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; Retry
|
|
;; --------------------------------------------------------------------------
|
|
|
|
(define handle-retry :effects [mutation io]
|
|
(fn (el (verb :as string) (method :as string) (url :as string) (extraParams :as dict))
|
|
;; Handle retry on failure if sx-retry is configured
|
|
(let ((retry-attr (dom-get-attr el "sx-retry"))
|
|
(spec (parse-retry-spec retry-attr)))
|
|
(when spec
|
|
(let ((current-ms (or (dom-get-attr el "data-sx-retry-ms")
|
|
(get spec "start-ms"))))
|
|
(let ((ms (parse-int current-ms (get spec "start-ms"))))
|
|
(dom-set-attr el "data-sx-retry-ms"
|
|
(str (next-retry-ms ms (get spec "cap-ms"))))
|
|
(set-timeout
|
|
(fn () (do-fetch el verb method url extraParams))
|
|
ms)))))))
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; Trigger binding
|
|
;; --------------------------------------------------------------------------
|
|
|
|
(define bind-triggers :effects [mutation io]
|
|
(fn (el (verbInfo :as dict))
|
|
;; Bind triggers from sx-trigger attribute (or defaults)
|
|
(let ((triggers (or (parse-trigger-spec (dom-get-attr el "sx-trigger"))
|
|
(default-trigger (dom-tag-name el)))))
|
|
(for-each
|
|
(fn ((trigger :as dict))
|
|
(let ((kind (classify-trigger trigger))
|
|
(mods (get trigger "modifiers")))
|
|
(cond
|
|
(= kind "poll")
|
|
(set-interval
|
|
(fn () (execute-request el nil nil))
|
|
(get mods "interval"))
|
|
|
|
(= kind "intersect")
|
|
(observe-intersection el
|
|
(fn () (execute-request el nil nil))
|
|
false (get mods "delay"))
|
|
|
|
(= kind "load")
|
|
(set-timeout
|
|
(fn () (execute-request el nil nil))
|
|
(or (get mods "delay") 0))
|
|
|
|
(= kind "revealed")
|
|
(observe-intersection el
|
|
(fn () (execute-request el nil nil))
|
|
true (get mods "delay"))
|
|
|
|
(= kind "event")
|
|
(bind-event el (get trigger "event") mods verbInfo))))
|
|
triggers))))
|
|
|
|
|
|
(define bind-event :effects [mutation io]
|
|
(fn (el (event-name :as string) (mods :as dict) (verbInfo :as dict))
|
|
;; Bind a standard DOM event trigger.
|
|
;; Handles delay, once, changed, optimistic, preventDefault.
|
|
(let ((timer nil)
|
|
(last-val nil)
|
|
(listen-target (if (get mods "from")
|
|
(dom-query (get mods "from"))
|
|
el)))
|
|
(when listen-target
|
|
(dom-add-listener listen-target event-name
|
|
(fn (e)
|
|
(let ((should-fire true))
|
|
;; Changed modifier: skip if value unchanged
|
|
(when (get mods "changed")
|
|
(let ((val (element-value el)))
|
|
(if (= val last-val)
|
|
(set! should-fire false)
|
|
(set! last-val val))))
|
|
|
|
(when should-fire
|
|
;; Prevent default for submit/click on links
|
|
(when (or (= event-name "submit")
|
|
(and (= event-name "click")
|
|
(dom-has-attr? el "href")))
|
|
(prevent-default e))
|
|
|
|
;; Re-read verb info from element at click time (not closed-over)
|
|
(let ((live-info (or (get-verb-info el) verbInfo))
|
|
(is-get-link (and (= event-name "click")
|
|
(= (get live-info "method") "GET")
|
|
(dom-has-attr? el "href")
|
|
(not (get mods "delay"))))
|
|
(client-routed false))
|
|
(when is-get-link
|
|
(set! client-routed
|
|
(try-client-route
|
|
(url-pathname (get live-info "url"))
|
|
(dom-get-attr el "sx-target"))))
|
|
(if client-routed
|
|
(do
|
|
(browser-push-state (get live-info "url"))
|
|
(browser-scroll-to 0 0))
|
|
(do
|
|
(when is-get-link
|
|
(log-info (str "sx:route server fetch " (get live-info "url"))))
|
|
(if (get mods "delay")
|
|
(do
|
|
(clear-timeout timer)
|
|
(set! timer
|
|
(set-timeout
|
|
(fn () (execute-request el nil nil))
|
|
(get mods "delay"))))
|
|
(execute-request el nil nil))))))))
|
|
(if (get mods "once") (dict "once" true) nil))))))
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; Post-swap lifecycle
|
|
;; --------------------------------------------------------------------------
|
|
|
|
(define post-swap :effects [mutation io]
|
|
(fn (root)
|
|
;; Run lifecycle after swap: activate scripts, process SX, hydrate, process
|
|
(activate-scripts root)
|
|
(sx-process-scripts root)
|
|
(sx-hydrate root)
|
|
(sx-hydrate-islands root)
|
|
(run-post-render-hooks)
|
|
(process-elements root)))
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; sx-on-settle — post-swap SX evaluation
|
|
;; --------------------------------------------------------------------------
|
|
;;
|
|
;; After a swap settles, evaluate the SX expression in the trigger element's
|
|
;; sx-on-settle attribute. The expression has access to all primitives
|
|
;; (including use-store, reset!, deref) so it can update reactive state
|
|
;; based on what the server returned.
|
|
;;
|
|
;; Example: (button :sx-get "/search" :sx-on-settle "(reset! (use-store \"count\") 0)")
|
|
|
|
(define process-settle-hooks :effects [mutation io]
|
|
(fn (el)
|
|
(let ((settle-expr (dom-get-attr el "sx-on-settle")))
|
|
(when (and settle-expr (not (empty? settle-expr)))
|
|
(let ((exprs (sx-parse settle-expr)))
|
|
(for-each
|
|
(fn (expr) (eval-expr expr (env-extend (dict))))
|
|
exprs))))))
|
|
|
|
|
|
(define activate-scripts :effects [mutation io]
|
|
(fn (root)
|
|
;; Re-activate scripts in swapped content.
|
|
;; Scripts inserted via innerHTML are inert — clone to make them execute.
|
|
(when root
|
|
(let ((scripts (dom-query-all root "script")))
|
|
(for-each
|
|
(fn (dead)
|
|
;; Skip already-processed or data-components scripts
|
|
(when (and (not (dom-has-attr? dead "data-components"))
|
|
(not (dom-has-attr? dead "data-sx-activated")))
|
|
(let ((live (create-script-clone dead)))
|
|
(dom-set-attr live "data-sx-activated" "true")
|
|
(dom-replace-child (dom-parent dead) live dead))))
|
|
scripts)))))
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; OOB swap processing
|
|
;; --------------------------------------------------------------------------
|
|
|
|
(define process-oob-swaps :effects [mutation io]
|
|
(fn (container (swap-fn :as lambda))
|
|
;; Find and process out-of-band swaps in container.
|
|
;; swap-fn is (fn (target oob-element swap-type) ...).
|
|
(let ((oobs (find-oob-swaps container)))
|
|
(for-each
|
|
(fn ((oob :as dict))
|
|
(let ((target-id (get oob "target-id"))
|
|
(target (dom-query-by-id target-id))
|
|
(oob-el (get oob "element"))
|
|
(swap-type (get oob "swap-type")))
|
|
;; Remove from source container
|
|
(when (dom-parent oob-el)
|
|
(dom-remove-child (dom-parent oob-el) oob-el))
|
|
;; Swap into target
|
|
(when target
|
|
(swap-fn target oob-el swap-type))))
|
|
oobs))))
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; Head element hoisting
|
|
;; --------------------------------------------------------------------------
|
|
|
|
(define hoist-head-elements :effects [mutation io]
|
|
(fn (container)
|
|
;; Move style[data-sx-css] and link[rel=stylesheet] to <head>
|
|
;; so they take effect globally.
|
|
(for-each
|
|
(fn (style)
|
|
(when (dom-parent style)
|
|
(dom-remove-child (dom-parent style) style))
|
|
(dom-append-to-head style))
|
|
(dom-query-all container "style[data-sx-css]"))
|
|
(for-each
|
|
(fn (link)
|
|
(when (dom-parent link)
|
|
(dom-remove-child (dom-parent link) link))
|
|
(dom-append-to-head link))
|
|
(dom-query-all container "link[rel=\"stylesheet\"]"))))
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; Boost processing
|
|
;; --------------------------------------------------------------------------
|
|
|
|
(define process-boosted :effects [mutation io]
|
|
(fn (root)
|
|
;; Find [sx-boost] containers and boost their descendants
|
|
(for-each
|
|
(fn (container)
|
|
(boost-descendants container))
|
|
(dom-query-all (or root (dom-body)) "[sx-boost]"))))
|
|
|
|
|
|
(define boost-descendants :effects [mutation io]
|
|
(fn (container)
|
|
;; Boost links and forms within a container.
|
|
;; The sx-boost attribute value is the default target selector
|
|
;; for boosted descendants (e.g. sx-boost="#main-panel").
|
|
(let ((boost-target (dom-get-attr container "sx-boost")))
|
|
(for-each
|
|
(fn (link)
|
|
(when (and (not (is-processed? link "boost"))
|
|
(should-boost-link? link))
|
|
(mark-processed! link "boost")
|
|
;; Inherit target from boost container if not specified
|
|
(when (and (not (dom-has-attr? link "sx-target"))
|
|
boost-target (not (= boost-target "true")))
|
|
(dom-set-attr link "sx-target" boost-target))
|
|
(when (not (dom-has-attr? link "sx-swap"))
|
|
(dom-set-attr link "sx-swap" "innerHTML"))
|
|
(when (not (dom-has-attr? link "sx-push-url"))
|
|
(dom-set-attr link "sx-push-url" "true"))
|
|
(bind-client-route-link link (dom-get-attr link "href"))))
|
|
(dom-query-all container "a[href]"))
|
|
(for-each
|
|
(fn (form)
|
|
(when (and (not (is-processed? form "boost"))
|
|
(should-boost-form? form))
|
|
(mark-processed! form "boost")
|
|
(let ((method (upper (or (dom-get-attr form "method") "GET")))
|
|
(action (or (dom-get-attr form "action")
|
|
(browser-location-href))))
|
|
(when (and (not (dom-has-attr? form "sx-target"))
|
|
boost-target (not (= boost-target "true")))
|
|
(dom-set-attr form "sx-target" boost-target))
|
|
(when (not (dom-has-attr? form "sx-swap"))
|
|
(dom-set-attr form "sx-swap" "innerHTML"))
|
|
(bind-boost-form form method action))))
|
|
(dom-query-all container "form")))))
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; Client-side routing — data cache
|
|
;; --------------------------------------------------------------------------
|
|
|
|
;; Cache for page data resolved via resolve-page-data.
|
|
;; Keyed by "page-name:param1=val1¶m2=val2", value is {data, ts}.
|
|
;; Default TTL: 30s. Prevents redundant fetches on back/forward navigation.
|
|
|
|
(define _page-data-cache (dict))
|
|
(define _page-data-cache-ttl 30000) ;; 30 seconds in ms
|
|
|
|
(define page-data-cache-key :effects []
|
|
(fn ((page-name :as string) (params :as dict))
|
|
;; Build a cache key from page name + params.
|
|
;; Params are from route matching so order is deterministic.
|
|
(let ((base page-name))
|
|
(if (or (nil? params) (empty? (keys params)))
|
|
base
|
|
(let ((parts (list)))
|
|
(for-each
|
|
(fn ((k :as string))
|
|
(append! parts (str k "=" (get params k))))
|
|
(keys params))
|
|
(str base ":" (join "&" parts)))))))
|
|
|
|
(define page-data-cache-get :effects [mutation io]
|
|
(fn ((cache-key :as string))
|
|
;; Return cached data if fresh, else nil.
|
|
(let ((entry (get _page-data-cache cache-key)))
|
|
(if (nil? entry)
|
|
nil
|
|
(if (> (- (now-ms) (get entry "ts")) _page-data-cache-ttl)
|
|
(do
|
|
(dict-set! _page-data-cache cache-key nil)
|
|
nil)
|
|
(get entry "data"))))))
|
|
|
|
(define page-data-cache-set :effects [mutation io]
|
|
(fn ((cache-key :as string) data)
|
|
;; Store data with current timestamp.
|
|
(dict-set! _page-data-cache cache-key
|
|
{"data" data "ts" (now-ms)})))
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; Client-side routing — cache management
|
|
;; --------------------------------------------------------------------------
|
|
|
|
(define invalidate-page-cache :effects [mutation io]
|
|
(fn ((page-name :as string))
|
|
;; Clear cached data for a page. Removes all cache entries whose key
|
|
;; matches page-name (exact) or starts with "page-name:" (with params).
|
|
;; Also notifies the service worker to clear its IndexedDB entries.
|
|
(for-each
|
|
(fn ((k :as string))
|
|
(when (or (= k page-name) (starts-with? k (str page-name ":")))
|
|
(dict-set! _page-data-cache k nil)))
|
|
(keys _page-data-cache))
|
|
(sw-post-message {"type" "invalidate" "page" page-name})
|
|
(log-info (str "sx:cache invalidate " page-name))))
|
|
|
|
(define invalidate-all-page-cache :effects [mutation io]
|
|
(fn ()
|
|
;; Clear all cached page data and notify service worker.
|
|
(set! _page-data-cache (dict))
|
|
(sw-post-message {"type" "invalidate" "page" "*"})
|
|
(log-info "sx:cache invalidate *")))
|
|
|
|
(define update-page-cache :effects [mutation io]
|
|
(fn ((page-name :as string) data)
|
|
;; Replace cached data for a page with server-provided data.
|
|
;; Uses a bare page-name key (no params) — the server knows the
|
|
;; canonical data shape for the page.
|
|
(let ((cache-key (page-data-cache-key page-name (dict))))
|
|
(page-data-cache-set cache-key data)
|
|
(log-info (str "sx:cache update " page-name)))))
|
|
|
|
(define process-cache-directives :effects [mutation io]
|
|
(fn (el (resp-headers :as dict) (response-text :as string))
|
|
;; Process cache invalidation and update directives from both
|
|
;; element attributes and response headers.
|
|
;;
|
|
;; Element attributes (set by component author):
|
|
;; sx-cache-invalidate="page-name" — clear page cache on success
|
|
;; sx-cache-invalidate="*" — clear all page caches
|
|
;;
|
|
;; Response headers (set by server):
|
|
;; SX-Cache-Invalidate: page-name — clear page cache
|
|
;; SX-Cache-Update: page-name — replace cache with response data
|
|
|
|
;; 1. Element-level invalidation
|
|
(let ((el-invalidate (dom-get-attr el "sx-cache-invalidate")))
|
|
(when el-invalidate
|
|
(if (= el-invalidate "*")
|
|
(invalidate-all-page-cache)
|
|
(invalidate-page-cache el-invalidate))))
|
|
|
|
;; 2. Response header invalidation
|
|
(let ((hdr-invalidate (get resp-headers "cache-invalidate")))
|
|
(when hdr-invalidate
|
|
(if (= hdr-invalidate "*")
|
|
(invalidate-all-page-cache)
|
|
(invalidate-page-cache hdr-invalidate))))
|
|
|
|
;; 3. Response header cache update (server pushes fresh data)
|
|
;; parse-sx-data is a platform-provided function that parses SX text
|
|
;; into a data value (returns nil on parse error).
|
|
(let ((hdr-update (get resp-headers "cache-update")))
|
|
(when hdr-update
|
|
(let ((data (parse-sx-data response-text)))
|
|
(when data
|
|
(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 :effects [mutation]
|
|
(fn ((cache-key :as string) (mutator :as lambda))
|
|
;; 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 :effects [mutation]
|
|
(fn ((cache-key :as string))
|
|
;; 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 :effects [mutation]
|
|
(fn ((cache-key :as string))
|
|
;; Server accepted — discard the rollback snapshot.
|
|
(dict-delete! _optimistic-snapshots cache-key)))
|
|
|
|
(define submit-mutation :effects [mutation io]
|
|
(fn ((page-name :as string) (params :as dict) (action-name :as string) payload (mutator-fn :as lambda) (on-complete :as lambda))
|
|
;; 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 :as string))
|
|
;; 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? :effects [io]
|
|
(fn () _is-online))
|
|
|
|
(define offline-set-online! :effects [mutation]
|
|
(fn ((val :as boolean))
|
|
(set! _is-online val)))
|
|
|
|
(define offline-queue-mutation :effects [mutation io]
|
|
(fn ((action-name :as string) payload (page-name :as string) (params :as dict) (mutator-fn :as lambda))
|
|
;; 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 :effects [mutation io]
|
|
(fn ()
|
|
;; Replay all pending mutations. Called on reconnect.
|
|
(let ((pending (filter (fn ((e :as dict)) (= (get e "status") "pending")) _offline-queue)))
|
|
(when (not (empty? pending))
|
|
(log-info (str "sx:offline syncing " (len pending) " mutations"))
|
|
(for-each
|
|
(fn ((entry :as dict))
|
|
(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 :as string))
|
|
(dict-set! entry "status" "failed")
|
|
(log-warn (str "sx:offline sync failed " (get entry "action") ": " error)))))
|
|
pending)))))
|
|
|
|
(define offline-pending-count :effects [io]
|
|
(fn ()
|
|
(len (filter (fn ((e :as dict)) (= (get e "status") "pending")) _offline-queue))))
|
|
|
|
(define offline-aware-mutation :effects [mutation io]
|
|
(fn ((page-name :as string) (params :as dict) (action-name :as string) payload (mutator-fn :as lambda) (on-complete :as lambda))
|
|
;; 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
|
|
;; --------------------------------------------------------------------------
|
|
|
|
(define current-page-layout :effects [io]
|
|
(fn ()
|
|
;; Find the layout name of the currently displayed page by matching
|
|
;; the browser URL against the page route table.
|
|
(let ((pathname (url-pathname (browser-location-href)))
|
|
(match (find-matching-route pathname _page-routes)))
|
|
(if (nil? match) ""
|
|
(or (get match "layout") "")))))
|
|
|
|
|
|
(define swap-rendered-content :effects [mutation io]
|
|
(fn (target rendered (pathname :as string))
|
|
;; Swap rendered DOM content into target and run post-processing.
|
|
;; Shared by pure and data page client routes.
|
|
(do
|
|
(dispose-islands-in target)
|
|
(dom-set-text-content target "")
|
|
(dom-append target rendered)
|
|
(hoist-head-elements-full target)
|
|
(process-elements target)
|
|
(sx-hydrate-elements target)
|
|
(run-post-render-hooks)
|
|
(dom-dispatch target "sx:clientRoute"
|
|
(dict "pathname" pathname))
|
|
(log-info (str "sx:route client " pathname)))))
|
|
|
|
|
|
(define resolve-route-target :effects [io]
|
|
(fn ((target-sel :as string))
|
|
;; Resolve a target selector to a DOM element, or nil.
|
|
(if (and target-sel (not (= target-sel "true")))
|
|
(dom-query target-sel)
|
|
nil)))
|
|
|
|
|
|
(define deps-satisfied? :effects [io]
|
|
(fn ((match :as dict))
|
|
;; Check if all component deps for a page are loaded client-side.
|
|
(let ((deps (get match "deps"))
|
|
(loaded (loaded-component-names)))
|
|
(if (or (nil? deps) (empty? deps))
|
|
true
|
|
(every? (fn ((dep :as string)) (contains? loaded dep)) deps)))))
|
|
|
|
|
|
(define try-client-route :effects [mutation io]
|
|
(fn ((pathname :as string) (target-sel :as string))
|
|
;; Try to render a page client-side. Returns true if successful, false otherwise.
|
|
;; target-sel is the CSS selector for the swap target (from sx-boost value).
|
|
;; For pure pages: renders immediately. For :data pages: fetches data then renders.
|
|
;; Falls through to server when layout changes (needs OOB header update).
|
|
(let ((match (find-matching-route pathname _page-routes)))
|
|
(if (nil? match)
|
|
(do (log-info (str "sx:route no match (" (len _page-routes) " routes) " pathname)) false)
|
|
(let ((target-layout (or (get match "layout") ""))
|
|
(cur-layout (current-page-layout)))
|
|
(if (not (= target-layout cur-layout))
|
|
(do (log-info (str "sx:route server (layout: " cur-layout " -> " target-layout ") " pathname)) false)
|
|
(let ((content-src (get match "content"))
|
|
(closure (or (get match "closure") {}))
|
|
(params (get match "params"))
|
|
(page-name (get match "name")))
|
|
(if (or (nil? content-src) (empty? content-src))
|
|
(do (log-warn (str "sx:route no content for " pathname)) false)
|
|
(let ((target (resolve-route-target target-sel)))
|
|
(if (nil? target)
|
|
(do (log-warn (str "sx:route target not found: " target-sel)) false)
|
|
(if (not (deps-satisfied? match))
|
|
(do (log-info (str "sx:route deps miss for " page-name)) false)
|
|
(let ((io-deps (get match "io-deps"))
|
|
(has-io (and io-deps (not (empty? io-deps))))
|
|
(render-plan (get match "render-plan")))
|
|
;; Log render plan for boundary visibility
|
|
(when render-plan
|
|
(let ((srv (or (get render-plan "server") (list)))
|
|
(cli (or (get render-plan "client") (list))))
|
|
(log-info (str "sx:route plan " page-name
|
|
" — " (len srv) " server, " (len cli) " client"))))
|
|
;; Ensure IO deps are registered as proxied primitives
|
|
(when has-io (register-io-deps io-deps))
|
|
(if (get match "stream")
|
|
;; Streaming page: fetch with streaming body reader.
|
|
;; First chunk = OOB SX swap (shell with skeletons),
|
|
;; subsequent chunks = resolve scripts filling slots.
|
|
(do (log-info (str "sx:route streaming " pathname))
|
|
(fetch-streaming target pathname
|
|
(build-request-headers target
|
|
(loaded-component-names) _css-hash))
|
|
true)
|
|
(if (get match "has-data")
|
|
;; Data page: check cache, else resolve asynchronously
|
|
(let ((cache-key (page-data-cache-key page-name params))
|
|
(cached (page-data-cache-get cache-key)))
|
|
(if cached
|
|
;; Cache hit
|
|
(let ((env (merge closure params cached)))
|
|
(if has-io
|
|
;; Async render (data+IO)
|
|
(do
|
|
(log-info (str "sx:route client+cache+async " pathname))
|
|
(try-async-eval-content content-src env
|
|
(fn (rendered)
|
|
(if (nil? rendered)
|
|
(do (log-warn (str "sx:route cache+async eval failed for " pathname " — server fallback"))
|
|
(fetch-and-restore target pathname
|
|
(build-request-headers target (loaded-component-names) _css-hash) 0))
|
|
(swap-rendered-content target rendered pathname))))
|
|
true)
|
|
;; Sync render (data only)
|
|
(let ((rendered (try-eval-content content-src env)))
|
|
(if (nil? rendered)
|
|
(do (log-warn (str "sx:route cached eval failed for " pathname)) false)
|
|
(do
|
|
(log-info (str "sx:route client+cache " pathname))
|
|
(swap-rendered-content target rendered pathname)
|
|
true)))))
|
|
;; Cache miss: fetch, cache, render
|
|
(do
|
|
(log-info (str "sx:route client+data " pathname))
|
|
(resolve-page-data page-name params
|
|
(fn ((data :as dict))
|
|
(page-data-cache-set cache-key data)
|
|
(let ((env (merge closure params data)))
|
|
(if has-io
|
|
;; Async render (data+IO)
|
|
(try-async-eval-content content-src env
|
|
(fn (rendered)
|
|
(if (nil? rendered)
|
|
(do (log-warn (str "sx:route data+async eval failed for " pathname " — server fallback"))
|
|
(fetch-and-restore target pathname
|
|
(build-request-headers target (loaded-component-names) _css-hash) 0))
|
|
(swap-rendered-content target rendered pathname))))
|
|
;; Sync render (data only)
|
|
(let ((rendered (try-eval-content content-src env)))
|
|
(if (nil? rendered)
|
|
(do (log-warn (str "sx:route data eval failed for " pathname " — server fallback"))
|
|
(fetch-and-restore target pathname
|
|
(build-request-headers target (loaded-component-names) _css-hash) 0))
|
|
(swap-rendered-content target rendered pathname)))))))
|
|
true)))
|
|
;; Non-data page
|
|
(if has-io
|
|
;; Async render (IO only, no data)
|
|
(do
|
|
(log-info (str "sx:route client+async " pathname))
|
|
(try-async-eval-content content-src (merge closure params)
|
|
(fn (rendered)
|
|
(if (nil? rendered)
|
|
(do (log-warn (str "sx:route async eval failed for " pathname " — server fallback"))
|
|
(fetch-and-restore target pathname
|
|
(build-request-headers target (loaded-component-names) _css-hash) 0))
|
|
(swap-rendered-content target rendered pathname))))
|
|
true)
|
|
;; Pure page: render immediately
|
|
(let ((env (merge closure params))
|
|
(rendered (try-eval-content content-src env)))
|
|
(if (nil? rendered)
|
|
(do (log-info (str "sx:route server (eval failed) " pathname)) false)
|
|
(do
|
|
(swap-rendered-content target rendered pathname)
|
|
true))))))))))))))))))
|
|
|
|
|
|
(define bind-client-route-link :effects [mutation io]
|
|
(fn (link (href :as string))
|
|
;; Bind a boost link with client-side routing. If the route can be
|
|
;; rendered client-side (pure page, no :data), do so. Otherwise
|
|
;; fall back to standard server fetch via bind-boost-link.
|
|
(bind-client-route-click link href
|
|
(fn ()
|
|
;; Fallback: use standard boost link binding
|
|
(bind-boost-link link href)))))
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; SSE processing
|
|
;; --------------------------------------------------------------------------
|
|
|
|
(define process-sse :effects [mutation io]
|
|
(fn (root)
|
|
;; Find and bind SSE elements
|
|
(for-each
|
|
(fn (el)
|
|
(when (not (is-processed? el "sse"))
|
|
(mark-processed! el "sse")
|
|
(bind-sse el)))
|
|
(dom-query-all (or root (dom-body)) "[sx-sse]"))))
|
|
|
|
|
|
(define bind-sse :effects [mutation io]
|
|
(fn (el)
|
|
;; Connect to SSE endpoint and bind swap handler
|
|
(let ((url (dom-get-attr el "sx-sse")))
|
|
(when url
|
|
(let ((source (event-source-connect url el))
|
|
(event-name (parse-sse-swap el)))
|
|
(event-source-listen source event-name
|
|
(fn ((data :as string))
|
|
(bind-sse-swap el data))))))))
|
|
|
|
|
|
(define bind-sse-swap :effects [mutation io]
|
|
(fn (el (data :as string))
|
|
;; Handle an SSE event: swap data into element
|
|
(let ((target (resolve-target el))
|
|
(swap-spec (parse-swap-spec
|
|
(dom-get-attr el "sx-swap")
|
|
(dom-has-class? (dom-body) "sx-transitions")))
|
|
(swap-style (get swap-spec "style"))
|
|
(use-transition (get swap-spec "transition"))
|
|
(trimmed (trim data)))
|
|
(when (not (empty? trimmed))
|
|
(dispose-islands-in target)
|
|
(if (starts-with? trimmed "(")
|
|
;; SX response
|
|
(let ((rendered (sx-render trimmed))
|
|
(container (dom-create-element "div" nil)))
|
|
(dom-append container rendered)
|
|
(with-transition use-transition
|
|
(fn ()
|
|
(swap-dom-nodes target (children-to-fragment container) swap-style)
|
|
(post-swap target))))
|
|
;; HTML response
|
|
(with-transition use-transition
|
|
(fn ()
|
|
(swap-html-string target trimmed swap-style)
|
|
(post-swap target))))))))
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; Inline event handlers
|
|
;; --------------------------------------------------------------------------
|
|
|
|
(define bind-inline-handlers :effects [mutation io]
|
|
(fn (root)
|
|
;; Find elements with sx-on:* attributes and bind SX event handlers.
|
|
;; Handler bodies are SX expressions evaluated with `event` and `this`
|
|
;; bound in scope. No raw JS — handlers are pure SX.
|
|
(for-each
|
|
(fn (el)
|
|
(for-each
|
|
(fn ((attr :as list))
|
|
(let ((name (first attr))
|
|
(body (nth attr 1)))
|
|
(when (starts-with? name "sx-on:")
|
|
(let ((event-name (slice name 6)))
|
|
(when (not (is-processed? el (str "on:" event-name)))
|
|
(mark-processed! el (str "on:" event-name))
|
|
;; Parse body as SX, bind handler that evaluates it
|
|
(let ((exprs (sx-parse body)))
|
|
(dom-listen el event-name
|
|
(fn (e)
|
|
(let ((handler-env (env-extend (dict))))
|
|
(env-bind! handler-env "event" e)
|
|
(env-bind! handler-env "this" el)
|
|
(env-bind! handler-env "detail" (event-detail e))
|
|
(for-each
|
|
(fn (expr) (eval-expr expr handler-env))
|
|
exprs))))))))))
|
|
(dom-attr-list el)))
|
|
(dom-query-all (or root (dom-body)) "[sx-on\\:]"))))
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; Preload
|
|
;; --------------------------------------------------------------------------
|
|
|
|
(define bind-preload-for :effects [mutation io]
|
|
(fn (el)
|
|
;; Bind preload event listeners based on sx-preload attribute
|
|
(let ((preload-attr (dom-get-attr el "sx-preload")))
|
|
(when preload-attr
|
|
(let ((events (if (= preload-attr "mousedown")
|
|
(list "mousedown" "touchstart")
|
|
(list "mouseover")))
|
|
(debounce-ms (if (= preload-attr "mousedown") 0 100)))
|
|
;; Re-read verb info and headers at preload time, not bind time
|
|
(bind-preload el events debounce-ms
|
|
(fn ()
|
|
(let ((info (get-verb-info el)))
|
|
(when info
|
|
(do-preload (get info "url")
|
|
(build-request-headers el
|
|
(loaded-component-names) _css-hash)))))))))))
|
|
|
|
|
|
(define do-preload :effects [mutation io]
|
|
(fn ((url :as string) (headers :as dict))
|
|
;; Execute a preload fetch into the cache
|
|
(when (nil? (preload-cache-get _preload-cache url))
|
|
(fetch-preload url headers _preload-cache))))
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; Main element processing
|
|
;; --------------------------------------------------------------------------
|
|
|
|
(define VERB_SELECTOR
|
|
(str "[sx-get],[sx-post],[sx-put],[sx-delete],[sx-patch]"))
|
|
|
|
(define process-elements :effects [mutation io]
|
|
(fn (root)
|
|
;; Find all elements with sx-* verb attributes and process them.
|
|
(let ((els (dom-query-all (or root (dom-body)) VERB_SELECTOR)))
|
|
(for-each
|
|
(fn (el)
|
|
(when (not (is-processed? el "verb"))
|
|
(mark-processed! el "verb")
|
|
(process-one el)))
|
|
els))
|
|
;; Also process boost, SSE, inline handlers, emit attributes
|
|
(process-boosted root)
|
|
(process-sse root)
|
|
(bind-inline-handlers root)
|
|
(process-emit-elements root)))
|
|
|
|
|
|
(define process-one :effects [mutation io]
|
|
(fn (el)
|
|
;; Process a single element with an sx-* verb attribute
|
|
(let ((verb-info (get-verb-info el)))
|
|
(when verb-info
|
|
;; Check for disabled
|
|
(when (not (dom-has-attr? el "sx-disable"))
|
|
(bind-triggers el verb-info)
|
|
(bind-preload-for el))))))
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; data-sx-emit — auto-dispatch custom events for lake→island bridge
|
|
;; --------------------------------------------------------------------------
|
|
;;
|
|
;; Elements with data-sx-emit="event-name" get a click listener that
|
|
;; dispatches a CustomEvent with that name. Optional data-sx-emit-detail
|
|
;; provides JSON payload.
|
|
;;
|
|
;; Example:
|
|
;; <button data-sx-emit="cart:add"
|
|
;; data-sx-emit-detail='{"id":42,"name":"Widget"}'>
|
|
;; Add to Cart
|
|
;; </button>
|
|
;;
|
|
;; On click → dispatches CustomEvent "cart:add" with detail {id:42, name:"Widget"}
|
|
;; The event bubbles up to the island container where bridge-event catches it.
|
|
|
|
(define process-emit-elements :effects [mutation io]
|
|
(fn (root)
|
|
(let ((els (dom-query-all (or root (dom-body)) "[data-sx-emit]")))
|
|
(for-each
|
|
(fn (el)
|
|
(when (not (is-processed? el "emit"))
|
|
(mark-processed! el "emit")
|
|
(let ((event-name (dom-get-attr el "data-sx-emit")))
|
|
(when event-name
|
|
(dom-listen el "click"
|
|
(fn (e)
|
|
(let ((detail-json (dom-get-attr el "data-sx-emit-detail"))
|
|
(detail (if detail-json (json-parse detail-json) (dict))))
|
|
(dom-dispatch el event-name detail))))))))
|
|
els))))
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; History: popstate handler
|
|
;; --------------------------------------------------------------------------
|
|
|
|
(define handle-popstate :effects [mutation io]
|
|
(fn ((scrollY :as number))
|
|
;; Handle browser back/forward navigation.
|
|
;; Derive target from [sx-boost] container or fall back to #main-panel.
|
|
;; Try client-side route first, fall back to server fetch.
|
|
(let ((url (browser-location-href))
|
|
(boost-el (dom-query "[sx-boost]"))
|
|
(target-sel (if boost-el
|
|
(let ((attr (dom-get-attr boost-el "sx-boost")))
|
|
(if (and attr (not (= attr "true"))) attr nil))
|
|
nil))
|
|
;; Fall back to #main-panel if no sx-boost target
|
|
(target-sel (or target-sel "#main-panel"))
|
|
(target (dom-query target-sel))
|
|
(pathname (url-pathname url)))
|
|
(when target
|
|
(if (try-client-route pathname target-sel)
|
|
(browser-scroll-to 0 scrollY)
|
|
(let ((headers (build-request-headers target
|
|
(loaded-component-names) _css-hash)))
|
|
(fetch-and-restore target url headers scrollY)))))))
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; Initialization
|
|
;; --------------------------------------------------------------------------
|
|
|
|
(define engine-init :effects [mutation io]
|
|
(fn ()
|
|
;; Initialize: CSS tracking, scripts, hydrate, process.
|
|
(do
|
|
(init-css-tracking)
|
|
(sx-process-scripts nil)
|
|
(sx-hydrate nil)
|
|
(process-elements nil))))
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; Platform interface — Orchestration
|
|
;; --------------------------------------------------------------------------
|
|
;;
|
|
;; From engine.sx (pure logic):
|
|
;; parse-trigger-spec, default-trigger, get-verb-info, classify-trigger,
|
|
;; build-request-headers, process-response-headers, parse-swap-spec,
|
|
;; parse-retry-spec, next-retry-ms, resolve-target, apply-optimistic,
|
|
;; revert-optimistic, find-oob-swaps, swap-dom-nodes, swap-html-string,
|
|
;; morph-children, handle-history, preload-cache-get, preload-cache-set,
|
|
;; should-boost-link?, should-boost-form?, parse-sse-swap, filter-params,
|
|
;; PRELOAD_TTL
|
|
;;
|
|
;; === Promises ===
|
|
;; (promise-resolve val) → resolved Promise
|
|
;; (promise-catch p fn) → p.catch(fn)
|
|
;;
|
|
;; === Abort controllers ===
|
|
;; (abort-previous el) → abort + remove controller for element
|
|
;; (track-controller el ctrl) → store controller for element
|
|
;; (abort-previous-target el) → abort + remove controller for target element
|
|
;; (track-controller-target el c) → store controller keyed by target element
|
|
;; (new-abort-controller) → new AbortController()
|
|
;; (controller-signal ctrl) → ctrl.signal
|
|
;; (abort-error? err) → boolean (err.name === "AbortError")
|
|
;;
|
|
;; === Timers ===
|
|
;; (set-timeout fn ms) → timer id
|
|
;; (set-interval fn ms) → timer id
|
|
;; (clear-timeout id) → void
|
|
;; (request-animation-frame fn) → void
|
|
;;
|
|
;; === Fetch ===
|
|
;; (fetch-request config success-fn error-fn) → Promise
|
|
;; config: dict with url, method, headers, body, signal, preloaded,
|
|
;; cross-origin
|
|
;; success-fn: (fn (resp-ok status get-header text) ...)
|
|
;; error-fn: (fn (err) ...)
|
|
;; (fetch-location url) → fetch URL and swap to boost target
|
|
;; (fetch-and-restore main url headers scroll-y) → popstate fetch+swap
|
|
;; (fetch-preload url headers cache) → preload into cache
|
|
;;
|
|
;; === Request body ===
|
|
;; (build-request-body el method url) → dict with body, url, content-type
|
|
;;
|
|
;; === Loading state ===
|
|
;; (show-indicator el) → indicator state (or nil)
|
|
;; (disable-elements el) → list of disabled elements
|
|
;; (clear-loading-state el indicator disabled-elts) → void
|
|
;;
|
|
;; === DOM extras (beyond adapter-dom.sx) ===
|
|
;; (dom-query-by-id id) → Element or nil
|
|
;; (dom-matches? el sel) → boolean
|
|
;; (dom-closest el sel) → Element or nil
|
|
;; (dom-body) → document.body
|
|
;; (dom-has-class? el cls) → boolean
|
|
;; (dom-append-to-head el) → void
|
|
;; (dom-parse-html-document text) → parsed document (DOMParser)
|
|
;; (dom-outer-html el) → string
|
|
;; (dom-body-inner-html doc) → string
|
|
;; (dom-tag-name el) → uppercase tag name
|
|
;;
|
|
;; === Events ===
|
|
;; (dom-dispatch el name detail) → boolean (dispatchEvent)
|
|
;; (dom-add-listener el event fn opts) → void
|
|
;; (prevent-default e) → void
|
|
;; (element-value el) → el.value or nil
|
|
;;
|
|
;; === Validation ===
|
|
;; (validate-for-request el) → boolean
|
|
;;
|
|
;; === View Transitions ===
|
|
;; (with-transition enabled fn) → void
|
|
;;
|
|
;; === IntersectionObserver ===
|
|
;; (observe-intersection el fn once? delay) → void
|
|
;;
|
|
;; === EventSource ===
|
|
;; (event-source-connect url el) → EventSource (with cleanup)
|
|
;; (event-source-listen source event fn) → void
|
|
;;
|
|
;; === Boost bindings ===
|
|
;; (bind-boost-link el href) → void (click handler + pushState)
|
|
;; (bind-boost-form form method action) → void (submit handler)
|
|
;; (bind-client-route-click link href fallback-fn) → void (client route click handler)
|
|
;;
|
|
;; === Inline handlers ===
|
|
;; (sx-on:* handlers are now evaluated as SX, not delegated to platform)
|
|
;;
|
|
;; === Preload ===
|
|
;; (bind-preload el events debounce-ms fn) → void
|
|
;;
|
|
;; === Processing markers ===
|
|
;; (mark-processed! el key) → void
|
|
;; (is-processed? el key) → boolean
|
|
;;
|
|
;; === Script handling ===
|
|
;; (create-script-clone script) → live script Element
|
|
;;
|
|
;; === SX API (references to Sx/SxRef object) ===
|
|
;; (sx-render source) → DOM nodes
|
|
;; (sx-process-scripts root) → void
|
|
;; (sx-hydrate root) → void
|
|
;; (loaded-component-names) → list of ~name strings
|
|
;;
|
|
;; === Response processing ===
|
|
;; (strip-component-scripts text) → cleaned text
|
|
;; (extract-response-css text) → cleaned text
|
|
;; (select-from-container el sel) → DocumentFragment
|
|
;; (children-to-fragment el) → DocumentFragment
|
|
;; (select-html-from-doc doc sel) → HTML string
|
|
;;
|
|
;; === Parsing ===
|
|
;; (try-parse-json s) → parsed value or nil
|
|
;;
|
|
;; === Client-side routing ===
|
|
;; (try-eval-content source env) → DOM node or nil (catches eval errors)
|
|
;; (try-async-eval-content source env callback) → void; async render,
|
|
;; calls (callback rendered-or-nil). Used for pages with IO deps.
|
|
;; (register-io-deps names) → void; ensure each IO name is registered
|
|
;; as a proxied IO primitive on the client. Idempotent.
|
|
;; (url-pathname href) → extract pathname from URL string
|
|
;; (resolve-page-data name params cb) → void; resolves data for a named page.
|
|
;; Platform decides transport (HTTP, cache, IPC, etc). Calls (cb data-dict)
|
|
;; 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
|
|
;;
|
|
;; From router.sx:
|
|
;; (find-matching-route path routes) → matching entry with params, or nil
|
|
;; (parse-route-pattern pattern) → parsed pattern segments
|
|
;;
|
|
;; === Browser (via engine.sx) ===
|
|
;; (browser-location-href) → current URL string
|
|
;; (browser-navigate url) → void
|
|
;; (browser-reload) → void
|
|
;; (browser-scroll-to x y) → void
|
|
;; (browser-media-matches? query) → boolean
|
|
;; (browser-confirm msg) → boolean
|
|
;; (browser-prompt msg) → string or nil
|
|
;; (csrf-token) → string
|
|
;; (cross-origin? url) → boolean
|
|
;; (now-ms) → timestamp ms
|
|
;;
|
|
;; === 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)
|
|
;; --------------------------------------------------------------------------
|