Fix signal-add-sub! losing subscribers after remove, fix build pipeline
signal-add-sub! used (append! subscribers f) which returns a new list for immutable List but discards the result — after signal-remove-sub! replaces the subscribers list via dict-set!, re-adding subscribers silently fails. Counter island only worked once (0→1 then stuck). Fix: use (dict-set! s "subscribers" (append ...)) to explicitly update the dict field, matching signal-remove-sub!'s pattern. Build pipeline fixes: - sx-build-all.sh now bundles spec→dist and recompiles .sxbc bytecode - compile-modules.js syncs .sx source files alongside .sxbc to wasm/sx/ - Per-file cache busting: wasm, platform JS, and sxbc each get own hash - bundle.sh adds cssx.sx to dist Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -180,8 +180,12 @@
|
||||
|
||||
var _baseUrl = "";
|
||||
|
||||
// Detect base URL from current script
|
||||
// Detect base URL and cache-bust params from current script tag.
|
||||
// _cacheBust comes from the script's own ?v= query string (used for .sx source fallback).
|
||||
// _sxbcCacheBust comes from data-sxbc-hash attribute — a separate content hash
|
||||
// covering all .sxbc files so each file gets its own correct cache buster.
|
||||
var _cacheBust = "";
|
||||
var _sxbcCacheBust = "";
|
||||
(function() {
|
||||
if (typeof document !== "undefined") {
|
||||
var scripts = document.getElementsByTagName("script");
|
||||
@@ -191,6 +195,8 @@
|
||||
_baseUrl = src.substring(0, src.lastIndexOf("/") + 1);
|
||||
var qi = src.indexOf("?");
|
||||
if (qi !== -1) _cacheBust = src.substring(qi);
|
||||
var sxbcHash = scripts[i].getAttribute("data-sxbc-hash");
|
||||
if (sxbcHash) _sxbcCacheBust = "?v=" + sxbcHash;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -239,7 +245,7 @@
|
||||
|
||||
// Try .sxbc (SX s-expression format, loaded via load-sxbc primitive)
|
||||
var sxbcPath = path.replace(/\.sx$/, '.sxbc');
|
||||
var sxbcUrl = _baseUrl + sxbcPath + _cacheBust;
|
||||
var sxbcUrl = _baseUrl + sxbcPath + _sxbcCacheBust;
|
||||
try {
|
||||
var xhr2 = new XMLHttpRequest();
|
||||
xhr2.open("GET", sxbcUrl, false);
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
(define make-signal (fn (value) (dict "__signal" true "value" value "subscribers" (list) "deps" (list))))
|
||||
(define
|
||||
make-signal
|
||||
(fn
|
||||
(value)
|
||||
(dict "__signal" true "value" value "subscribers" (list) "deps" (list))))
|
||||
|
||||
(define signal? (fn (x) (and (dict? x) (has-key? x "__signal"))))
|
||||
|
||||
@@ -8,38 +12,184 @@
|
||||
|
||||
(define signal-subscribers (fn (s) (get s "subscribers")))
|
||||
|
||||
(define signal-add-sub! (fn (s f) (when (not (contains? (get s "subscribers") f)) (append! (get s "subscribers") f))))
|
||||
(define
|
||||
signal-add-sub!
|
||||
(fn
|
||||
(s f)
|
||||
(when
|
||||
(not (contains? (get s "subscribers") f))
|
||||
(dict-set! s "subscribers" (append (get s "subscribers") (list f))))))
|
||||
|
||||
(define signal-remove-sub! (fn (s f) (dict-set! s "subscribers" (filter (fn (sub) (not (identical? sub f))) (get s "subscribers")))))
|
||||
(define
|
||||
signal-remove-sub!
|
||||
(fn
|
||||
(s f)
|
||||
(dict-set!
|
||||
s
|
||||
"subscribers"
|
||||
(filter (fn (sub) (not (identical? sub f))) (get s "subscribers")))))
|
||||
|
||||
(define signal-deps (fn (s) (get s "deps")))
|
||||
|
||||
(define signal-set-deps! (fn (s deps) (dict-set! s "deps" deps)))
|
||||
|
||||
(define signal :effects () (fn ((initial-value :as any)) (make-signal initial-value)))
|
||||
(define
|
||||
signal
|
||||
:effects ()
|
||||
(fn ((initial-value :as any)) (make-signal initial-value)))
|
||||
|
||||
(define deref :effects () (fn ((s :as any)) (if (not (signal? s)) s (let ((ctx (context "sx-reactive" nil))) (when ctx (let ((dep-list (get ctx "deps")) (notify-fn (get ctx "notify"))) (when (not (contains? dep-list s)) (append! dep-list s) (signal-add-sub! s notify-fn)))) (signal-value s)))))
|
||||
(define
|
||||
deref
|
||||
:effects ()
|
||||
(fn
|
||||
((s :as any))
|
||||
(if
|
||||
(not (signal? s))
|
||||
s
|
||||
(let
|
||||
((ctx (context "sx-reactive" nil)))
|
||||
(when
|
||||
ctx
|
||||
(let
|
||||
((dep-list (get ctx "deps")) (notify-fn (get ctx "notify")))
|
||||
(when
|
||||
(not (contains? dep-list s))
|
||||
(append! dep-list s)
|
||||
(signal-add-sub! s notify-fn))))
|
||||
(signal-value s)))))
|
||||
|
||||
(define reset! :effects (mutation) (fn ((s :as signal) value) (when (signal? s) (let ((old (signal-value s))) (when (not (identical? old value)) (signal-set-value! s value) (notify-subscribers s))))))
|
||||
(define
|
||||
reset!
|
||||
:effects (mutation)
|
||||
(fn
|
||||
((s :as signal) value)
|
||||
(when
|
||||
(signal? s)
|
||||
(let
|
||||
((old (signal-value s)))
|
||||
(when
|
||||
(not (identical? old value))
|
||||
(signal-set-value! s value)
|
||||
(notify-subscribers s))))))
|
||||
|
||||
(define swap! :effects (mutation) (fn ((s :as signal) (f :as lambda) &rest args) (when (signal? s) (let ((old (signal-value s)) (new-val (trampoline (apply f (cons old args))))) (when (not (identical? old new-val)) (signal-set-value! s new-val) (notify-subscribers s))))))
|
||||
(define
|
||||
swap!
|
||||
:effects (mutation)
|
||||
(fn
|
||||
((s :as signal) (f :as lambda) &rest args)
|
||||
(when
|
||||
(signal? s)
|
||||
(let
|
||||
((old (signal-value s))
|
||||
(new-val (trampoline (apply f (cons old args)))))
|
||||
(when
|
||||
(not (identical? old new-val))
|
||||
(signal-set-value! s new-val)
|
||||
(notify-subscribers s))))))
|
||||
|
||||
(define computed :effects (mutation) (fn ((compute-fn :as lambda)) (let ((s (make-signal nil)) (deps (list)) (compute-ctx nil)) (let ((recompute (fn () (for-each (fn ((dep :as signal)) (signal-remove-sub! dep recompute)) (signal-deps s)) (signal-set-deps! s (list)) (let ((ctx (dict "deps" (list) "notify" recompute))) (scope-push! "sx-reactive" ctx) (let ((new-val (cek-call compute-fn nil))) (scope-pop! "sx-reactive") (signal-set-deps! s (get ctx "deps")) (let ((old (signal-value s))) (signal-set-value! s new-val) (when (not (identical? old new-val)) (notify-subscribers s)))))))) (recompute) (register-in-scope (fn () (dispose-computed s))) s))))
|
||||
(define
|
||||
computed
|
||||
:effects (mutation)
|
||||
(fn
|
||||
((compute-fn :as lambda))
|
||||
(let
|
||||
((s (make-signal nil)) (deps (list)) (compute-ctx nil))
|
||||
(let
|
||||
((recompute (fn () (for-each (fn ((dep :as signal)) (signal-remove-sub! dep recompute)) (signal-deps s)) (signal-set-deps! s (list)) (let ((ctx (dict "deps" (list) "notify" recompute))) (scope-push! "sx-reactive" ctx) (let ((new-val (cek-call compute-fn nil))) (scope-pop! "sx-reactive") (signal-set-deps! s (get ctx "deps")) (let ((old (signal-value s))) (signal-set-value! s new-val) (when (not (identical? old new-val)) (notify-subscribers s))))))))
|
||||
(recompute)
|
||||
(register-in-scope (fn () (dispose-computed s)))
|
||||
s))))
|
||||
|
||||
(define effect :effects (mutation) (fn ((effect-fn :as lambda)) (let ((deps (list)) (disposed false) (cleanup-fn nil)) (let ((run-effect (fn () (when (not disposed) (when cleanup-fn (cek-call cleanup-fn nil)) (for-each (fn ((dep :as signal)) (signal-remove-sub! dep run-effect)) deps) (set! deps (list)) (let ((ctx (dict "deps" (list) "notify" run-effect))) (scope-push! "sx-reactive" ctx) (let ((result (cek-call effect-fn nil))) (scope-pop! "sx-reactive") (set! deps (get ctx "deps")) (when (callable? result) (set! cleanup-fn result)))))))) (run-effect) (let ((dispose-fn (fn () (set! disposed true) (when cleanup-fn (cek-call cleanup-fn nil)) (for-each (fn ((dep :as signal)) (signal-remove-sub! dep run-effect)) deps) (set! deps (list))))) (register-in-scope dispose-fn) dispose-fn)))))
|
||||
(define
|
||||
effect
|
||||
:effects (mutation)
|
||||
(fn
|
||||
((effect-fn :as lambda))
|
||||
(let
|
||||
((deps (list)) (disposed false) (cleanup-fn nil))
|
||||
(let
|
||||
((run-effect (fn () (when (not disposed) (when cleanup-fn (cek-call cleanup-fn nil)) (for-each (fn ((dep :as signal)) (signal-remove-sub! dep run-effect)) deps) (set! deps (list)) (let ((ctx (dict "deps" (list) "notify" run-effect))) (scope-push! "sx-reactive" ctx) (let ((result (cek-call effect-fn nil))) (scope-pop! "sx-reactive") (set! deps (get ctx "deps")) (when (callable? result) (set! cleanup-fn result))))))))
|
||||
(run-effect)
|
||||
(let
|
||||
((dispose-fn (fn () (set! disposed true) (when cleanup-fn (cek-call cleanup-fn nil)) (for-each (fn ((dep :as signal)) (signal-remove-sub! dep run-effect)) deps) (set! deps (list)))))
|
||||
(register-in-scope dispose-fn)
|
||||
dispose-fn)))))
|
||||
|
||||
(define *batch-depth* 0)
|
||||
|
||||
(define *batch-queue* (list))
|
||||
|
||||
(define batch :effects (mutation) (fn ((thunk :as lambda)) (set! *batch-depth* (+ *batch-depth* 1)) (cek-call thunk nil) (set! *batch-depth* (- *batch-depth* 1)) (when (= *batch-depth* 0) (let ((queue *batch-queue*)) (set! *batch-queue* (list)) (let ((seen (list)) (pending (list))) (for-each (fn ((s :as signal)) (for-each (fn ((sub :as lambda)) (when (not (contains? seen sub)) (append! seen sub) (append! pending sub))) (signal-subscribers s))) queue) (for-each (fn ((sub :as lambda)) (sub)) pending))))))
|
||||
(define
|
||||
batch
|
||||
:effects (mutation)
|
||||
(fn
|
||||
((thunk :as lambda))
|
||||
(set! *batch-depth* (+ *batch-depth* 1))
|
||||
(cek-call thunk nil)
|
||||
(set! *batch-depth* (- *batch-depth* 1))
|
||||
(when
|
||||
(= *batch-depth* 0)
|
||||
(let
|
||||
((queue *batch-queue*))
|
||||
(set! *batch-queue* (list))
|
||||
(let
|
||||
((seen (list)) (pending (list)))
|
||||
(for-each
|
||||
(fn
|
||||
((s :as signal))
|
||||
(for-each
|
||||
(fn
|
||||
((sub :as lambda))
|
||||
(when
|
||||
(not (contains? seen sub))
|
||||
(append! seen sub)
|
||||
(append! pending sub)))
|
||||
(signal-subscribers s)))
|
||||
queue)
|
||||
(for-each (fn ((sub :as lambda)) (sub)) pending))))))
|
||||
|
||||
(define notify-subscribers :effects (mutation) (fn ((s :as signal)) (if (> *batch-depth* 0) (when (not (contains? *batch-queue* s)) (append! *batch-queue* s)) (flush-subscribers s))))
|
||||
(define
|
||||
notify-subscribers
|
||||
:effects (mutation)
|
||||
(fn
|
||||
((s :as signal))
|
||||
(if
|
||||
(> *batch-depth* 0)
|
||||
(when (not (contains? *batch-queue* s)) (append! *batch-queue* s))
|
||||
(flush-subscribers s))))
|
||||
|
||||
(define flush-subscribers :effects (mutation) (fn ((s :as signal)) (for-each (fn ((sub :as lambda)) (sub)) (signal-subscribers s))))
|
||||
(define
|
||||
flush-subscribers
|
||||
:effects (mutation)
|
||||
(fn
|
||||
((s :as dict))
|
||||
(for-each (fn ((sub :as lambda)) (sub)) (signal-subscribers s))))
|
||||
|
||||
(define dispose-computed :effects (mutation) (fn ((s :as signal)) (when (signal? s) (for-each (fn ((dep :as signal)) (signal-remove-sub! dep nil)) (signal-deps s)) (signal-set-deps! s (list)))))
|
||||
(define
|
||||
dispose-computed
|
||||
:effects (mutation)
|
||||
(fn
|
||||
((s :as signal))
|
||||
(when
|
||||
(signal? s)
|
||||
(for-each
|
||||
(fn ((dep :as signal)) (signal-remove-sub! dep nil))
|
||||
(signal-deps s))
|
||||
(signal-set-deps! s (list)))))
|
||||
|
||||
(define with-island-scope :effects (mutation) (fn ((scope-fn :as lambda) (body-fn :as lambda)) (scope-push! "sx-island-scope" scope-fn) (let ((result (body-fn))) (scope-pop! "sx-island-scope") result)))
|
||||
(define
|
||||
with-island-scope
|
||||
:effects (mutation)
|
||||
(fn
|
||||
((scope-fn :as lambda) (body-fn :as lambda))
|
||||
(scope-push! "sx-island-scope" scope-fn)
|
||||
(let ((result (body-fn))) (scope-pop! "sx-island-scope") result)))
|
||||
|
||||
(define register-in-scope :effects (mutation) (fn ((disposable :as lambda)) (let ((collector (scope-peek "sx-island-scope"))) (when collector (cek-call collector (list disposable))))))
|
||||
(define
|
||||
register-in-scope
|
||||
:effects (mutation)
|
||||
(fn
|
||||
((disposable :as lambda))
|
||||
(let
|
||||
((collector (scope-peek "sx-island-scope")))
|
||||
(when collector (cek-call collector (list disposable))))))
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -883,7 +883,9 @@ def _get_shell_static() -> dict[str, Any]:
|
||||
pages_sx=pages_sx,
|
||||
sx_css=sx_css,
|
||||
sx_css_classes=sx_css_classes,
|
||||
wasm_hash=_wasm_hash("sx_browser.bc.js"),
|
||||
wasm_hash=_wasm_hash("sx_browser.bc.wasm.js"),
|
||||
platform_hash=_wasm_hash("sx-platform-2.js"),
|
||||
sxbc_hash=_sxbc_hash(),
|
||||
asset_url=_ca.config.get("ASSET_URL", "/static"),
|
||||
inline_css=_shell_cfg.get("inline_css"),
|
||||
inline_head_js=_shell_cfg.get("inline_head_js"),
|
||||
@@ -1032,7 +1034,9 @@ def sx_page_streaming_parts(ctx: dict, page_html: str, *,
|
||||
from quart import current_app
|
||||
pages_sx = _build_pages_sx(current_app.name)
|
||||
|
||||
wasm_hash = _wasm_hash("sx_browser.bc.js")
|
||||
wasm_hash = _wasm_hash("sx_browser.bc.wasm.js")
|
||||
platform_hash = _wasm_hash("sx-platform-2.js")
|
||||
sxbc_hash = _sxbc_hash()
|
||||
|
||||
# Shell: head + body with server-rendered HTML (not SX mount script)
|
||||
shell = (
|
||||
@@ -1074,7 +1078,7 @@ def sx_page_streaming_parts(ctx: dict, page_html: str, *,
|
||||
tail = (
|
||||
_SX_STREAMING_BOOTSTRAP + '\n'
|
||||
+ f'<script src="{asset_url}/wasm/sx_browser.bc.wasm.js?v={wasm_hash}"></script>\n'
|
||||
f'<script src="{asset_url}/wasm/sx-platform.js?v={wasm_hash}"></script>\n'
|
||||
f'<script src="{asset_url}/wasm/sx-platform-2.js?v={platform_hash}" data-sxbc-hash="{sxbc_hash}"></script>\n'
|
||||
)
|
||||
|
||||
return shell, tail
|
||||
@@ -1124,6 +1128,21 @@ def _wasm_hash(filename: str) -> str:
|
||||
return _SCRIPT_HASH_CACHE[key]
|
||||
|
||||
|
||||
def _sxbc_hash() -> str:
|
||||
"""Compute combined MD5 hash of all .sxbc files, cached for process lifetime."""
|
||||
key = "sxbc/_combined"
|
||||
if key not in _SCRIPT_HASH_CACHE:
|
||||
try:
|
||||
sxbc_dir = Path(__file__).resolve().parent.parent / "static" / "wasm" / "sx"
|
||||
h = hashlib.md5()
|
||||
for p in sorted(sxbc_dir.glob("*.sxbc")):
|
||||
h.update(p.read_bytes())
|
||||
_SCRIPT_HASH_CACHE[key] = h.hexdigest()[:8]
|
||||
except OSError:
|
||||
_SCRIPT_HASH_CACHE[key] = "dev"
|
||||
return _SCRIPT_HASH_CACHE[key]
|
||||
|
||||
|
||||
def _get_csrf_token() -> str:
|
||||
"""Get the CSRF token from the current request context."""
|
||||
try:
|
||||
|
||||
@@ -190,7 +190,8 @@ class OcamlBridge:
|
||||
# Only inject small, safe values as kernel variables.
|
||||
# Large/complex blobs use placeholder tokens at render time.
|
||||
for key in ("component_hash", "sx_css_classes", "asset_url",
|
||||
"sx_js_hash", "body_js_hash", "wasm_hash"):
|
||||
"sx_js_hash", "body_js_hash", "wasm_hash",
|
||||
"platform_hash", "sxbc_hash"):
|
||||
val = static.get(key) or ""
|
||||
var = f"__shell-{key.replace('_', '-')}"
|
||||
defn = f'(define {var} "{_escape(str(val))}")'
|
||||
@@ -269,6 +270,7 @@ class OcamlBridge:
|
||||
placeholders = {}
|
||||
static_keys = {"component_hash", "sx_css_classes", "asset_url",
|
||||
"sx_js_hash", "body_js_hash", "wasm_hash",
|
||||
"platform_hash", "sxbc_hash",
|
||||
"head_scripts", "body_scripts"}
|
||||
# page_source is SX wire format that may contain \" escapes.
|
||||
# Send via binary blob protocol to avoid double-escaping
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
(body-html :as string?)
|
||||
(asset-url :as string)
|
||||
(wasm-hash :as string?)
|
||||
(platform-hash :as string?)
|
||||
(sxbc-hash :as string?)
|
||||
(inline-css :as string?)
|
||||
(inline-head-js :as string?)
|
||||
(init-sx :as string?))
|
||||
@@ -74,8 +76,12 @@
|
||||
:type "text/sx"
|
||||
:data-mount "#sx-root"
|
||||
(raw! (or page-sx "")))
|
||||
(let
|
||||
((wv (or wasm-hash "0")))
|
||||
(<>
|
||||
(script :src (str asset-url "/wasm/sx_browser.bc.wasm.js?v=" wv))
|
||||
(script :src (str asset-url "/wasm/sx-platform-2.js?v=" wv))))))))
|
||||
(<>
|
||||
(script
|
||||
:src (str
|
||||
asset-url
|
||||
"/wasm/sx_browser.bc.wasm.js?v="
|
||||
(or wasm-hash "0")))
|
||||
(script
|
||||
:src (str asset-url "/wasm/sx-platform-2.js?v=" (or platform-hash "0"))
|
||||
:data-sxbc-hash (or sxbc-hash "0")))))))
|
||||
|
||||
Reference in New Issue
Block a user