Island disposal, reactive lists, input binding, and Phase 2 plan

- Effect and computed auto-register disposers with island scope via
  register-in-scope; dispose-islands-in called before every swap point
  (orchestration.sx) to clean up intervals/subscriptions on navigation.
- Map + deref inside islands auto-upgrades to reactive-list for signal-
  bound list rendering. Demo island with add/remove items.
- New :bind attribute for two-way signal-input binding (text, checkbox,
  radio, textarea, select). bind-input in adapter-dom.sx handles both
  signal→element (effect) and element→signal (event listener).
- Phase 2 plan page at /reactive-islands/phase2 covering input binding,
  keyed reconciliation, reactive class/style, refs, portals, error
  boundaries, suspense, and transitions.
- Updated status tables in overview and plan pages.
- Fixed stopwatch reset (fn body needs do wrapper for multiple exprs).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 16:10:52 +00:00
parent efc7e340da
commit 8683cf24c3
10 changed files with 543 additions and 59 deletions

View File

@@ -182,6 +182,9 @@
(and (starts-with? attr-name "on-")
(callable? attr-val))
(dom-listen el (slice attr-name 3) attr-val)
;; Two-way input binding: :bind signal
(and (= attr-name "bind") (signal? attr-val))
(bind-input el attr-val)
;; Boolean attr
(contains? BOOLEAN_ATTRS attr-name)
(when attr-val (dom-set-attr el attr-name ""))
@@ -366,19 +369,41 @@
(definition-form? name)
(do (trampoline (eval-expr expr env)) (create-fragment))
;; map
;; map — reactive-list when mapping over a signal inside an island
(= name "map")
(let ((f (trampoline (eval-expr (nth expr 1) env)))
(coll (trampoline (eval-expr (nth expr 2) env)))
(frag (create-fragment)))
(for-each
(fn (item)
(let ((val (if (lambda? f)
(render-lambda-dom f (list item) env ns)
(render-to-dom (apply f (list item)) env ns))))
(dom-append frag val)))
coll)
frag)
(let ((coll-expr (nth expr 2)))
(if (and *island-scope*
(= (type-of coll-expr) "list")
(> (len coll-expr) 1)
(= (first coll-expr) "deref"))
;; Reactive path: pass signal to reactive-list
(let ((f (trampoline (eval-expr (nth expr 1) env)))
(sig (trampoline (eval-expr (nth coll-expr 1) env))))
(if (signal? sig)
(reactive-list f sig env ns)
;; deref on non-signal: fall through to static
(let ((coll (deref sig))
(frag (create-fragment)))
(for-each
(fn (item)
(let ((val (if (lambda? f)
(render-lambda-dom f (list item) env ns)
(render-to-dom (apply f (list item)) env ns))))
(dom-append frag val)))
coll)
frag)))
;; Static path: no island scope or no deref
(let ((f (trampoline (eval-expr (nth expr 1) env)))
(coll (trampoline (eval-expr (nth expr 2) env)))
(frag (create-fragment)))
(for-each
(fn (item)
(let ((val (if (lambda? f)
(render-lambda-dom f (list item) env ns)
(render-to-dom (apply f (list item)) env ns))))
(dom-append frag val)))
coll)
frag)))
;; map-indexed
(= name "map-indexed")
@@ -567,18 +592,54 @@
(when parent
;; Remove all nodes after marker until next sibling marker
(dom-remove-children-after marker)
;; Render new items
(let ((items (deref items-sig)))
;; Render new items into a fragment, then insert after marker
(let ((items (deref items-sig))
(frag (create-fragment)))
(for-each
(fn (item)
(let ((rendered (if (lambda? map-fn)
(render-lambda-dom map-fn (list item) env ns)
(render-to-dom (apply map-fn (list item)) env ns))))
(dom-insert-after marker rendered)))
(reverse items)))))))
(dom-append frag rendered)))
items)
(dom-insert-after marker frag))))))
container)))
;; --------------------------------------------------------------------------
;; bind-input — two-way signal binding for form elements
;; --------------------------------------------------------------------------
;;
;; (bind-input el sig) creates a bidirectional link:
;; Signal → element: effect updates el.value (or el.checked) when sig changes
;; Element → signal: input/change listener updates sig when user types
;;
;; Handles: input[text/number/email/...], textarea, select, checkbox, radio
(define bind-input
(fn (el sig)
(let ((input-type (lower (or (dom-get-attr el "type") "")))
(is-checkbox (or (= input-type "checkbox")
(= input-type "radio"))))
;; Set initial value from signal
(if is-checkbox
(dom-set-prop el "checked" (deref sig))
(dom-set-prop el "value" (str (deref sig))))
;; Signal → element (reactive effect)
(effect (fn ()
(if is-checkbox
(dom-set-prop el "checked" (deref sig))
(let ((v (str (deref sig))))
(when (!= (dom-get-prop el "value") v)
(dom-set-prop el "value" v))))))
;; Element → signal (event listener)
(dom-listen el (if is-checkbox "change" "input")
(fn (e)
(if is-checkbox
(reset! sig (dom-get-prop el "checked"))
(reset! sig (dom-get-prop el "value"))))))))
;; --------------------------------------------------------------------------
;; Platform interface — DOM adapter
;; --------------------------------------------------------------------------

View File

@@ -391,6 +391,15 @@
disposers)
(dom-set-data el "sx-disposers" nil)))))
(define dispose-islands-in
(fn (root)
;; Dispose all islands within root before a swap replaces them.
(when root
(let ((islands (dom-query-all root "[data-sx-island]")))
(when (and islands (not (empty? islands)))
(log-info (str "disposing " (len islands) " island(s)"))
(for-each dispose-island islands))))))
;; --------------------------------------------------------------------------
;; Full boot sequence

