From c55f0956bc9bc16233f7f2b3b58eafd54541ad02 Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 8 Mar 2026 11:13:18 +0000 Subject: [PATCH] Bootstrap stores + event bridge, add island hydration to boot.sx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - signals.sx: fix has? → has-key?, add def-store/use-store/clear-stores (L3 named stores), emit-event/on-event/bridge-event (event bridge) - boot.sx: add sx-hydrate-islands, hydrate-island, dispose-island for client-side island hydration from SSR output - bootstrap_js.py: add RENAMES, platform fns (domListen, eventDetail, domGetData, jsonParse), public API exports for all new functions - bootstrap_py.py: add RENAMES, server-side no-op stubs for DOM events - Regenerate sx-ref.js (with boot adapter) and sx_ref.py - Update reactive-islands status: hydration, stores, bridge all spec'd Co-Authored-By: Claude Opus 4.6 --- shared/static/scripts/sx-ref.js | 430 +++++++++++++++++++++++++++++++- shared/sx/ref/boot.sx | 111 ++++++++- shared/sx/ref/bootstrap_js.py | 38 +++ shared/sx/ref/bootstrap_py.py | 21 ++ shared/sx/ref/signals.sx | 4 +- shared/sx/ref/sx_ref.py | 58 +++-- sx/sx/reactive-islands.sx | 4 +- 7 files changed, 633 insertions(+), 33 deletions(-) diff --git a/shared/static/scripts/sx-ref.js b/shared/static/scripts/sx-ref.js index 2a17738..85ca7b8 100644 --- a/shared/static/scripts/sx-ref.js +++ b/shared/static/scripts/sx-ref.js @@ -14,7 +14,7 @@ // ========================================================================= var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } }); - var SX_VERSION = "2026-03-08T10:13:40Z"; + var SX_VERSION = "2026-03-08T11:12:31Z"; function isNil(x) { return x === NIL || x === null || x === undefined; } function isSxTruthy(x) { return x !== false && !isNil(x); } @@ -572,6 +572,92 @@ return makeThunk(componentBody(comp), local); }; + // ========================================================================= + // Platform: deps module — component dependency analysis + // ========================================================================= + + function componentDeps(c) { + return c.deps ? c.deps.slice() : []; + } + + function componentSetDeps(c, deps) { + c.deps = deps; + } + + function componentCssClasses(c) { + return c.cssClasses ? c.cssClasses.slice() : []; + } + + function envComponents(env) { + var names = []; + for (var k in env) { + var v = env[k]; + if (v && (v._component || v._macro)) names.push(k); + } + return names; + } + + function regexFindAll(pattern, source) { + var re = new RegExp(pattern, "g"); + var results = []; + var m; + while ((m = re.exec(source)) !== null) { + if (m[1] !== undefined) results.push(m[1]); + else results.push(m[0]); + } + return results; + } + + function scanCssClasses(source) { + var classes = {}; + var result = []; + var m; + var re1 = /:class\s+"([^"]*)"/g; + while ((m = re1.exec(source)) !== null) { + var parts = m[1].split(/\s+/); + for (var i = 0; i < parts.length; i++) { + if (parts[i] && !classes[parts[i]]) { + classes[parts[i]] = true; + result.push(parts[i]); + } + } + } + var re2 = /:class\s+\(str\s+((?:"[^"]*"\s*)+)\)/g; + while ((m = re2.exec(source)) !== null) { + var re3 = /"([^"]*)"/g; + var m2; + while ((m2 = re3.exec(m[1])) !== null) { + var parts2 = m2[1].split(/\s+/); + for (var j = 0; j < parts2.length; j++) { + if (parts2[j] && !classes[parts2[j]]) { + classes[parts2[j]] = true; + result.push(parts2[j]); + } + } + } + } + var re4 = /;;\s*@css\s+(.+)/g; + while ((m = re4.exec(source)) !== null) { + var parts3 = m[1].split(/\s+/); + for (var k = 0; k < parts3.length; k++) { + if (parts3[k] && !classes[parts3[k]]) { + classes[parts3[k]] = true; + result.push(parts3[k]); + } + } + } + return result; + } + + function componentIoRefs(c) { + return c.ioRefs ? c.ioRefs.slice() : []; + } + + function componentSetIoRefs(c, refs) { + c.ioRefs = refs; + } + + // ========================================================================= // Platform interface — Parser // ========================================================================= @@ -2179,6 +2265,93 @@ return (function() { })() : NIL); })(); }; + // _optimistic-snapshots + var _optimisticSnapshots = {}; + + // optimistic-cache-update + var optimisticCacheUpdate = function(cacheKey, mutator) { return (function() { + var cached = pageDataCacheGet(cacheKey); + return (isSxTruthy(cached) ? (function() { + var predicted = mutator(cached); + _optimisticSnapshots[cacheKey] = cached; + pageDataCacheSet(cacheKey, predicted); + return predicted; +})() : NIL); +})(); }; + + // optimistic-cache-revert + var optimisticCacheRevert = function(cacheKey) { return (function() { + var snapshot = get(_optimisticSnapshots, cacheKey); + return (isSxTruthy(snapshot) ? (pageDataCacheSet(cacheKey, snapshot), dictDelete(_optimisticSnapshots, cacheKey), snapshot) : NIL); +})(); }; + + // optimistic-cache-confirm + var optimisticCacheConfirm = function(cacheKey) { return dictDelete(_optimisticSnapshots, cacheKey); }; + + // submit-mutation + var submitMutation = function(pageName, params, actionName, payload, mutatorFn, onComplete) { return (function() { + var cacheKey = pageDataCacheKey(pageName, params); + var predicted = optimisticCacheUpdate(cacheKey, mutatorFn); + if (isSxTruthy(predicted)) { + tryRerenderPage(pageName, params, predicted); +} + return executeAction(actionName, payload, function(result) { if (isSxTruthy(result)) { + pageDataCacheSet(cacheKey, result); +} +optimisticCacheConfirm(cacheKey); +if (isSxTruthy(result)) { + tryRerenderPage(pageName, params, result); +} +logInfo((String("sx:optimistic confirmed ") + String(pageName))); +return (isSxTruthy(onComplete) ? onComplete("confirmed") : NIL); }, function(error) { return (function() { + var reverted = optimisticCacheRevert(cacheKey); + if (isSxTruthy(reverted)) { + tryRerenderPage(pageName, params, reverted); +} + logWarn((String("sx:optimistic reverted ") + String(pageName) + String(": ") + String(error))); + return (isSxTruthy(onComplete) ? onComplete("reverted") : NIL); +})(); }); +})(); }; + + // _is-online + var _isOnline = true; + + // _offline-queue + var _offlineQueue = []; + + // offline-is-online? + var offlineIsOnline_p = function() { return _isOnline; }; + + // offline-set-online! + var offlineSetOnline_b = function(val) { return (_isOnline = val); }; + + // offline-queue-mutation + var offlineQueueMutation = function(actionName, payload, pageName, params, mutatorFn) { return (function() { + var cacheKey = pageDataCacheKey(pageName, params); + var entry = {["action"]: actionName, ["payload"]: payload, ["page"]: pageName, ["params"]: params, ["timestamp"]: nowMs(), ["status"]: "pending"}; + _offlineQueue.push(entry); + (function() { + var predicted = optimisticCacheUpdate(cacheKey, mutatorFn); + return (isSxTruthy(predicted) ? tryRerenderPage(pageName, params, predicted) : NIL); +})(); + logInfo((String("sx:offline queued ") + String(actionName) + String(" (") + String(len(_offlineQueue)) + String(" pending)"))); + return entry; +})(); }; + + // offline-sync + var offlineSync = function() { return (function() { + var pending = filter(function(e) { return (get(e, "status") == "pending"); }, _offlineQueue); + return (isSxTruthy(!isSxTruthy(isEmpty(pending))) ? (logInfo((String("sx:offline syncing ") + String(len(pending)) + String(" mutations"))), forEach(function(entry) { return executeAction(get(entry, "action"), get(entry, "payload"), function(result) { entry["status"] = "synced"; +return logInfo((String("sx:offline synced ") + String(get(entry, "action")))); }, function(error) { entry["status"] = "failed"; +return logWarn((String("sx:offline sync failed ") + String(get(entry, "action")) + String(": ") + String(error))); }); }, pending)) : NIL); +})(); }; + + // offline-pending-count + var offlinePendingCount = function() { return len(filter(function(e) { return (get(e, "status") == "pending"); }, _offlineQueue)); }; + + // offline-aware-mutation + var offlineAwareMutation = function(pageName, params, actionName, payload, mutatorFn, onComplete) { return (isSxTruthy(_isOnline) ? submitMutation(pageName, params, actionName, payload, mutatorFn, onComplete) : (offlineQueueMutation(actionName, payload, pageName, params, mutatorFn), (isSxTruthy(onComplete) ? onComplete("queued") : NIL))); }; + // current-page-layout var currentPageLayout = function() { return (function() { var pathname = urlPathname(browserLocationHref()); @@ -2382,7 +2555,8 @@ return bindInlineHandlers(root); }; domAppend(el, node); hoistHeadElementsFull(el); processElements(el); - return sxHydrateElements(el); + sxHydrateElements(el); + return sxHydrateIslands(el); })() : NIL); })(); }; @@ -2397,6 +2571,7 @@ return (function() { { var _c = exprs; for (var _i = 0; _i < _c.length; _i++) { var expr = _c[_i]; domAppend(el, renderToDom(expr, env, NIL)); } } processElements(el); sxHydrateElements(el); + sxHydrateIslands(el); return domDispatch(el, "sx:resolved", {"id": id}); })() : logWarn((String("resolveSuspense: no element for id=") + String(id)))); })(); }; @@ -2491,8 +2666,186 @@ callExpr.push(dictGet(kwargs, k)); } } return logInfo((String("pages: ") + String(len(_pageRoutes)) + String(" routes loaded"))); })(); }; + // sx-hydrate-islands + var sxHydrateIslands = function(root) { return (function() { + var els = domQueryAll(sxOr(root, domBody()), "[data-sx-island]"); + return forEach(function(el) { return (isSxTruthy(!isSxTruthy(isProcessed(el, "island-hydrated"))) ? (markProcessed(el, "island-hydrated"), hydrateIsland(el)) : NIL); }, els); +})(); }; + + // hydrate-island + var hydrateIsland = function(el) { return (function() { + var name = domGetAttr(el, "data-sx-island"); + var stateJson = sxOr(domGetAttr(el, "data-sx-state"), "{}"); + return (function() { + var compName = (String("~") + String(name)); + var env = getRenderEnv(NIL); + return (function() { + var comp = envGet(env, compName); + return (isSxTruthy(!isSxTruthy(sxOr(isComponent(comp), isIsland(comp)))) ? logWarn((String("hydrate-island: unknown island ") + String(compName))) : (function() { + var kwargs = jsonParse(stateJson); + var disposers = []; + var local = envMerge(componentClosure(comp), env); + { var _c = componentParams(comp); for (var _i = 0; _i < _c.length; _i++) { var p = _c[_i]; local[p] = (isSxTruthy(dictHas(kwargs, p)) ? dictGet(kwargs, p) : NIL); } } + return (function() { + var bodyDom = withIslandScope(function(disposable) { return append_b(disposers, disposable); }, function() { return renderToDom(componentBody(comp), local, NIL); }); + morphChildren(el, bodyDom); + domSetData(el, "sx-disposers", disposers); + processElements(el); + return logInfo((String("hydrated island: ") + String(compName) + String(" (") + String(len(disposers)) + String(" disposers)"))); +})(); +})()); +})(); +})(); +})(); }; + + // dispose-island + var disposeIsland = function(el) { return (function() { + var disposers = domGetData(el, "sx-disposers"); + return (isSxTruthy(disposers) ? (forEach(function(d) { return (isSxTruthy(isCallable(d)) ? d() : NIL); }, disposers), domSetData(el, "sx-disposers", NIL)) : NIL); +})(); }; + // boot-init - var bootInit = function() { return (logInfo((String("sx-browser ") + String(SX_VERSION))), initCssTracking(), processPageScripts(), processSxScripts(NIL), sxHydrateElements(NIL), processElements(NIL)); }; + var bootInit = function() { return (logInfo((String("sx-browser ") + String(SX_VERSION))), initCssTracking(), processPageScripts(), processSxScripts(NIL), sxHydrateElements(NIL), sxHydrateIslands(NIL), processElements(NIL)); }; + + + // === Transpiled from deps (component dependency analysis) === + + // scan-refs + var scanRefs = function(node) { return (function() { + var refs = []; + scanRefsWalk(node, refs); + return refs; +})(); }; + + // scan-refs-walk + var scanRefsWalk = function(node, refs) { return (isSxTruthy((typeOf(node) == "symbol")) ? (function() { + var name = symbolName(node); + return (isSxTruthy(startsWith(name, "~")) ? (isSxTruthy(!isSxTruthy(contains(refs, name))) ? append_b(refs, name) : NIL) : NIL); +})() : (isSxTruthy((typeOf(node) == "list")) ? forEach(function(item) { return scanRefsWalk(item, refs); }, node) : (isSxTruthy((typeOf(node) == "dict")) ? forEach(function(key) { return scanRefsWalk(dictGet(node, key), refs); }, keys(node)) : NIL))); }; + + // transitive-deps-walk + var transitiveDepsWalk = function(n, seen, env) { return (isSxTruthy(!isSxTruthy(contains(seen, n))) ? (append_b(seen, n), (function() { + var val = envGet(env, n); + return (isSxTruthy((typeOf(val) == "component")) ? forEach(function(ref) { return transitiveDepsWalk(ref, seen, env); }, scanRefs(componentBody(val))) : (isSxTruthy((typeOf(val) == "macro")) ? forEach(function(ref) { return transitiveDepsWalk(ref, seen, env); }, scanRefs(macroBody(val))) : NIL)); +})()) : NIL); }; + + // transitive-deps + var transitiveDeps = function(name, env) { return (function() { + var seen = []; + var key = (isSxTruthy(startsWith(name, "~")) ? name : (String("~") + String(name))); + transitiveDepsWalk(key, seen, env); + return filter(function(x) { return !isSxTruthy((x == key)); }, seen); +})(); }; + + // compute-all-deps + var computeAllDeps = function(env) { return forEach(function(name) { return (function() { + var val = envGet(env, name); + return (isSxTruthy((typeOf(val) == "component")) ? componentSetDeps(val, transitiveDeps(name, env)) : NIL); +})(); }, envComponents(env)); }; + + // scan-components-from-source + var scanComponentsFromSource = function(source) { return (function() { + var matches = regexFindAll("\\(~([a-zA-Z_][a-zA-Z0-9_\\-]*)", source); + return map(function(m) { return (String("~") + String(m)); }, matches); +})(); }; + + // components-needed + var componentsNeeded = function(pageSource, env) { return (function() { + var direct = scanComponentsFromSource(pageSource); + var allNeeded = []; + { var _c = direct; for (var _i = 0; _i < _c.length; _i++) { var name = _c[_i]; if (isSxTruthy(!isSxTruthy(contains(allNeeded, name)))) { + allNeeded.push(name); +} +(function() { + var val = envGet(env, name); + return (function() { + var deps = (isSxTruthy((isSxTruthy((typeOf(val) == "component")) && !isSxTruthy(isEmpty(componentDeps(val))))) ? componentDeps(val) : transitiveDeps(name, env)); + return forEach(function(dep) { return (isSxTruthy(!isSxTruthy(contains(allNeeded, dep))) ? append_b(allNeeded, dep) : NIL); }, deps); +})(); +})(); } } + return allNeeded; +})(); }; + + // page-component-bundle + var pageComponentBundle = function(pageSource, env) { return componentsNeeded(pageSource, env); }; + + // page-css-classes + var pageCssClasses = function(pageSource, env) { return (function() { + var needed = componentsNeeded(pageSource, env); + var classes = []; + { var _c = needed; for (var _i = 0; _i < _c.length; _i++) { var name = _c[_i]; (function() { + var val = envGet(env, name); + return (isSxTruthy((typeOf(val) == "component")) ? forEach(function(cls) { return (isSxTruthy(!isSxTruthy(contains(classes, cls))) ? append_b(classes, cls) : NIL); }, componentCssClasses(val)) : NIL); +})(); } } + { var _c = scanCssClasses(pageSource); for (var _i = 0; _i < _c.length; _i++) { var cls = _c[_i]; if (isSxTruthy(!isSxTruthy(contains(classes, cls)))) { + classes.push(cls); +} } } + return classes; +})(); }; + + // scan-io-refs-walk + var scanIoRefsWalk = function(node, ioNames, refs) { return (isSxTruthy((typeOf(node) == "symbol")) ? (function() { + var name = symbolName(node); + return (isSxTruthy(contains(ioNames, name)) ? (isSxTruthy(!isSxTruthy(contains(refs, name))) ? append_b(refs, name) : NIL) : NIL); +})() : (isSxTruthy((typeOf(node) == "list")) ? forEach(function(item) { return scanIoRefsWalk(item, ioNames, refs); }, node) : (isSxTruthy((typeOf(node) == "dict")) ? forEach(function(key) { return scanIoRefsWalk(dictGet(node, key), ioNames, refs); }, keys(node)) : NIL))); }; + + // scan-io-refs + var scanIoRefs = function(node, ioNames) { return (function() { + var refs = []; + scanIoRefsWalk(node, ioNames, refs); + return refs; +})(); }; + + // transitive-io-refs-walk + var transitiveIoRefsWalk = function(n, seen, allRefs, env, ioNames) { return (isSxTruthy(!isSxTruthy(contains(seen, n))) ? (append_b(seen, n), (function() { + var val = envGet(env, n); + return (isSxTruthy((typeOf(val) == "component")) ? (forEach(function(ref) { return (isSxTruthy(!isSxTruthy(contains(allRefs, ref))) ? append_b(allRefs, ref) : NIL); }, scanIoRefs(componentBody(val), ioNames)), forEach(function(dep) { return transitiveIoRefsWalk(dep, seen, allRefs, env, ioNames); }, scanRefs(componentBody(val)))) : (isSxTruthy((typeOf(val) == "macro")) ? (forEach(function(ref) { return (isSxTruthy(!isSxTruthy(contains(allRefs, ref))) ? append_b(allRefs, ref) : NIL); }, scanIoRefs(macroBody(val), ioNames)), forEach(function(dep) { return transitiveIoRefsWalk(dep, seen, allRefs, env, ioNames); }, scanRefs(macroBody(val)))) : NIL)); +})()) : NIL); }; + + // transitive-io-refs + var transitiveIoRefs = function(name, env, ioNames) { return (function() { + var allRefs = []; + var seen = []; + var key = (isSxTruthy(startsWith(name, "~")) ? name : (String("~") + String(name))); + transitiveIoRefsWalk(key, seen, allRefs, env, ioNames); + return allRefs; +})(); }; + + // compute-all-io-refs + var computeAllIoRefs = function(env, ioNames) { return forEach(function(name) { return (function() { + var val = envGet(env, name); + return (isSxTruthy((typeOf(val) == "component")) ? componentSetIoRefs(val, transitiveIoRefs(name, env, ioNames)) : NIL); +})(); }, envComponents(env)); }; + + // component-pure? + var componentPure_p = function(name, env, ioNames) { return isEmpty(transitiveIoRefs(name, env, ioNames)); }; + + // render-target + var renderTarget = function(name, env, ioNames) { return (function() { + var key = (isSxTruthy(startsWith(name, "~")) ? name : (String("~") + String(name))); + return (function() { + var val = envGet(env, key); + return (isSxTruthy(!isSxTruthy((typeOf(val) == "component"))) ? "server" : (function() { + var affinity = componentAffinity(val); + return (isSxTruthy((affinity == "server")) ? "server" : (isSxTruthy((affinity == "client")) ? "client" : (isSxTruthy(!isSxTruthy(componentPure_p(name, env, ioNames))) ? "server" : "client"))); +})()); +})(); +})(); }; + + // page-render-plan + var pageRenderPlan = function(pageSource, env, ioNames) { return (function() { + var needed = componentsNeeded(pageSource, env); + var compTargets = {}; + var serverList = []; + var clientList = []; + var ioDeps = []; + { var _c = needed; for (var _i = 0; _i < _c.length; _i++) { var name = _c[_i]; (function() { + var target = renderTarget(name, env, ioNames); + compTargets[name] = target; + return (isSxTruthy((target == "server")) ? (append_b(serverList, name), forEach(function(ioRef) { return (isSxTruthy(!isSxTruthy(contains(ioDeps, ioRef))) ? append_b(ioDeps, ioRef) : NIL); }, transitiveIoRefs(name, env, ioNames))) : append_b(clientList, name)); +})(); } } + return {"components": compTargets, "server": serverList, "client": clientList, "io-deps": ioDeps}; +})(); }; // === Transpiled from router (client-side route matching) === @@ -2702,6 +3055,40 @@ return (isSxTruthy((_batchDepth == 0)) ? (function() { // register-in-scope var registerInScope = function(disposable) { return (isSxTruthy(_islandScope) ? _islandScope(disposable) : NIL); }; + // *store-registry* + var _storeRegistry = {}; + + // def-store + var defStore = function(name, initFn) { return (function() { + var registry = _storeRegistry; + if (isSxTruthy(!isSxTruthy(hasKey_p(registry, name)))) { + _storeRegistry = assoc(registry, name, initFn()); +} + return get(_storeRegistry, name); +})(); }; + + // use-store + var useStore = function(name) { return (isSxTruthy(hasKey_p(_storeRegistry, name)) ? get(_storeRegistry, name) : error((String("Store not found: ") + String(name) + String(". Call (def-store ...) before (use-store ...).")))); }; + + // clear-stores + var clearStores = function() { return (_storeRegistry = {}); }; + + // emit-event + var emitEvent = function(el, eventName, detail) { return domDispatch(el, eventName, detail); }; + + // on-event + var onEvent = function(el, eventName, handler) { return domListen(el, eventName, handler); }; + + // bridge-event + var bridgeEvent = function(el, eventName, targetSignal, transformFn) { return effect(function() { return (function() { + var remove = domListen(el, eventName, function(e) { return (function() { + var detail = eventDetail(e); + var newVal = (isSxTruthy(transformFn) ? transformFn(detail) : detail); + return reset_b(targetSignal, newVal); +})(); }); + return remove; +})(); }); }; + // ========================================================================= // Platform interface — DOM adapter (browser-only) @@ -2852,6 +3239,16 @@ return (isSxTruthy((_batchDepth == 0)) ? (function() { return el.dispatchEvent(evt); } + function domListen(el, name, handler) { + if (!_hasDom || !el) return function() {}; + el.addEventListener(name, handler); + return function() { el.removeEventListener(name, handler); }; + } + + function eventDetail(e) { + return (e && e.detail != null) ? e.detail : nil; + } + function domQuery(sel) { return _hasDom ? document.querySelector(sel) : null; } @@ -2879,6 +3276,12 @@ return (isSxTruthy((_batchDepth == 0)) ? (function() { function domSetData(el, key, val) { if (el) { if (!el._sxData) el._sxData = {}; el._sxData[key] = val; } } + function domGetData(el, key) { + return (el && el._sxData) ? (el._sxData[key] != null ? el._sxData[key] : nil) : nil; + } + function jsonParse(s) { + try { return JSON.parse(s); } catch(e) { return {}; } + } // ========================================================================= // Performance overrides — replace transpiled spec with imperative JS @@ -4706,7 +5109,20 @@ return (isSxTruthy((_batchDepth == 0)) ? (function() { renderComponent: typeof sxRenderComponent === "function" ? sxRenderComponent : null, getEnv: function() { return componentEnv; }, resolveSuspense: typeof resolveSuspense === "function" ? resolveSuspense : null, + hydrateIslands: typeof sxHydrateIslands === "function" ? sxHydrateIslands : null, + disposeIsland: typeof disposeIsland === "function" ? disposeIsland : null, init: typeof bootInit === "function" ? bootInit : null, + scanRefs: scanRefs, + scanComponentsFromSource: scanComponentsFromSource, + transitiveDeps: transitiveDeps, + computeAllDeps: computeAllDeps, + componentsNeeded: componentsNeeded, + pageComponentBundle: pageComponentBundle, + pageCssClasses: pageCssClasses, + scanIoRefs: scanIoRefs, + transitiveIoRefs: transitiveIoRefs, + computeAllIoRefs: computeAllIoRefs, + componentPure_p: componentPure_p, splitPathSegments: splitPathSegments, parseRoutePattern: parseRoutePattern, matchRoute: matchRoute, @@ -4724,6 +5140,12 @@ return (isSxTruthy((_batchDepth == 0)) ? (function() { batch: batch, isSignal: isSignal, makeSignal: makeSignal, + defStore: defStore, + useStore: useStore, + clearStores: clearStores, + emitEvent: emitEvent, + onEvent: onEvent, + bridgeEvent: bridgeEvent, _version: "ref-2.0 (boot+dom+engine+html+orchestration+parser+sx, bootstrap-compiled)" }; @@ -4766,4 +5188,4 @@ return (isSxTruthy((_batchDepth == 0)) ? (function() { if (typeof module !== "undefined" && module.exports) module.exports = Sx; else global.Sx = Sx; -})(typeof globalThis !== "undefined" ? globalThis : typeof window !== "undefined" ? window : this); +})(typeof globalThis !== "undefined" ? globalThis : typeof window !== "undefined" ? window : this); \ No newline at end of file diff --git a/shared/sx/ref/boot.sx b/shared/sx/ref/boot.sx index 8f85e85..f4cf787 100644 --- a/shared/sx/ref/boot.sx +++ b/shared/sx/ref/boot.sx @@ -84,9 +84,10 @@ (dom-append el node) ;; Hoist head elements from rendered content (hoist-head-elements-full el) - ;; Process sx- attributes and hydrate + ;; Process sx- attributes, hydrate data-sx and islands (process-elements el) - (sx-hydrate-elements el)))))) + (sx-hydrate-elements el) + (sx-hydrate-islands el)))))) ;; -------------------------------------------------------------------------- @@ -117,6 +118,7 @@ exprs) (process-elements el) (sx-hydrate-elements el) + (sx-hydrate-islands el) (dom-dispatch el "sx:resolved" {:id id}))) (log-warn (str "resolveSuspense: no element for id=" id)))))) @@ -305,6 +307,88 @@ (log-info (str "pages: " (len _page-routes) " routes loaded"))))) +;; -------------------------------------------------------------------------- +;; Island hydration — activate reactive islands from SSR output +;; -------------------------------------------------------------------------- +;; +;; The server renders islands as: +;;
+;; ...static HTML... +;;
+;; +;; Hydration: +;; 1. Find all [data-sx-island] elements +;; 2. Look up the island component by name +;; 3. Parse data-sx-state into kwargs +;; 4. Re-render the island body in a reactive context +;; 5. Morph existing DOM to preserve structure, focus, scroll +;; 6. Store disposers on the element for cleanup + +(define sx-hydrate-islands + (fn (root) + (let ((els (dom-query-all (or root (dom-body)) "[data-sx-island]"))) + (for-each + (fn (el) + (when (not (is-processed? el "island-hydrated")) + (mark-processed! el "island-hydrated") + (hydrate-island el))) + els)))) + +(define hydrate-island + (fn (el) + (let ((name (dom-get-attr el "data-sx-island")) + (state-json (or (dom-get-attr el "data-sx-state") "{}"))) + (let ((comp-name (str "~" name)) + (env (get-render-env nil))) + (let ((comp (env-get env comp-name))) + (if (not (or (component? comp) (island? comp))) + (log-warn (str "hydrate-island: unknown island " comp-name)) + + ;; Parse state and build keyword args + (let ((kwargs (json-parse state-json)) + (disposers (list)) + (local (env-merge (component-closure comp) env))) + + ;; Bind params from kwargs + (for-each + (fn (p) + (env-set! local p (if (dict-has? kwargs p) (dict-get kwargs p) nil))) + (component-params comp)) + + ;; Render the island body in a reactive scope + (let ((body-dom + (with-island-scope + (fn (disposable) (append! disposers disposable)) + (fn () (render-to-dom (component-body comp) local nil))))) + + ;; Morph existing DOM against reactive output + (morph-children el body-dom) + + ;; Store disposers for cleanup + (dom-set-data el "sx-disposers" disposers) + + ;; Process any sx- attributes on new content + (process-elements el) + + (log-info (str "hydrated island: " comp-name + " (" (len disposers) " disposers)")))))))))) + + +;; -------------------------------------------------------------------------- +;; Island disposal — clean up when island removed from DOM +;; -------------------------------------------------------------------------- + +(define dispose-island + (fn (el) + (let ((disposers (dom-get-data el "sx-disposers"))) + (when disposers + (for-each + (fn (d) + (when (callable? d) (d))) + disposers) + (dom-set-data el "sx-disposers" nil))))) + + ;; -------------------------------------------------------------------------- ;; Full boot sequence ;; -------------------------------------------------------------------------- @@ -317,13 +401,15 @@ ;; 3. Process scripts (components + mounts) ;; 4. Process page registry (client-side routing) ;; 5. Hydrate [data-sx] elements - ;; 6. Process engine elements + ;; 6. Hydrate [data-sx-island] elements (reactive islands) + ;; 7. Process engine elements (do (log-info (str "sx-browser " SX_VERSION)) (init-css-tracking) (process-page-scripts) (process-sx-scripts nil) (sx-hydrate-elements nil) + (sx-hydrate-islands nil) (process-elements nil)))) @@ -382,8 +468,25 @@ ;; (log-info msg) → void (console.log with prefix) ;; (log-parse-error label text err) → void (diagnostic parse error) ;; -;; === JSON parsing === +;; === JSON === +;; (json-parse str) → dict/list/value (JSON.parse) +;; ;; === Processing markers === ;; (mark-processed! el key) → void ;; (is-processed? el key) → boolean +;; +;; === Morph === +;; (morph-children target source) → void (morph target's children to match source) +;; +;; === Island support (from adapter-dom.sx / signals.sx) === +;; (island? x) → boolean +;; (component-closure comp) → env +;; (component-params comp) → list of param names +;; (component-body comp) → AST +;; (component-name comp) → string +;; (component-has-children? comp) → boolean +;; (with-island-scope scope-fn body-fn) → result (track disposables) +;; (render-to-dom expr env ns) → DOM node +;; (dom-get-data el key) → any (from el._sxData) +;; (dom-set-data el key val) → void ;; -------------------------------------------------------------------------- diff --git a/shared/sx/ref/bootstrap_js.py b/shared/sx/ref/bootstrap_js.py index ef6fe27..52ea517 100644 --- a/shared/sx/ref/bootstrap_js.py +++ b/shared/sx/ref/bootstrap_js.py @@ -165,6 +165,13 @@ class JSEmitter: "*batch-depth*": "_batchDepth", "*batch-queue*": "_batchQueue", "*island-scope*": "_islandScope", + "*store-registry*": "_storeRegistry", + "def-store": "defStore", + "use-store": "useStore", + "clear-stores": "clearStores", + "emit-event": "emitEvent", + "on-event": "onEvent", + "bridge-event": "bridgeEvent", "macro?": "isMacro", "primitive?": "isPrimitive", "get-primitive": "getPrimitive", @@ -314,6 +321,8 @@ class JSEmitter: "dom-add-class": "domAddClass", "dom-remove-class": "domRemoveClass", "dom-dispatch": "domDispatch", + "dom-listen": "domListen", + "event-detail": "eventDetail", "dom-query": "domQuery", "dom-query-all": "domQueryAll", "dom-tag-name": "domTagName", @@ -322,6 +331,8 @@ class JSEmitter: "dom-child-nodes": "domChildNodes", "dom-remove-children-after": "domRemoveChildrenAfter", "dom-set-data": "domSetData", + "dom-get-data": "domGetData", + "json-parse": "jsonParse", "dict-has?": "dictHas", "dict-delete!": "dictDelete", "process-bindings": "processBindings", @@ -508,6 +519,9 @@ class JSEmitter: "process-component-script": "processComponentScript", "SX_VERSION": "SX_VERSION", "boot-init": "bootInit", + "sx-hydrate-islands": "sxHydrateIslands", + "hydrate-island": "hydrateIsland", + "dispose-island": "disposeIsland", "resolve-suspense": "resolveSuspense", "resolve-mount-target": "resolveMountTarget", "sx-render-with-env": "sxRenderWithEnv", @@ -2870,6 +2884,16 @@ PLATFORM_DOM_JS = """ return el.dispatchEvent(evt); } + function domListen(el, name, handler) { + if (!_hasDom || !el) return function() {}; + el.addEventListener(name, handler); + return function() { el.removeEventListener(name, handler); }; + } + + function eventDetail(e) { + return (e && e.detail != null) ? e.detail : nil; + } + function domQuery(sel) { return _hasDom ? document.querySelector(sel) : null; } @@ -2897,6 +2921,12 @@ PLATFORM_DOM_JS = """ function domSetData(el, key, val) { if (el) { if (!el._sxData) el._sxData = {}; el._sxData[key] = val; } } + function domGetData(el, key) { + return (el && el._sxData) ? (el._sxData[key] != null ? el._sxData[key] : nil) : nil; + } + function jsonParse(s) { + try { return JSON.parse(s); } catch(e) { return {}; } + } // ========================================================================= // Performance overrides — replace transpiled spec with imperative JS @@ -4142,6 +4172,8 @@ def public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_boot, has api_lines.append(' renderComponent: typeof sxRenderComponent === "function" ? sxRenderComponent : null,') api_lines.append(' getEnv: function() { return componentEnv; },') api_lines.append(' resolveSuspense: typeof resolveSuspense === "function" ? resolveSuspense : null,') + api_lines.append(' hydrateIslands: typeof sxHydrateIslands === "function" ? sxHydrateIslands : null,') + api_lines.append(' disposeIsland: typeof disposeIsland === "function" ? disposeIsland : null,') api_lines.append(' init: typeof bootInit === "function" ? bootInit : null,') elif has_orch: api_lines.append(' init: typeof engineInit === "function" ? engineInit : null,') @@ -4178,6 +4210,12 @@ def public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_boot, has api_lines.append(' batch: batch,') api_lines.append(' isSignal: isSignal,') api_lines.append(' makeSignal: makeSignal,') + api_lines.append(' defStore: defStore,') + api_lines.append(' useStore: useStore,') + api_lines.append(' clearStores: clearStores,') + api_lines.append(' emitEvent: emitEvent,') + api_lines.append(' onEvent: onEvent,') + api_lines.append(' bridgeEvent: bridgeEvent,') api_lines.append(f' _version: "{version}"') api_lines.append(' };') api_lines.append('') diff --git a/shared/sx/ref/bootstrap_py.py b/shared/sx/ref/bootstrap_py.py index 53cdf3d..26c966d 100644 --- a/shared/sx/ref/bootstrap_py.py +++ b/shared/sx/ref/bootstrap_py.py @@ -174,6 +174,16 @@ class PyEmitter: "*batch-depth*": "_batch_depth", "*batch-queue*": "_batch_queue", "*island-scope*": "_island_scope", + "*store-registry*": "_store_registry", + "def-store": "def_store", + "use-store": "use_store", + "clear-stores": "clear_stores", + "emit-event": "emit_event", + "on-event": "on_event", + "bridge-event": "bridge_event", + "dom-listen": "dom_listen", + "dom-dispatch": "dom_dispatch", + "event-detail": "event_detail", "macro?": "is_macro", "primitive?": "is_primitive", "get-primitive": "get_primitive", @@ -1544,6 +1554,17 @@ def is_empty_dict(d): return len(d) == 0 +# DOM event primitives — no-ops on server (browser-only). +def dom_listen(el, name, handler): + return lambda: None + +def dom_dispatch(el, name, detail=None): + return False + +def event_detail(e): + return None + + def env_has(env, name): return name in env diff --git a/shared/sx/ref/signals.sx b/shared/sx/ref/signals.sx index f37ad44..22ea528 100644 --- a/shared/sx/ref/signals.sx +++ b/shared/sx/ref/signals.sx @@ -307,13 +307,13 @@ (fn (name init-fn) (let ((registry *store-registry*)) ;; Only create the store once — subsequent calls return existing - (when (not (has? registry name)) + (when (not (has-key? registry name)) (set! *store-registry* (assoc registry name (init-fn)))) (get *store-registry* name)))) (define use-store (fn (name) - (if (has? *store-registry* name) + (if (has-key? *store-registry* name) (get *store-registry* name) (error (str "Store not found: " name ". Call (def-store ...) before (use-store ...)."))))) diff --git a/shared/sx/ref/sx_ref.py b/shared/sx/ref/sx_ref.py index ea4645e..2acb2f9 100644 --- a/shared/sx/ref/sx_ref.py +++ b/shared/sx/ref/sx_ref.py @@ -416,6 +416,17 @@ def is_empty_dict(d): return len(d) == 0 +# DOM event primitives — no-ops on server (browser-only). +def dom_listen(el, name, handler): + return lambda: None + +def dom_dispatch(el, name, detail=None): + return False + +def event_detail(e): + return None + + def env_has(env, name): return name in env @@ -1346,24 +1357,6 @@ render_html_island = lambda island, args, env: (lambda kwargs: (lambda children: serialize_island_state = lambda kwargs: (NIL if sx_truthy(is_empty_dict(kwargs)) else json_serialize(kwargs)) -# === Transpiled from adapter-sx === - -# render-to-sx -render_to_sx = lambda expr, env: (lambda result: (result if sx_truthy((type_of(result) == 'string')) else serialize(result)))(aser(expr, env)) - -# aser -aser = lambda expr, env: _sx_case(type_of(expr), [('number', lambda: expr), ('string', lambda: expr), ('boolean', lambda: expr), ('nil', lambda: NIL), ('symbol', lambda: (lambda name: (env_get(env, name) if sx_truthy(env_has(env, name)) else (get_primitive(name) if sx_truthy(is_primitive(name)) else (True if sx_truthy((name == 'true')) else (False if sx_truthy((name == 'false')) else (NIL if sx_truthy((name == 'nil')) else error(sx_str('Undefined symbol: ', name))))))))(symbol_name(expr))), ('keyword', lambda: keyword_name(expr)), ('list', lambda: ([] if sx_truthy(empty_p(expr)) else aser_list(expr, env))), (None, lambda: expr)]) - -# aser-list -aser_list = lambda expr, env: (lambda head: (lambda args: (map(lambda x: aser(x, env), expr) if sx_truthy((not sx_truthy((type_of(head) == 'symbol')))) else (lambda name: (aser_fragment(args, env) if sx_truthy((name == '<>')) else (aser_call(name, args, env) if sx_truthy(starts_with_p(name, '~')) else (aser_call(name, args, env) if sx_truthy(contains_p(HTML_TAGS, name)) else (aser_special(name, expr, env) if sx_truthy((is_special_form(name) if sx_truthy(is_special_form(name)) else is_ho_form(name))) else (aser(expand_macro(env_get(env, name), args, env), env) if sx_truthy((env_has(env, name) if not sx_truthy(env_has(env, name)) else is_macro(env_get(env, name)))) else (lambda f: (lambda evaled_args: (apply(f, evaled_args) if sx_truthy((is_callable(f) if not sx_truthy(is_callable(f)) else ((not sx_truthy(is_lambda(f))) if not sx_truthy((not sx_truthy(is_lambda(f)))) else ((not sx_truthy(is_component(f))) if not sx_truthy((not sx_truthy(is_component(f)))) else (not sx_truthy(is_island(f))))))) else (trampoline(call_lambda(f, evaled_args, env)) if sx_truthy(is_lambda(f)) else (aser_call(sx_str('~', component_name(f)), args, env) if sx_truthy(is_component(f)) else (aser_call(sx_str('~', component_name(f)), args, env) if sx_truthy(is_island(f)) else error(sx_str('Not callable: ', inspect(f))))))))(map(lambda a: trampoline(eval_expr(a, env)), args)))(trampoline(eval_expr(head, env)))))))))(symbol_name(head))))(rest(expr)))(first(expr)) - -# aser-fragment -aser_fragment = lambda children, env: (lambda parts: ('' if sx_truthy(empty_p(parts)) else sx_str('(<> ', join(' ', map(serialize, parts)), ')')))(filter(lambda x: (not sx_truthy(is_nil(x))), map(lambda c: aser(c, env), children))) - -# aser-call -aser_call = lambda name, args, env: (lambda parts: _sx_begin(reduce(lambda state, arg: (lambda skip: (assoc(state, 'skip', False, 'i', (get(state, 'i') + 1)) if sx_truthy(skip) else ((lambda val: _sx_begin((_sx_begin(_sx_append(parts, sx_str(':', keyword_name(arg))), _sx_append(parts, serialize(val))) if sx_truthy((not sx_truthy(is_nil(val)))) else NIL), assoc(state, 'skip', True, 'i', (get(state, 'i') + 1))))(aser(nth(args, (get(state, 'i') + 1)), env)) if sx_truthy(((type_of(arg) == 'keyword') if not sx_truthy((type_of(arg) == 'keyword')) else ((get(state, 'i') + 1) < len(args)))) else (lambda val: _sx_begin((_sx_append(parts, serialize(val)) if sx_truthy((not sx_truthy(is_nil(val)))) else NIL), assoc(state, 'i', (get(state, 'i') + 1))))(aser(arg, env)))))(get(state, 'skip')), {'i': 0, 'skip': False}, args), sx_str('(', join(' ', parts), ')')))([name]) - - # === Transpiled from deps (component dependency analysis) === # scan-refs @@ -1493,6 +1486,32 @@ def with_island_scope(scope_fn, body_fn): # register-in-scope register_in_scope = lambda disposable: (_island_scope(disposable) if sx_truthy(_island_scope) else NIL) +# *store-registry* +_store_registry = {} + +# def-store +def def_store(name, init_fn): + registry = _store_registry + if sx_truthy((not sx_truthy(has_key_p(registry, name)))): + _store_registry = assoc(registry, name, init_fn()) + return get(_store_registry, name) + +# use-store +use_store = lambda name: (get(_store_registry, name) if sx_truthy(has_key_p(_store_registry, name)) else error(sx_str('Store not found: ', name, '. Call (def-store ...) before (use-store ...).'))) + +# clear-stores +def clear_stores(): + return _sx_cell_set(_cells, '_store_registry', {}) + +# emit-event +emit_event = lambda el, event_name, detail: dom_dispatch(el, event_name, detail) + +# on-event +on_event = lambda el, event_name, handler: dom_listen(el, event_name, handler) + +# bridge-event +bridge_event = lambda el, event_name, target_signal, transform_fn: effect(lambda : (lambda remove: remove)(dom_listen(el, event_name, lambda e: (lambda detail: (lambda new_val: reset_b(target_signal, new_val))((transform_fn(detail) if sx_truthy(transform_fn) else detail)))(event_detail(e))))) + # ========================================================================= # Fixups -- wire up render adapter dispatch @@ -1530,9 +1549,6 @@ def _wrap_aser_outputs(): # Public API # ========================================================================= -# Wrap aser outputs to return SxExpr -_wrap_aser_outputs() - # Set HTML as default adapter _setup_html_adapter() diff --git a/sx/sx/reactive-islands.sx b/sx/sx/reactive-islands.sx index 1e514d3..f4974e5 100644 --- a/sx/sx/reactive-islands.sx +++ b/sx/sx/reactive-islands.sx @@ -111,8 +111,8 @@ (td :class "px-3 py-2 font-mono text-xs text-stone-500" "signals.sx: emit-event, on-event, bridge-event")) (tr :class "border-b border-stone-100" (td :class "px-3 py-2 text-stone-700" "Client hydration") - (td :class "px-3 py-2 text-amber-600 font-medium" "TODO") - (td :class "px-3 py-2 font-mono text-xs text-stone-500" "boot.sx")) + (td :class "px-3 py-2 text-green-700 font-medium" "Spec'd") + (td :class "px-3 py-2 font-mono text-xs text-stone-500" "boot.sx: sx-hydrate-islands, hydrate-island, dispose-island")) (tr :class "border-b border-stone-100" (td :class "px-3 py-2 text-stone-700" "Event bindings") (td :class "px-3 py-2 text-amber-600 font-medium" "TODO")