diff --git a/shared/sx/ref/run_cek_reactive_tests.py b/shared/sx/ref/run_cek_reactive_tests.py new file mode 100644 index 0000000..299e022 --- /dev/null +++ b/shared/sx/ref/run_cek_reactive_tests.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python3 +"""Run test-cek-reactive.sx — tests for deref-as-shift reactive rendering.""" +from __future__ import annotations +import os, sys + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_PROJECT = os.path.abspath(os.path.join(_HERE, "..", "..", "..")) +sys.path.insert(0, _PROJECT) + +from shared.sx.parser import parse_all +from shared.sx.ref import sx_ref +from shared.sx.ref.sx_ref import ( + make_env, env_get, env_has, env_set, + env_extend, env_merge, +) +# Use tree-walk evaluator for interpreting .sx test files. +# The CEK override (eval_expr = cek_run) would cause the interpreted cek.sx +# to delegate to the transpiled CEK, not the interpreted one being tested. +# Override both the local names AND the module-level names so that transpiled +# functions (ho_map, call_lambda, etc.) also use tree-walk internally. +eval_expr = sx_ref._tree_walk_eval_expr +trampoline = sx_ref._tree_walk_trampoline +sx_ref.eval_expr = eval_expr +sx_ref.trampoline = trampoline +from shared.sx.types import ( + NIL, Symbol, Keyword, Lambda, Component, Island, Continuation, Macro, + _ShiftSignal, +) + +# Build env with primitives +env = make_env() + +# Platform test functions +_suite_stack: list[str] = [] +_pass_count = 0 +_fail_count = 0 + +def _try_call(thunk): + try: + trampoline(eval_expr([thunk], env)) + return {"ok": True} + except Exception as e: + return {"ok": False, "error": str(e)} + +def _report_pass(name): + global _pass_count + _pass_count += 1 + ctx = " > ".join(_suite_stack) + print(f" PASS: {ctx} > {name}") + return NIL + +def _report_fail(name, error): + global _fail_count + _fail_count += 1 + ctx = " > ".join(_suite_stack) + print(f" FAIL: {ctx} > {name}: {error}") + return NIL + +def _push_suite(name): + _suite_stack.append(name) + print(f"{' ' * (len(_suite_stack)-1)}Suite: {name}") + return NIL + +def _pop_suite(): + if _suite_stack: + _suite_stack.pop() + return NIL + +def _test_env(): + return env + +def _sx_parse(source): + return parse_all(source) + +def _sx_parse_one(source): + """Parse a single expression.""" + exprs = parse_all(source) + return exprs[0] if exprs else NIL + +def _make_continuation(fn): + return Continuation(fn) + +env["try-call"] = _try_call +env["report-pass"] = _report_pass +env["report-fail"] = _report_fail +env["push-suite"] = _push_suite +env["pop-suite"] = _pop_suite +env["test-env"] = _test_env +env["sx-parse"] = _sx_parse +env["sx-parse-one"] = _sx_parse_one +env["env-get"] = env_get +env["env-has?"] = env_has +env["env-set!"] = env_set +env["env-extend"] = env_extend +env["make-continuation"] = _make_continuation +env["continuation?"] = lambda x: isinstance(x, Continuation) +env["continuation-fn"] = lambda c: c.fn + +def _make_cek_continuation_with_data(captured, rest_kont): + c = Continuation(lambda v=NIL: v) + c._cek_data = {"captured": captured, "rest-kont": rest_kont} + return c + +env["make-cek-continuation"] = _make_cek_continuation_with_data +env["continuation-data"] = lambda c: getattr(c, '_cek_data', {}) + +# Type predicates and constructors +env["callable?"] = lambda x: callable(x) or isinstance(x, (Lambda, Component, Island, Continuation)) +env["lambda?"] = lambda x: isinstance(x, Lambda) +env["component?"] = lambda x: isinstance(x, Component) +env["island?"] = lambda x: isinstance(x, Island) +env["macro?"] = lambda x: isinstance(x, Macro) +env["thunk?"] = sx_ref.is_thunk +env["thunk-expr"] = sx_ref.thunk_expr +env["thunk-env"] = sx_ref.thunk_env +env["make-thunk"] = sx_ref.make_thunk +env["make-lambda"] = sx_ref.make_lambda +env["make-component"] = sx_ref.make_component +env["make-island"] = sx_ref.make_island +env["make-macro"] = sx_ref.make_macro +env["make-symbol"] = lambda n: Symbol(n) +env["lambda-params"] = lambda f: f.params +env["lambda-body"] = lambda f: f.body +env["lambda-closure"] = lambda f: f.closure +env["lambda-name"] = lambda f: f.name +env["set-lambda-name!"] = lambda f, n: setattr(f, 'name', n) or NIL +env["component-params"] = lambda c: c.params +env["component-body"] = lambda c: c.body +env["component-closure"] = lambda c: c.closure +env["component-has-children?"] = lambda c: c.has_children +env["component-affinity"] = lambda c: getattr(c, 'affinity', 'auto') +env["component-set-param-types!"] = lambda c, t: setattr(c, 'param_types', t) or NIL +env["macro-params"] = lambda m: m.params +env["macro-rest-param"] = lambda m: m.rest_param +env["macro-body"] = lambda m: m.body +env["macro-closure"] = lambda m: m.closure +env["env-merge"] = env_merge +env["symbol-name"] = lambda s: s.name if isinstance(s, Symbol) else str(s) +env["keyword-name"] = lambda k: k.name if isinstance(k, Keyword) else str(k) +env["type-of"] = sx_ref.type_of +env["primitive?"] = lambda n: n in sx_ref.PRIMITIVES +env["get-primitive"] = lambda n: sx_ref.PRIMITIVES.get(n) +env["strip-prefix"] = lambda s, p: s[len(p):] if s.startswith(p) else s +env["inspect"] = repr +env["debug-log"] = lambda *args: None +env["error"] = sx_ref.error +env["apply"] = lambda f, args: f(*args) + +# Functions from eval.sx that cek.sx references +env["trampoline"] = trampoline +env["eval-expr"] = eval_expr +env["eval-list"] = sx_ref.eval_list +env["eval-call"] = sx_ref.eval_call +env["call-lambda"] = sx_ref.call_lambda +env["call-component"] = sx_ref.call_component +env["parse-keyword-args"] = sx_ref.parse_keyword_args +env["sf-lambda"] = sx_ref.sf_lambda +env["sf-defcomp"] = sx_ref.sf_defcomp +env["sf-defisland"] = sx_ref.sf_defisland +env["sf-defmacro"] = sx_ref.sf_defmacro +env["sf-defstyle"] = sx_ref.sf_defstyle +env["sf-deftype"] = sx_ref.sf_deftype +env["sf-defeffect"] = sx_ref.sf_defeffect +env["sf-letrec"] = sx_ref.sf_letrec +env["sf-named-let"] = sx_ref.sf_named_let +env["sf-dynamic-wind"] = sx_ref.sf_dynamic_wind +env["sf-scope"] = sx_ref.sf_scope +env["sf-provide"] = sx_ref.sf_provide +env["qq-expand"] = sx_ref.qq_expand +env["expand-macro"] = sx_ref.expand_macro +env["cond-scheme?"] = sx_ref.cond_scheme_p + +# Higher-order form handlers +env["ho-map"] = sx_ref.ho_map +env["ho-map-indexed"] = sx_ref.ho_map_indexed +env["ho-filter"] = sx_ref.ho_filter +env["ho-reduce"] = sx_ref.ho_reduce +env["ho-some"] = sx_ref.ho_some +env["ho-every"] = sx_ref.ho_every +env["ho-for-each"] = sx_ref.ho_for_each +env["call-fn"] = sx_ref.call_fn + +# Render-related (stub for testing — no active rendering) +env["render-active?"] = lambda: False +env["is-render-expr?"] = lambda expr: False +env["render-expr"] = lambda expr, env: NIL + +# Scope primitives (needed for reactive-shift-deref island cleanup) +env["scope-push!"] = sx_ref.PRIMITIVES.get("scope-push!", lambda *a: NIL) +env["scope-pop!"] = sx_ref.PRIMITIVES.get("scope-pop!", lambda *a: NIL) +env["context"] = sx_ref.PRIMITIVES.get("context", lambda *a: NIL) +env["emit!"] = sx_ref.PRIMITIVES.get("emit!", lambda *a: NIL) +env["emitted"] = sx_ref.PRIMITIVES.get("emitted", lambda *a: []) + +# Dynamic wind +env["push-wind!"] = lambda before, after: NIL +env["pop-wind!"] = lambda: NIL +env["call-thunk"] = lambda f, e: f() if callable(f) else trampoline(eval_expr([f], e)) + +# Mutation helpers +env["dict-get"] = lambda d, k: d.get(k, NIL) if isinstance(d, dict) else NIL +env["identical?"] = lambda a, b: a is b + +def _invoke(f, *args): + """Call f with args — handles both native callables and SX lambdas.""" + if isinstance(f, Lambda): + return trampoline(sx_ref.call_lambda(f, list(args), env)) + elif callable(f): + return f(*args) + return NIL + +env["invoke"] = _invoke + +# defhandler, defpage, defquery, defaction stubs +for name in ["sf-defhandler", "sf-defpage", "sf-defquery", "sf-defaction"]: + pyname = name.replace("-", "_") + fn = getattr(sx_ref, pyname, None) + if fn: + env[name] = fn + else: + env[name] = lambda args, e, _n=name: NIL + +# Load test framework +with open(os.path.join(_HERE, "test-framework.sx")) as f: + for expr in parse_all(f.read()): + trampoline(eval_expr(expr, env)) + +# Load signals module +print("Loading signals.sx ...") +with open(os.path.join(_HERE, "signals.sx")) as f: + for expr in parse_all(f.read()): + trampoline(eval_expr(expr, env)) + +# Load frames module +print("Loading frames.sx ...") +with open(os.path.join(_HERE, "frames.sx")) as f: + for expr in parse_all(f.read()): + trampoline(eval_expr(expr, env)) + +# Load CEK module +print("Loading cek.sx ...") +with open(os.path.join(_HERE, "cek.sx")) as f: + for expr in parse_all(f.read()): + trampoline(eval_expr(expr, env)) + +# Run tests +print("=" * 60) +print("Running test-cek-reactive.sx") +print("=" * 60) + +with open(os.path.join(_HERE, "test-cek-reactive.sx")) as f: + for expr in parse_all(f.read()): + trampoline(eval_expr(expr, env)) + +print("=" * 60) +print(f"Results: {_pass_count} passed, {_fail_count} failed") +print("=" * 60) +sys.exit(1 if _fail_count > 0 else 0) diff --git a/shared/sx/ref/run_cek_tests.py b/shared/sx/ref/run_cek_tests.py index 3aae5c6..bdb25bc 100644 --- a/shared/sx/ref/run_cek_tests.py +++ b/shared/sx/ref/run_cek_tests.py @@ -10,9 +10,18 @@ sys.path.insert(0, _PROJECT) from shared.sx.parser import parse_all from shared.sx.ref import sx_ref from shared.sx.ref.sx_ref import ( - eval_expr, trampoline, make_env, env_get, env_has, env_set, + make_env, env_get, env_has, env_set, env_extend, env_merge, ) +# Use tree-walk evaluator for interpreting .sx test files. +# The CEK override (eval_expr = cek_run) would cause the interpreted cek.sx +# to delegate to the transpiled CEK, not the interpreted one being tested. +# Override both the local names AND the module-level names so that transpiled +# functions (ho_map, call_lambda, etc.) also use tree-walk internally. +eval_expr = sx_ref._tree_walk_eval_expr +trampoline = sx_ref._tree_walk_trampoline +sx_ref.eval_expr = eval_expr +sx_ref.trampoline = trampoline from shared.sx.types import ( NIL, Symbol, Keyword, Lambda, Component, Island, Continuation, Macro, _ShiftSignal, diff --git a/shared/sx/ref/test-cek-reactive.sx b/shared/sx/ref/test-cek-reactive.sx new file mode 100644 index 0000000..3cf97b0 --- /dev/null +++ b/shared/sx/ref/test-cek-reactive.sx @@ -0,0 +1,154 @@ +;; ========================================================================== +;; test-cek-reactive.sx — Tests for deref-as-shift reactive rendering +;; +;; Tests that (deref signal) inside a reactive-reset boundary performs +;; continuation capture: the rest of the expression becomes the subscriber. +;; +;; Requires: test-framework.sx, frames.sx, cek.sx, signals.sx loaded first. +;; ========================================================================== + + +;; -------------------------------------------------------------------------- +;; Basic deref behavior through CEK +;; -------------------------------------------------------------------------- + +(defsuite "deref pass-through" + (deftest "deref non-signal passes through" + (let ((result (eval-expr-cek + (sx-parse-one "(deref 42)") + (test-env)))) + (assert-equal 42 result))) + + (deftest "deref nil passes through" + (let ((result (eval-expr-cek + (sx-parse-one "(deref nil)") + (test-env)))) + (assert-nil result))) + + (deftest "deref string passes through" + (let ((result (eval-expr-cek + (sx-parse-one "(deref \"hello\")") + (test-env)))) + (assert-equal "hello" result)))) + + +;; -------------------------------------------------------------------------- +;; Deref signal without reactive-reset (no shift) +;; -------------------------------------------------------------------------- + +(defsuite "deref signal without reactive-reset" + (deftest "deref signal returns current value" + (let ((s (signal 99))) + (env-set! (test-env) "test-sig" s) + (let ((result (eval-expr-cek + (sx-parse-one "(deref test-sig)") + (test-env)))) + (assert-equal 99 result)))) + + (deftest "deref signal in expression returns computed value" + (let ((s (signal 10))) + (env-set! (test-env) "test-sig" s) + (let ((result (eval-expr-cek + (sx-parse-one "(+ 5 (deref test-sig))") + (test-env)))) + (assert-equal 15 result))))) + + +;; -------------------------------------------------------------------------- +;; Reactive reset + deref: continuation capture +;; -------------------------------------------------------------------------- + +(defsuite "reactive-reset shift" + (deftest "deref signal with reactive-reset captures continuation" + (let ((s (signal 42)) + (captured-val nil)) + ;; Run CEK with a ReactiveResetFrame + (let ((result (cek-run + (make-cek-state + (sx-parse-one "(deref test-sig)") + (let ((e (env-extend (test-env)))) + (env-set! e "test-sig" s) + e) + (list (make-reactive-reset-frame + (test-env) + (fn (v) (set! captured-val v)) + true)))))) + ;; Initial render: returns current value, update-fn NOT called (first-render) + (assert-equal 42 result) + (assert-nil captured-val)))) + + (deftest "signal change invokes subscriber with update-fn" + (let ((s (signal 10)) + (update-calls (list))) + ;; Set up reactive-reset with tracking update-fn + (scope-push! "sx-island-scope" nil) + (let ((e (env-extend (test-env)))) + (env-set! e "test-sig" s) + (cek-run + (make-cek-state + (sx-parse-one "(deref test-sig)") + e + (list (make-reactive-reset-frame + e + (fn (v) (append! update-calls v)) + true))))) + ;; Change signal — subscriber should fire + (reset! s 20) + (assert-equal 1 (len update-calls)) + (assert-equal 20 (first update-calls)) + ;; Change again + (reset! s 30) + (assert-equal 2 (len update-calls)) + (assert-equal 30 (nth update-calls 1)) + (scope-pop! "sx-island-scope"))) + + (deftest "expression with deref captures rest as continuation" + (let ((s (signal 5)) + (update-calls (list))) + (scope-push! "sx-island-scope" nil) + (let ((e (env-extend (test-env)))) + (env-set! e "test-sig" s) + ;; (str "val=" (deref test-sig)) — continuation captures (str "val=" [HOLE]) + (let ((result (cek-run + (make-cek-state + (sx-parse-one "(str \"val=\" (deref test-sig))") + e + (list (make-reactive-reset-frame + e + (fn (v) (append! update-calls v)) + true)))))) + (assert-equal "val=5" result))) + ;; Change signal — should get updated string + (reset! s 42) + (assert-equal 1 (len update-calls)) + (assert-equal "val=42" (first update-calls)) + (scope-pop! "sx-island-scope")))) + + +;; -------------------------------------------------------------------------- +;; Disposal and cleanup +;; -------------------------------------------------------------------------- + +(defsuite "disposal" + (deftest "scope cleanup unsubscribes continuation" + (let ((s (signal 1)) + (update-calls (list)) + (disposers (list))) + ;; Create island scope with collector that accumulates disposers + (scope-push! "sx-island-scope" (fn (d) (append! disposers d))) + (let ((e (env-extend (test-env)))) + (env-set! e "test-sig" s) + (cek-run + (make-cek-state + (sx-parse-one "(deref test-sig)") + e + (list (make-reactive-reset-frame + e + (fn (v) (append! update-calls v)) + true))))) + ;; Pop scope — call all disposers + (scope-pop! "sx-island-scope") + (for-each (fn (d) (invoke d)) disposers) + ;; Change signal — no update should fire + (reset! s 999) + (assert-equal 0 (len update-calls))))) diff --git a/sx/sx/nav-data.sx b/sx/sx/nav-data.sx index 9490e42..6115c5c 100644 --- a/sx/sx/nav-data.sx +++ b/sx/sx/nav-data.sx @@ -226,7 +226,9 @@ (dict :label "Scoped Effects" :href "/sx/(etc.(plan.scoped-effects))" :summary "Algebraic effects as the unified foundation — spreads, islands, lakes, signals, and context are all instances of one primitive: a named scope with downward value, upward accumulation, and a propagation mode.") (dict :label "Foundations" :href "/sx/(etc.(plan.foundations))" - :summary "The computational floor — from scoped effects through algebraic effects and delimited continuations to the CEK machine. Why three registers are irreducible, and the three-axis model (depth, topology, linearity)."))) + :summary "The computational floor — from scoped effects through algebraic effects and delimited continuations to the CEK machine. Why three registers are irreducible, and the three-axis model (depth, topology, linearity).") + (dict :label "Deref as Shift" :href "/sx/(etc.(plan.cek-reactive))" + :summary "Phase B: replace explicit effect wrapping with implicit continuation capture. Deref inside reactive-reset performs shift, capturing the rest of the expression as the subscriber."))) (define reactive-islands-nav-items (list (dict :label "Overview" :href "/sx/(geography.(reactive))" diff --git a/sx/sx/plans/cek-reactive.sx b/sx/sx/plans/cek-reactive.sx new file mode 100644 index 0000000..1d3b169 --- /dev/null +++ b/sx/sx/plans/cek-reactive.sx @@ -0,0 +1,302 @@ +;; Deref as Shift — CEK-Based Reactive DOM Renderer +;; Phase B: replace explicit effects with implicit continuation capture. + +(defcomp ~plans/cek-reactive/plan-cek-reactive-content () + (~docs/page :title "Deref as Shift — CEK-Based Reactive DOM Renderer" + + (p :class "text-stone-500 text-sm italic mb-8" + "Phase A collapsed signals to plain dicts with zero platform primitives. " + "Phase B replaces explicit effect wrapping in the reactive DOM renderer " + "with implicit continuation capture: when " (code "deref") " encounters a signal " + "inside a " (code "reactive-reset") " boundary, it performs " (code "shift") ", " + "capturing the rest of the expression as a continuation. " + "That continuation IS the subscriber.") + + ;; ===================================================================== + ;; The Insight + ;; ===================================================================== + + (~docs/section :title "The Insight" :id "insight" + + (p "Each reactive binding is a micro-computation:") + (ul :class "list-disc pl-6 mb-4 space-y-1" + (li (code "reactive-text") ": given signal value, set text node content to " (code "(str value)")) + (li (code "reactive-attr") ": given signal value, set attribute to " (code "(str value)"))) + + (p "Currently wrapped in explicit " (code "effect") " calls. With deref-as-shift, " + "the continuation captures this automatically:") + + (~docs/code :code (highlight + ";; User writes:\n(div :class (str \"count-\" (deref counter))\n (str \"Value: \" (deref counter)))\n\n;; Renderer internally wraps each expression:\n(div :class (reactive-reset update-attr-fn (str \"count-\" (deref counter)))\n (reactive-reset update-text-fn (str \"Value: \" (deref counter))))\n\n;; When (deref counter) hits a signal inside reactive-reset:\n;; 1. Shift: capture continuation (str \"count-\" [HOLE])\n;; 2. Register continuation as signal subscriber\n;; 3. Return current value for initial render\n;; When counter changes:\n;; Re-invoke continuation with new value → update-fn updates DOM" + "lisp"))) + + ;; ===================================================================== + ;; Step 1: Bootstrap CEK to JavaScript + ;; ===================================================================== + + (~docs/section :title "Step 1: Bootstrap CEK to JavaScript" :id "step-1" + + (p "Add " (code "frames.sx") " + " (code "cek.sx") " to the JS build pipeline. " + "Currently CEK is Python-only.") + + (~docs/subsection :title "1a. platform_js.py — SPEC_MODULES + platform code" + + (p "Add to " (code "SPEC_MODULES") " dict:") + (~docs/code :code (highlight + "\"frames\": (\"frames.sx\", \"frames (CEK continuation frames)\"),\n\"cek\": (\"cek.sx\", \"cek (explicit CEK machine evaluator)\"),\n\n# Add ordering (new constant):\nSPEC_MODULE_ORDER = [\"deps\", \"frames\", \"page-helpers\", \"router\", \"signals\", \"cek\"]" + "python")) + + (p "Add " (code "PLATFORM_CEK_JS") " constant (mirrors " (code "PLATFORM_CEK_PY") "):") + (~docs/code :code (highlight + "// Primitive aliases used by cek.sx\nvar inc = PRIMITIVES[\"inc\"];\nvar dec = PRIMITIVES[\"dec\"];\nvar zip_pairs = PRIMITIVES[\"zip-pairs\"];\n\nfunction makeCekContinuation(captured, restKont) {\n var c = new Continuation(function(v) { return v !== undefined ? v : NIL; });\n c._cek_data = {\"captured\": captured, \"rest-kont\": restKont};\n return c;\n}\nfunction continuationData(c) {\n return (c && c._cek_data) ? c._cek_data : {};\n}" + "javascript")) + + (p "Add " (code "CEK_FIXUPS_JS") " — iterative " (code "cek-run") " override:") + (~docs/code :code (highlight + "cekRun = function(state) {\n while (!cekTerminal_p(state)) { state = cekStep(state); }\n return cekValue(state);\n};" + "javascript"))) + + (~docs/subsection :title "1b. run_js_sx.py — Update compile_ref_to_js" + (ul :class "list-disc pl-6 mb-4 space-y-1" + (li "Auto-add " (code "\"frames\"") " when " (code "\"cek\"") " in spec_mod_set (mirror Python " (code "bootstrap_py.py") ")") + (li "Auto-add " (code "\"cek\"") " + " (code "\"frames\"") " when " (code "\"dom\"") " adapter included (CEK needed for reactive rendering)") + (li "Use " (code "SPEC_MODULE_ORDER") " for ordering instead of " (code "sorted()")) + (li "Add " (code "has_cek") " flag") + (li "Include " (code "PLATFORM_CEK_JS") " after transpiled code when " (code "has_cek")) + (li "Include " (code "CEK_FIXUPS_JS") " in fixups section when " (code "has_cek")))) + + (~docs/subsection :title "1c. js.sx — RENAMES for predicate functions" + (p "Default mangling handles most names. Only add RENAMES where " (code "?") + " suffix needs clean JS names:") + (~docs/code :code (highlight + "\"cek-terminal?\" \"cekTerminalP\"\n\"kont-empty?\" \"kontEmptyP\"\n\"make-cek-continuation\" \"makeCekContinuation\"\n\"continuation-data\" \"continuationData\"" + "lisp"))) + + (~docs/subsection :title "1d. bootstrap_py.py — RENAMES for CEK predicates" + (~docs/code :code (highlight + "\"cek-terminal?\": \"cek_terminal_p\",\n\"kont-empty?\": \"kont_empty_p\",\n\"make-cek-continuation\": \"make_cek_continuation\",\n\"continuation-data\": \"continuation_data\"," + "python"))) + + (~docs/subsection :title "Verification" + (ul :class "list-disc pl-6 mb-4 space-y-1" + (li "Rebootstrap JS: " (code "python3 bootstrap_js.py")) + (li "Check output contains frame constructors + CEK step functions") + (li "Run existing CEK Python tests: " (code "python3 run_cek_tests.py") " (should still pass)")))) + + ;; ===================================================================== + ;; Step 2: ReactiveResetFrame + DerefFrame + ;; ===================================================================== + + (~docs/section :title "Step 2: ReactiveResetFrame + DerefFrame" :id "step-2" + + (p "New frame types in " (code "frames.sx") " that enable deref-as-shift.") + + (~docs/subsection :title "2a. New frame constructors" + (~docs/code :code (highlight + ";; ReactiveResetFrame: delimiter for reactive deref-as-shift\n;; Carries an update-fn that gets called with new values on re-render.\n(define make-reactive-reset-frame\n (fn (env update-fn first-render?)\n {:type \"reactive-reset\" :env env :update-fn update-fn\n :first-render first-render?}))\n\n;; DerefFrame: awaiting evaluation of deref's argument\n(define make-deref-frame\n (fn (env)\n {:type \"deref\" :env env}))" + "lisp"))) + + (~docs/subsection :title "2b. Update kont-capture-to-reset" + (p "Must stop at EITHER " (code "\"reset\"") " OR " (code "\"reactive-reset\"") ":") + (~docs/code :code (highlight + "(define kont-capture-to-reset\n (fn (kont)\n (define scan\n (fn (k captured)\n (if (empty? k)\n (error \"shift without enclosing reset\")\n (let ((frame (first k)))\n (if (or (= (frame-type frame) \"reset\")\n (= (frame-type frame) \"reactive-reset\"))\n (list captured (rest k))\n (scan (rest k) (append captured (list frame))))))))\n (scan kont (list))))" + "lisp"))) + + (~docs/subsection :title "2c. Helpers to scan for ReactiveResetFrame" + (~docs/code :code (highlight + "(define has-reactive-reset-frame?\n (fn (kont)\n (if (empty? kont) false\n (if (= (frame-type (first kont)) \"reactive-reset\") true\n (has-reactive-reset-frame? (rest kont))))))\n\n;; Returns 3 values: (captured, frame, rest)\n(define kont-capture-to-reactive-reset\n (fn (kont)\n (define scan\n (fn (k captured)\n (if (empty? k)\n (error \"reactive deref without enclosing reactive-reset\")\n (let ((frame (first k)))\n (if (= (frame-type frame) \"reactive-reset\")\n (list captured frame (rest k))\n (scan (rest k) (append captured (list frame))))))))\n (scan kont (list))))" + "lisp")))) + + ;; ===================================================================== + ;; Step 3: Make deref a CEK Special Form + ;; ===================================================================== + + (~docs/section :title "Step 3: Make deref a CEK Special Form" :id "step-3" + + (p "When " (code "deref") " encounters a signal inside a " (code "reactive-reset") + ", perform shift.") + + (~docs/subsection :title "3a. Add to special form dispatch in cek.sx" + (p "In the dispatch table (around where " (code "reset") " and " (code "shift") " are):") + (~docs/code :code (highlight + "(= name \"deref\") (step-sf-deref args env kont)" + "lisp"))) + + (~docs/subsection :title "3b. step-sf-deref" + (p "Evaluates the argument first (push DerefFrame), then decides whether to shift:") + (~docs/code :code (highlight + "(define step-sf-deref\n (fn (args env kont)\n (make-cek-state\n (first args) env\n (kont-push (make-deref-frame env) kont))))" + "lisp"))) + + (~docs/subsection :title "3c. Handle DerefFrame in step-continue" + (p "When the deref argument is evaluated, decide: shift or return.") + (~docs/code :code (highlight + "(= ft \"deref\")\n (let ((val value)\n (fenv (get frame \"env\")))\n (if (not (signal? val))\n ;; Not a signal: pass through\n (make-cek-value val fenv rest-k)\n ;; Signal: check for ReactiveResetFrame\n (if (has-reactive-reset-frame? rest-k)\n ;; Perform reactive shift\n (reactive-shift-deref val fenv rest-k)\n ;; No reactive-reset: normal deref (scope-based tracking)\n (do\n (let ((ctx (context \"sx-reactive\" nil)))\n (when ctx\n (let ((dep-list (get ctx \"deps\"))\n (notify-fn (get ctx \"notify\")))\n (when (not (contains? dep-list val))\n (append! dep-list val)\n (signal-add-sub! val notify-fn)))))\n (make-cek-value (signal-value val) fenv rest-k)))))" + "lisp"))) + + (~docs/subsection :title "3d. reactive-shift-deref — the heart" + (~docs/code :code (highlight + "(define reactive-shift-deref\n (fn (sig env kont)\n (let ((scan-result (kont-capture-to-reactive-reset kont))\n (captured-frames (first scan-result))\n (reset-frame (nth scan-result 1))\n (remaining-kont (nth scan-result 2))\n (update-fn (get reset-frame \"update-fn\")))\n ;; Sub-scope for nested subscriber cleanup on re-invocation\n (let ((sub-disposers (list)))\n (let ((subscriber\n (fn ()\n ;; Dispose previous nested subscribers\n (for-each (fn (d) (invoke d)) sub-disposers)\n (set! sub-disposers (list))\n ;; Re-invoke: push fresh ReactiveResetFrame (first-render=false)\n (let ((new-reset (make-reactive-reset-frame env update-fn false))\n (new-kont (concat captured-frames\n (list new-reset)\n remaining-kont)))\n (with-island-scope\n (fn (d) (append! sub-disposers d))\n (fn ()\n (cek-run\n (make-cek-value (signal-value sig) env new-kont))))))))\n ;; Register subscriber\n (signal-add-sub! sig subscriber)\n ;; Register cleanup with island scope\n (register-in-scope\n (fn ()\n (signal-remove-sub! sig subscriber)\n (for-each (fn (d) (invoke d)) sub-disposers)))\n ;; Return current value for initial render\n (make-cek-value (signal-value sig) env remaining-kont))))))" + "lisp"))) + + (~docs/subsection :title "3e. Handle ReactiveResetFrame in step-continue" + (p "When expression completes normally (or after re-invocation):") + (~docs/code :code (highlight + "(= ft \"reactive-reset\")\n (let ((update-fn (get frame \"update-fn\"))\n (first? (get frame \"first-render\")))\n ;; On re-render (not first), call update-fn with new value\n (when (and update-fn (not first?))\n (invoke update-fn value))\n (make-cek-value value env rest-k))" + "lisp")) + (p (strong "Key:") " On first render, update-fn is NOT called — the value flows back to the caller " + "who inserts it into the DOM. On re-render (subscriber fires), update-fn IS called " + "to mutate the existing DOM."))) + + ;; ===================================================================== + ;; Step 4: Integrate into adapter-dom.sx + ;; ===================================================================== + + (~docs/section :title "Step 4: Integrate into adapter-dom.sx" :id "step-4" + + (p "Add CEK reactive path alongside existing effect-based path, controlled by opt-in flag.") + + (~docs/subsection :title "4a. Opt-in flag" + (~docs/code :code (highlight + "(define *use-cek-reactive* false)\n(define enable-cek-reactive! (fn () (set! *use-cek-reactive* true)))" + "lisp"))) + + (~docs/subsection :title "4b. CEK reactive attribute binding" + (~docs/code :code (highlight + "(define cek-reactive-attr\n (fn (el attr-name expr env)\n (let ((update-fn (fn (val)\n (cond\n (or (nil? val) (= val false)) (dom-remove-attr el attr-name)\n (= val true) (dom-set-attr el attr-name \"\")\n :else (dom-set-attr el attr-name (str val))))))\n ;; Mark for morph protection\n (let ((existing (or (dom-get-attr el \"data-sx-reactive-attrs\") \"\"))\n (updated (if (empty? existing) attr-name (str existing \",\" attr-name))))\n (dom-set-attr el \"data-sx-reactive-attrs\" updated))\n ;; Initial render via CEK with ReactiveResetFrame\n (let ((initial (cek-run\n (make-cek-state expr env\n (list (make-reactive-reset-frame env update-fn true))))))\n (invoke update-fn initial)))))" + "lisp"))) + + (~docs/subsection :title "4c. Modify render-dom-element dispatch" + (p "In attribute processing, add conditional:") + (~docs/code :code (highlight + "(context \"sx-island-scope\" nil)\n (if *use-cek-reactive*\n (cek-reactive-attr el attr-name attr-expr env)\n (reactive-attr el attr-name\n (fn () (trampoline (eval-expr attr-expr env)))))" + "lisp")) + (p "Similarly for text positions and conditional rendering.")) + + (~docs/subsection :title "4d. CEK reactive text" + (~docs/code :code (highlight + "(define cek-reactive-text\n (fn (expr env)\n (let ((node (create-text-node \"\"))\n (update-fn (fn (val)\n (dom-set-text-content node (str val)))))\n (let ((initial (cek-run\n (make-cek-state expr env\n (list (make-reactive-reset-frame env update-fn true))))))\n (dom-set-text-content node (str initial))\n node))))" + "lisp"))) + + (~docs/subsection :title "4e. What stays unchanged" + (ul :class "list-disc pl-6 mb-4 space-y-1" + (li (code "reactive-list") " — keyed reconciliation is complex; keep effect-based for now") + (li (code "reactive-spread") " — spread tracking is complex; keep effect-based") + (li (code "effect") ", " (code "computed") " — still needed for non-rendering side effects") + (li "Existing " (code "reactive-*") " functions — remain as default path")))) + + ;; ===================================================================== + ;; Step 5: Tests + ;; ===================================================================== + + (~docs/section :title "Step 5: Tests" :id "step-5" + + (~docs/subsection :title "5a. test-cek-reactive.sx" + (p "Tests:") + (ol :class "list-decimal pl-6 mb-4 space-y-1" + (li (code "deref") " non-signal passes through (no shift)") + (li (code "deref") " signal without reactive-reset: returns value, no subscription") + (li (code "deref") " signal with reactive-reset: shifts, registers subscriber, update-fn called on change") + (li "Expression with deref: " (code "(str \"hello \" (deref sig))") " — continuation captures rest") + (li "Multi-deref: both signals create subscribers, both fire correctly") + (li "Disposal: removing island scope unsubscribes all continuations") + (li "Stale subscriber cleanup: re-invocation disposes nested subscribers"))) + + (~docs/subsection :title "5b. run_cek_reactive_tests.py" + (p "Mirrors " (code "run_cek_tests.py") ". " + "Loads frames.sx, cek.sx, signals.sx, runs test-cek-reactive.sx."))) + + ;; ===================================================================== + ;; Step 6: Browser Demo + ;; ===================================================================== + + (~docs/section :title "Step 6: Browser Demo" :id "step-6" + (p "Demo showing:") + (ul :class "list-disc pl-6 mb-4 space-y-1" + (li "Counter island with implicit reactivity (no explicit effects)") + (li (code "(deref counter)") " in text position auto-updates") + (li (code "(str \"count-\" (deref class-sig))") " in attr position auto-updates") + (li "Side-by-side comparison: effect-based vs continuation-based code"))) + + ;; ===================================================================== + ;; Multi-Deref Handling + ;; ===================================================================== + + (~docs/section :title "Multi-Deref Handling" :id "multi-deref" + + (~docs/code :code (highlight "(str (deref first-name) \" \" (deref last-name))" "lisp")) + + (ol :class "list-decimal pl-6 mb-6 space-y-3" + (li (strong "Initial render:") " First " (code "deref") " hits signal → shifts, captures " + (code "(str [HOLE] \" \" (deref last-name))") ". Subscriber registered for " + (code "first-name") ". Returns current value. Second " (code "deref") + " runs (no ReactiveResetFrame between it and the already-consumed one) — " + "falls through to normal scope-based tracking.") + (li (strong "first-name changes:") " Subscriber fires → re-pushes ReactiveResetFrame → " + "re-invokes continuation with new first-name value → second " (code "deref") + " hits ReactiveResetFrame again → shifts, creates NEW subscriber for " + (code "last-name") ". Old last-name subscriber cleaned up via sub-scope disposal.") + (li (strong "last-name changes:") " Its subscriber fires → re-invokes inner continuation → " + "update-fn called with new result.")) + + (p "This creates O(n) nested continuations for n derefs. Fine for small reactive expressions.")) + + ;; ===================================================================== + ;; Commit Strategy + ;; ===================================================================== + + (~docs/section :title "Commit Strategy" :id "commits" + + (ol :class "list-decimal pl-6 mb-4 space-y-1" + (li (strong "Commit 1:") " Bootstrap CEK to JS (Step 1) — mechanical, independent") + (li (strong "Commit 2:") " ReactiveResetFrame + DerefFrame (Step 2) — new frame types") + (li (strong "Commit 3:") " Deref-as-shift + adapter integration + tests (Steps 3-5) — the core change") + (li (strong "Commit 4:") " Browser demo (Step 6)"))) + + ;; ===================================================================== + ;; Files + ;; ===================================================================== + + (~docs/section :title "Files" :id "files" + + (div :class "overflow-x-auto mb-6" + (table :class "min-w-full text-sm" + (thead (tr + (th :class "text-left pr-4 pb-2 font-semibold" "File") + (th :class "text-left pb-2 font-semibold" "Change"))) + (tbody + (tr (td :class "pr-4 py-1 font-mono text-xs" "shared/sx/ref/platform_js.py") + (td "SPEC_MODULES entries, PLATFORM_CEK_JS, CEK_FIXUPS_JS, SPEC_MODULE_ORDER")) + (tr (td :class "pr-4 py-1 font-mono text-xs" "shared/sx/ref/run_js_sx.py") + (td "compile_ref_to_js: has_cek, auto-inclusion, ordering, platform code")) + (tr (td :class "pr-4 py-1 font-mono text-xs" "shared/sx/ref/js.sx") + (td "RENAMES for CEK predicate functions")) + (tr (td :class "pr-4 py-1 font-mono text-xs" "shared/sx/ref/bootstrap_py.py") + (td "RENAMES for CEK predicates")) + (tr (td :class "pr-4 py-1 font-mono text-xs" "shared/sx/ref/frames.sx") + (td "ReactiveResetFrame, DerefFrame, has-reactive-reset-frame?, kont-capture-to-reactive-reset")) + (tr (td :class "pr-4 py-1 font-mono text-xs" "shared/sx/ref/cek.sx") + (td "step-sf-deref, reactive-shift-deref, deref in dispatch, ReactiveResetFrame in step-continue")) + (tr (td :class "pr-4 py-1 font-mono text-xs" "shared/sx/ref/adapter-dom.sx") + (td "*use-cek-reactive* flag, cek-reactive-attr, cek-reactive-text, conditional dispatch")) + (tr (td :class "pr-4 py-1 font-mono text-xs" "shared/sx/ref/test-cek-reactive.sx") + (td (strong "New:") " continuation-based reactivity tests")) + (tr (td :class "pr-4 py-1 font-mono text-xs" "shared/sx/ref/run_cek_reactive_tests.py") + (td (strong "New:") " Python test runner")) + (tr (td :class "pr-4 py-1 font-mono text-xs" "shared/sx/ref/sx_ref.py") + (td "Rebootstrap (generated)")) + (tr (td :class "pr-4 py-1 font-mono text-xs" "shared/static/scripts/sx-browser.js") + (td "Rebootstrap (generated)")))))) + + ;; ===================================================================== + ;; Risks + ;; ===================================================================== + + (~docs/section :title "Risks" :id "risks" + + (ol :class "list-decimal pl-6 mb-4 space-y-2" + (li (strong "Performance:") " CEK allocates a dict per step. Mitigated: opt-in flag, tree-walk remains default.") + (li (strong "Multi-deref stale subscribers:") " Mitigated: sub-scope disposal before re-invocation.") + (li (strong "Interaction with user shift/reset:") " " (code "kont-capture-to-reactive-reset") + " only scans for " (code "\"reactive-reset\"") ", not " (code "\"reset\"") ". Orthogonal.") + (li (strong "JS bootstrapper complexity:") " ~10 RENAMES for predicates. Default mangling handles the rest."))))) diff --git a/sx/sxc/pages/docs.sx b/sx/sxc/pages/docs.sx index 92e1d11..45574b4 100644 --- a/sx/sxc/pages/docs.sx +++ b/sx/sxc/pages/docs.sx @@ -578,6 +578,7 @@ "sx-protocol" (~plans/sx-protocol/plan-sx-protocol-content) "scoped-effects" (~plans/scoped-effects/plan-scoped-effects-content) "foundations" (~plans/foundations/plan-foundations-content) + "cek-reactive" (~plans/cek-reactive/plan-cek-reactive-content) :else (~plans/index/plans-index-content)))) ;; ---------------------------------------------------------------------------