View File

@@ -261,6 +261,7 @@
;; Process OOB swaps
(process-oob-swaps container
(fn (t oob s)
(dispose-islands-in t)
(swap-dom-nodes t oob s)
(sx-hydrate t)
(process-elements t)))
@@ -269,6 +270,8 @@
(content (if select-sel
(select-from-container container select-sel)
(children-to-fragment container))))
;; Dispose old islands before swap
(dispose-islands-in target)
;; Swap
(with-transition use-transition
(fn ()
@@ -282,6 +285,8 @@
(let ((doc (dom-parse-html-document text)))
(when doc
(let ((select-sel (dom-get-attr el "sx-select")))
;; Dispose old islands before swap
(dispose-islands-in target)
(if select-sel
;; Select from parsed document
(let ((html (select-html-from-doc doc select-sel)))
@@ -295,6 +300,7 @@
;; Process OOB swaps
(process-oob-swaps container
(fn (t oob s)
(dispose-islands-in t)
(swap-dom-nodes t oob s)
(post-swap t)))
;; Hoist head elements
@@ -816,6 +822,7 @@
;; Swap rendered DOM content into target and run post-processing.
;; Shared by pure and data page client routes.
(do
(dispose-islands-in target)
(dom-set-text-content target "")
(dom-append target rendered)
(hoist-head-elements-full target)
@@ -1003,6 +1010,7 @@
(use-transition (get swap-spec "transition"))
(trimmed (trim data)))
(when (not (empty? trimmed))
(dispose-islands-in target)
(if (starts-with? trimmed "(")
;; SX response
(let ((rendered (sx-render trimmed))

View File

@@ -133,6 +133,8 @@
;; Initial computation
(recompute)
;; Auto-register disposal with island scope
(register-in-scope (fn () (dispose-computed s)))
s))))
@@ -176,13 +178,17 @@
(run-effect)
;; Return dispose function
(fn ()
(set! disposed true)
(when cleanup-fn (invoke cleanup-fn))
(for-each
(fn (dep) (signal-remove-sub! dep run-effect))
deps)
(set! deps (list)))))))
(let ((dispose-fn
(fn ()
(set! disposed true)
(when cleanup-fn (invoke cleanup-fn))
(for-each
(fn (dep) (signal-remove-sub! dep run-effect))
deps)
(set! deps (list)))))
;; Auto-register with island scope so disposal happens on swap
(register-in-scope dispose-fn)
dispose-fn)))))
;; --------------------------------------------------------------------------

View File

@@ -1339,7 +1339,7 @@ reset_b = lambda s, value: ((lambda old: (_sx_begin(signal_set_value(s, value),
swap_b = lambda s, f, *args: ((lambda old: (lambda new_val: (_sx_begin(signal_set_value(s, new_val), notify_subscribers(s)) if sx_truthy((not sx_truthy(is_identical(old, new_val)))) else NIL))(apply(f, cons(old, args))))(signal_value(s)) if sx_truthy(is_signal(s)) else NIL)
# computed
computed = lambda compute_fn: (lambda s: (lambda deps: (lambda compute_ctx: (lambda recompute: _sx_begin(recompute(), s))(_sx_fn(lambda : (
computed = lambda compute_fn: (lambda s: (lambda deps: (lambda compute_ctx: (lambda recompute: _sx_begin(recompute(), register_in_scope(lambda : dispose_computed(s)), s))(_sx_fn(lambda : (
for_each(lambda dep: signal_remove_sub(dep, recompute), signal_deps(s)),
signal_set_deps(s, []),
(lambda ctx: (lambda prev: _sx_begin(set_tracking_context(ctx), (lambda new_val: _sx_begin(set_tracking_context(prev), signal_set_deps(s, tracking_context_deps(ctx)), (lambda old: _sx_begin(signal_set_value(s, new_val), (notify_subscribers(s) if sx_truthy((not sx_truthy(is_identical(old, new_val)))) else NIL)))(signal_value(s))))(invoke(compute_fn))))(get_tracking_context()))(make_tracking_context(recompute))
@@ -1353,12 +1353,14 @@ def effect(effect_fn):
_cells['cleanup_fn'] = NIL
run_effect = lambda : (_sx_begin((invoke(_cells['cleanup_fn']) if sx_truthy(_cells['cleanup_fn']) else NIL), for_each(lambda dep: signal_remove_sub(dep, run_effect), _cells['deps']), _sx_cell_set(_cells, 'deps', []), (lambda ctx: (lambda prev: _sx_begin(set_tracking_context(ctx), (lambda result: _sx_begin(set_tracking_context(prev), _sx_cell_set(_cells, 'deps', tracking_context_deps(ctx)), (_sx_cell_set(_cells, 'cleanup_fn', result) if sx_truthy(is_callable(result)) else NIL)))(invoke(effect_fn))))(get_tracking_context()))(make_tracking_context(run_effect))) if sx_truthy((not sx_truthy(_cells['disposed']))) else NIL)
run_effect()
return _sx_fn(lambda : (
dispose_fn = _sx_fn(lambda : (
_sx_cell_set(_cells, 'disposed', True),
(invoke(_cells['cleanup_fn']) if sx_truthy(_cells['cleanup_fn']) else NIL),
for_each(lambda dep: signal_remove_sub(dep, run_effect), _cells['deps']),
_sx_cell_set(_cells, 'deps', [])
)[-1])
register_in_scope(dispose_fn)
return dispose_fn
# *batch-depth*
_batch_depth = 0