Bootstrap stores + event bridge, add island hydration to boot.sx

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 11:13:18 +00:00
parent 5b70cd5cfc
commit c55f0956bc
7 changed files with 633 additions and 33 deletions

View File

@@ -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:
;; <div data-sx-island="counter" data-sx-state='{"initial": 0}'>
;; ...static HTML...
;; </div>
;;
;; 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
;; --------------------------------------------------------------------------

View File

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

View File

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

View File

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

View File

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