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:
@@ -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
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
@@ -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('')
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 ...).")))))
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user