Unify scoped effects: scope as general primitive, provide as sugar
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 12m54s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 12m54s
- Add `scope` special form to eval.sx: (scope name body...) or (scope name :value v body...) — general dynamic scope primitive - `provide` becomes sugar: (provide name value body...) calls scope - Rename provide-push!/provide-pop! to scope-push!/scope-pop! throughout all adapters (async, dom, html, sx) and platform implementations - Update boundary.sx: Tier 5 now "Scoped effects" with scope-push!/ scope-pop! as primary, provide-push!/provide-pop! as aliases - Add scope form handling to async adapter and aser wire format - Update sx-browser.js, sx_ref.py (bootstrapped output) - Add scopes.sx docs page, update provide/spreads/demo docs - Update nav-data, page-functions, docs page definitions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -374,10 +374,12 @@
|
||||
:children (list
|
||||
{:label "Reference" :href "/sx/(geography.(hypermedia.(reference)))" :children reference-nav-items}
|
||||
{:label "Examples" :href "/sx/(geography.(hypermedia.(example)))" :children examples-nav-items})}
|
||||
{:label "Scopes" :href "/sx/(geography.(scopes))"
|
||||
:summary "The unified primitive beneath provide, collect!, spreads, and islands. Named scope with downward value, upward accumulation, and a dedup flag."}
|
||||
{:label "Provide / Emit!" :href "/sx/(geography.(provide))"
|
||||
:summary "Render-time dynamic scope — the substrate beneath spreads, CSSX, and script collection. Downward context, upward accumulation, one mechanism."}
|
||||
:summary "Sugar for scope-with-value. Render-time dynamic scope — the substrate beneath spreads, CSSX, and script collection."}
|
||||
{:label "Spreads" :href "/sx/(geography.(spreads))"
|
||||
:summary "Child-to-parent communication across render boundaries — spread, collect!, reactive-spread, built on provide/emit!."}
|
||||
:summary "Child-to-parent communication across render boundaries — spread, collect!, reactive-spread, built on scopes."}
|
||||
{:label "Marshes" :href "/sx/(geography.(marshes))"
|
||||
:summary "Where reactivity and hypermedia interpenetrate — server writes to signals, reactive transforms reshape server content, client state modifies how hypermedia is interpreted."}
|
||||
{:label "Isomorphism" :href "/sx/(geography.(isomorphism))" :children isomorphism-nav-items})}
|
||||
|
||||
@@ -60,6 +60,10 @@
|
||||
"phase2" '(~reactive-islands/phase2/reactive-islands-phase2-content)
|
||||
:else '(~reactive-islands/index/reactive-islands-index-content)))))
|
||||
|
||||
(define scopes
|
||||
(fn (content)
|
||||
(if (nil? content) '(~geography/scopes-content) content)))
|
||||
|
||||
(define spreads
|
||||
(fn (content)
|
||||
(if (nil? content) '(~geography/spreads-content) content)))
|
||||
|
||||
@@ -340,24 +340,27 @@
|
||||
(p "The path from current SX to the scope primitive follows the existing plan "
|
||||
"and adds two phases:")
|
||||
|
||||
(~docs/subsection :title "Phase 1: provide/context/emit! (immediate)"
|
||||
(p "Already planned. Implement render-time dynamic scope. Four primitives: "
|
||||
(~docs/subsection :title "Phase 1: provide/context/emit! ✓"
|
||||
(p (strong "Complete. ") "Render-time dynamic scope. Four primitives: "
|
||||
(code "provide") " (special form), " (code "context") ", " (code "emit!") ", "
|
||||
(code "emitted") ". Platform provides " (code "provide-push!/provide-pop!") ".")
|
||||
(p "This is " (code "scope") " with " (code ":propagation :render") " only. "
|
||||
"No change to islands or lakes. Pure addition.")
|
||||
(p (strong "Delivers: ") "render-time context, scoped accumulation, "
|
||||
"spread and collect reimplemented as sugar over provide/emit."))
|
||||
(code "emitted") ". Platform provides " (code "scope-push!/scope-pop!") ". "
|
||||
"Spreads reimplemented on provide/emit!.")
|
||||
(p "See "
|
||||
(a :href "/sx/(geography.(provide))" :class "text-violet-600 hover:underline" "provide article")
|
||||
" and "
|
||||
(a :href "/sx/(geography.(spreads))" :class "text-violet-600 hover:underline" "spreads article")
|
||||
"."))
|
||||
|
||||
(~docs/subsection :title "Phase 2: scope as the common form (next)"
|
||||
(p "Introduce " (code "scope") " as the general form. "
|
||||
(code "provide") " becomes sugar for " (code "(scope ... :propagation :render)") ". "
|
||||
(code "defisland") " becomes sugar for " (code "(scope ... :propagation :reactive)") ". "
|
||||
(code "lake") " becomes sugar for " (code "(scope ... :propagation :morph)") ".")
|
||||
(p "The sugar forms remain — nobody writes " (code "scope") " directly in page code. "
|
||||
"But the evaluator, adapters, and bootstrappers all dispatch through one mechanism.")
|
||||
(p (strong "Delivers: ") "unified internal representation, reactive context (the new cell), "
|
||||
"simplified adapter code (one scope handler instead of three separate paths)."))
|
||||
(~docs/subsection :title "Phase 2: scope as the common form ✓"
|
||||
(p (strong "Complete. ") (code "scope") " is now the general form. "
|
||||
(code "provide") " is sugar for " (code "(scope name :value v body...)") ". "
|
||||
(code "collect!") " creates a lazy root scope with deduplication. "
|
||||
"All adapters use " (code "scope-push!/scope-pop!") " directly.")
|
||||
(p "The unified platform structure:")
|
||||
(~docs/code :code (highlight "_scope_stacks = {} ;; {name: [{value, emitted: [], dedup: bool}]}" "python"))
|
||||
(p "See "
|
||||
(a :href "/sx/(geography.(scopes))" :class "text-violet-600 hover:underline" "scopes article")
|
||||
"."))
|
||||
|
||||
(~docs/subsection :title "Phase 3: effect handlers (future)"
|
||||
(p "Make propagation modes extensible. A " (code ":propagation") " value is a "
|
||||
@@ -437,8 +440,10 @@
|
||||
"and composable. It's the last primitive SX needs.")
|
||||
|
||||
(~docs/note
|
||||
(p (strong "Status: ") "Phase 1 (" (code "provide/context/emit!") ") is specced and "
|
||||
"ready to build. Phase 2 (" (code "scope") " unification) follows naturally once "
|
||||
"provide is working. Phase 3 (extensible handlers) is the research frontier — "
|
||||
(p (strong "Status: ") "Phase 1 (" (code "provide/context/emit!") ") and "
|
||||
"Phase 2 (" (code "scope") " unification) are complete. "
|
||||
"574 tests pass. All four adapters use " (code "scope-push!/scope-pop!") ". "
|
||||
(code "collect!") " is backed by lazy scopes with dedup. "
|
||||
"Phase 3 (extensible handlers) is the research frontier — "
|
||||
"it may turn out that three modes are sufficient, or it may turn out that "
|
||||
"user-defined modes unlock something unexpected.")))))
|
||||
|
||||
@@ -67,10 +67,12 @@
|
||||
(~docs/page :title "Provide / Context / Emit!"
|
||||
|
||||
(p :class "text-stone-500 text-sm italic mb-8"
|
||||
"Render-time dynamic scope. " (code "provide") " creates a named scope with a value "
|
||||
"and an accumulator. " (code "context") " reads the value downward. "
|
||||
"Sugar for " (code "scope") " with a value. " (code "provide") " creates a named scope "
|
||||
"with a value and an accumulator. " (code "context") " reads the value downward. "
|
||||
(code "emit!") " appends to the accumulator upward. " (code "emitted") " retrieves what was emitted. "
|
||||
"This is the substrate that spreads, CSSX, and script collection are built on.")
|
||||
"See "
|
||||
(a :href "/sx/(geography.(scopes))" :class "text-violet-600 hover:underline" "scopes")
|
||||
" for the unified primitive.")
|
||||
|
||||
;; =====================================================================
|
||||
;; I. The four primitives
|
||||
@@ -215,25 +217,26 @@
|
||||
;; =====================================================================
|
||||
|
||||
(~docs/section :title "Platform implementation" :id "platform"
|
||||
(p "Each platform (Python, JavaScript) must provide five operations. "
|
||||
"The platform manages per-name stacks — each stack entry has a value and an "
|
||||
"emitted list.")
|
||||
(p (code "provide") " is sugar for " (code "scope") ". At the platform level, "
|
||||
(code "provide-push!") " and " (code "provide-pop!") " are aliases for "
|
||||
(code "scope-push!") " and " (code "scope-pop!") ". All operations work on a unified "
|
||||
(code "_scope_stacks") " data structure.")
|
||||
|
||||
(~docs/table
|
||||
:headers (list "Platform primitive" "Purpose")
|
||||
:rows (list
|
||||
(list "provide-push!(name, value)" "Push a new scope with value and empty emitted list")
|
||||
(list "provide-pop!(name)" "Pop the most recent scope")
|
||||
(list "scope-push!(name, value)" "Push a new scope with value and empty accumulator")
|
||||
(list "scope-pop!(name)" "Pop the most recent scope")
|
||||
(list "context(name, ...default)" "Read value from nearest scope (error if missing and no default)")
|
||||
(list "emit!(name, value)" "Append to nearest scope's emitted list (tolerant: no-op if missing)")
|
||||
(list "emitted(name)" "Return list of emitted values from nearest scope")))
|
||||
(list "emit!(name, value)" "Append to nearest scope's accumulator (tolerant: no-op if missing)")
|
||||
(list "emitted(name)" "Return accumulated values from nearest scope")))
|
||||
|
||||
(p (code "provide") " itself is a special form in "
|
||||
(p (code "provide") " is a special form in "
|
||||
(a :href "/sx/(language.(spec.(explore.evaluator)))" :class "font-mono text-violet-600 hover:underline text-sm" "eval.sx")
|
||||
" — it calls " (code "provide-push!") ", evaluates the body, "
|
||||
"then calls " (code "provide-pop!") ". The five platform primitives are declared in "
|
||||
(a :href "/sx/(language.(spec.(explore.boundary)))" :class "font-mono text-violet-600 hover:underline text-sm" "boundary.sx")
|
||||
" (Tier 5: Dynamic scope).")
|
||||
" — it calls " (code "scope-push!") ", evaluates the body, "
|
||||
"then calls " (code "scope-pop!") ". See "
|
||||
(a :href "/sx/(geography.(scopes))" :class "text-violet-600 hover:underline" "scopes")
|
||||
" for the full unified platform.")
|
||||
|
||||
(~docs/note
|
||||
(p (strong "Spec explorer: ") "See the provide/emit! primitives in "
|
||||
|
||||
@@ -11,73 +11,73 @@
|
||||
|
||||
(~docs/section :title "1. Signal + Computed + Effect" :id "demo-counter"
|
||||
(p "A signal holds a value. A computed derives from it. Click the buttons — the counter and doubled value update instantly, no server round-trip.")
|
||||
(~reactive-islands/demo/counter :initial 0)
|
||||
(~reactive-islands/index/demo-counter :initial 0)
|
||||
(~docs/code :code (highlight "(defisland ~reactive-islands/demo/counter (&key initial)\n (let ((count (signal (or initial 0)))\n (doubled (computed (fn () (* 2 (deref count))))))\n (div :class \"...\"\n (button :on-click (fn (e) (swap! count dec)) \"−\")\n (span (deref count))\n (button :on-click (fn (e) (swap! count inc)) \"+\")\n (p \"doubled: \" (deref doubled)))))" "lisp"))
|
||||
(p (code "(deref count)") " in a text position creates a reactive text node. When " (code "count") " changes, " (em "only that text node") " updates. " (code "doubled") " recomputes automatically. No diffing."))
|
||||
|
||||
(~docs/section :title "2. Temperature Converter" :id "demo-temperature"
|
||||
(p "Two derived values from one signal. Click to change Celsius — Fahrenheit updates reactively.")
|
||||
(~reactive-islands/demo/temperature)
|
||||
(~reactive-islands/index/demo-temperature)
|
||||
(~docs/code :code (highlight "(defisland ~reactive-islands/demo/temperature ()\n (let ((celsius (signal 20)))\n (div :class \"...\"\n (button :on-click (fn (e) (swap! celsius (fn (c) (- c 5)))) \"−5\")\n (span (deref celsius))\n (button :on-click (fn (e) (swap! celsius (fn (c) (+ c 5)))) \"+5\")\n (span \"°C = \")\n (span (+ (* (deref celsius) 1.8) 32))\n (span \"°F\"))))" "lisp"))
|
||||
(p "The actual implementation uses " (code "computed") " for Fahrenheit: " (code "(computed (fn () (+ (* (deref celsius) 1.8) 32)))") ". The " (code "(deref fahrenheit)") " in the span creates a reactive text node that updates when celsius changes."))
|
||||
|
||||
(~docs/section :title "3. Effect + Cleanup: Stopwatch" :id "demo-stopwatch"
|
||||
(p "Effects can return cleanup functions. This stopwatch starts a " (code "set-interval") " — the cleanup clears it when the running signal toggles off.")
|
||||
(~reactive-islands/demo/stopwatch)
|
||||
(~reactive-islands/index/demo-stopwatch)
|
||||
(~docs/code :code (highlight "(defisland ~reactive-islands/demo/stopwatch ()\n (let ((running (signal false))\n (elapsed (signal 0))\n (time-text (create-text-node \"0.0s\"))\n (btn-text (create-text-node \"Start\")))\n ;; Timer: effect creates interval, cleanup clears it\n (effect (fn ()\n (when (deref running)\n (let ((id (set-interval (fn () (swap! elapsed inc)) 100)))\n (fn () (clear-interval id))))))\n ;; Display: updates text node when elapsed changes\n (effect (fn ()\n (let ((e (deref elapsed)))\n (dom-set-text-content time-text\n (str (floor (/ e 10)) \".\" (mod e 10) \"s\")))))\n ;; Button label\n (effect (fn ()\n (dom-set-text-content btn-text\n (if (deref running) \"Stop\" \"Start\"))))\n (div :class \"...\"\n (span time-text)\n (button :on-click (fn (e) (swap! running not)) btn-text)\n (button :on-click (fn (e)\n (reset! running false) (reset! elapsed 0)) \"Reset\"))))" "lisp"))
|
||||
(p "Three effects, each tracking different signals. The timer effect's cleanup fires before each re-run — toggling " (code "running") " off clears the interval. No hook rules: effects can appear anywhere, in any order."))
|
||||
|
||||
(~docs/section :title "4. Imperative Pattern" :id "demo-imperative"
|
||||
(p "For complex reactivity (dynamic classes, conditional text), use the imperative pattern: " (code "create-text-node") " + " (code "effect") " + " (code "dom-set-text-content") ".")
|
||||
(~reactive-islands/demo/imperative)
|
||||
(~reactive-islands/index/demo-imperative)
|
||||
(~docs/code :code (highlight "(defisland ~reactive-islands/demo/imperative ()\n (let ((count (signal 0))\n (text-node (create-text-node \"0\")))\n ;; Explicit effect: re-runs when count changes\n (effect (fn ()\n (dom-set-text-content text-node (str (deref count)))))\n (div :class \"...\"\n (span text-node)\n (button :on-click (fn (e) (swap! count inc)) \"+\"))))" "lisp"))
|
||||
(p "Two patterns exist: " (strong "declarative") " (" (code "(span (deref sig))") " — auto-reactive via " (code "reactive-text") ") and " (strong "imperative") " (" (code "create-text-node") " + " (code "effect") " — explicit, full control). Use declarative for simple text, imperative for dynamic classes, conditional DOM, or complex updates."))
|
||||
|
||||
(~docs/section :title "5. Reactive List" :id "demo-reactive-list"
|
||||
(p "When " (code "map") " is used with " (code "(deref signal)") " inside an island, it auto-upgrades to a reactive list. With " (code ":key") " attributes, existing DOM nodes are reused across updates — only additions, removals, and reorderings touch the DOM.")
|
||||
(~reactive-islands/demo/reactive-list)
|
||||
(~reactive-islands/index/demo-reactive-list)
|
||||
(~docs/code :code (highlight "(defisland ~reactive-islands/demo/reactive-list ()\n (let ((next-id (signal 1))\n (items (signal (list)))\n (add-item (fn (e)\n (batch (fn ()\n (swap! items (fn (old)\n (append old (dict \"id\" (deref next-id)\n \"text\" (str \"Item \" (deref next-id))))))\n (swap! next-id inc)))))\n (remove-item (fn (id)\n (swap! items (fn (old)\n (filter (fn (item) (not (= (get item \"id\") id))) old))))))\n (div\n (button :on-click add-item \"Add Item\")\n (span (deref (computed (fn () (len (deref items))))) \" items\")\n (ul\n (map (fn (item)\n (li :key (str (get item \"id\"))\n (span (get item \"text\"))\n (button :on-click (fn (e) (remove-item (get item \"id\"))) \"✕\")))\n (deref items))))))" "lisp"))
|
||||
(p (code ":key") " identifies each list item. When items change, the reconciler matches old and new keys — reusing existing DOM nodes, inserting new ones, and removing stale ones. Without keys, the list falls back to clear-and-rerender. " (code "batch") " groups the two signal writes into one update pass."))
|
||||
|
||||
(~docs/section :title "6. Input Binding" :id "demo-input-binding"
|
||||
(p "The " (code ":bind") " attribute creates a two-way link between a signal and a form element. Type in the input — the signal updates. Change the signal — the input updates. Works with text inputs, checkboxes, radios, textareas, and selects.")
|
||||
(~reactive-islands/demo/input-binding)
|
||||
(~reactive-islands/index/demo-input-binding)
|
||||
(~docs/code :code (highlight "(defisland ~reactive-islands/demo/input-binding ()\n (let ((name (signal \"\"))\n (agreed (signal false)))\n (div\n (input :type \"text\" :bind name\n :placeholder \"Type your name...\")\n (span \"Hello, \" (strong (deref name)) \"!\")\n (input :type \"checkbox\" :bind agreed)\n (when (deref agreed)\n (p \"Thanks for agreeing!\")))))" "lisp"))
|
||||
(p (code ":bind") " detects the element type automatically — text inputs use " (code "value") " + " (code "input") " event, checkboxes use " (code "checked") " + " (code "change") " event. The effect only updates the DOM when the value actually changed, preventing cursor jump."))
|
||||
|
||||
(~docs/section :title "7. Portals" :id "demo-portal"
|
||||
(p "A " (code "portal") " renders children into a DOM node " (em "outside") " the island's subtree. Essential for modals, tooltips, and toasts — anything that must escape " (code "overflow:hidden") " or z-index stacking.")
|
||||
(~reactive-islands/demo/portal)
|
||||
(~reactive-islands/index/demo-portal)
|
||||
(~docs/code :code (highlight "(defisland ~reactive-islands/demo/portal ()\n (let ((open? (signal false)))\n (div\n (button :on-click (fn (e) (swap! open? not))\n (if (deref open?) \"Close Modal\" \"Open Modal\"))\n (portal \"#portal-root\"\n (when (deref open?)\n (div :class \"fixed inset-0 bg-black/50 ...\"\n :on-click (fn (e) (reset! open? false))\n (div :class \"bg-white rounded-lg p-6 ...\"\n :on-click (fn (e) (stop-propagation e))\n (h2 \"Portal Modal\")\n (p \"Rendered outside the island's DOM.\")\n (button :on-click (fn (e) (reset! open? false))\n \"Close\"))))))))" "lisp"))
|
||||
(p "The portal content lives in " (code "#portal-root") " (typically at the page body level), not inside the island. On island disposal, portal content is automatically removed from its target — the " (code "register-in-scope") " mechanism handles cleanup."))
|
||||
|
||||
(~docs/section :title "8. Error Boundaries" :id "demo-error-boundary"
|
||||
(p "When an island's rendering or effect throws, " (code "error-boundary") " catches the error and renders a fallback. The fallback receives the error and a retry function. Partial effects created before the error are disposed automatically.")
|
||||
(~reactive-islands/demo/error-boundary)
|
||||
(~reactive-islands/index/demo-error-boundary)
|
||||
(~docs/code :code (highlight "(defisland ~reactive-islands/demo/error-boundary ()\n (let ((throw? (signal false)))\n (error-boundary\n ;; Fallback: receives (err retry-fn)\n (fn (err retry-fn)\n (div :class \"p-3 bg-red-50 border border-red-200 rounded\"\n (p :class \"text-red-700\" (error-message err))\n (button :on-click (fn (e)\n (reset! throw? false) (invoke retry-fn))\n \"Retry\")))\n ;; Children: the happy path\n (do\n (when (deref throw?) (error \"Intentional explosion!\"))\n (p \"Everything is fine.\")))))" "lisp"))
|
||||
(p "React equivalent: " (code "componentDidCatch") " / " (code "ErrorBoundary") ". SX's version is simpler — one form, not a class. The " (code "error-boundary") " form is a render-dom special form in " (code "adapter-dom.sx") "."))
|
||||
|
||||
(~docs/section :title "9. Refs — Imperative DOM Access" :id "demo-refs"
|
||||
(p "The " (code ":ref") " attribute captures a DOM element handle into a dict. Use it for imperative operations: focusing, measuring, reading values.")
|
||||
(~reactive-islands/demo/refs)
|
||||
(~reactive-islands/index/demo-refs)
|
||||
(~docs/code :code (highlight "(defisland ~reactive-islands/demo/refs ()\n (let ((my-ref (dict \"current\" nil))\n (msg (signal \"\")))\n (input :ref my-ref :type \"text\"\n :placeholder \"I can be focused programmatically\")\n (button :on-click (fn (e)\n (dom-focus (get my-ref \"current\")))\n \"Focus Input\")\n (button :on-click (fn (e)\n (let ((el (get my-ref \"current\")))\n (reset! msg (str \"value: \" (dom-get-prop el \"value\")))))\n \"Read Input\")\n (when (not (= (deref msg) \"\"))\n (p (deref msg)))))" "lisp"))
|
||||
(p "React equivalent: " (code "useRef") ". In SX, a ref is just " (code "(dict \"current\" nil)") " — no special API. The " (code ":ref") " attribute sets " (code "(dict-set! ref \"current\" el)") " when the element is created. Read it with " (code "(get ref \"current\")") "."))
|
||||
|
||||
(~docs/section :title "10. Dynamic Class and Style" :id "demo-dynamic-class"
|
||||
(p "React uses " (code "className") " and " (code "style") " props with state. SX does the same — " (code "(deref signal)") " inside a " (code ":class") " or " (code ":style") " attribute creates a reactive binding. The attribute updates when the signal changes.")
|
||||
(~reactive-islands/demo/dynamic-class)
|
||||
(~reactive-islands/index/demo-dynamic-class)
|
||||
(~docs/code :code (highlight "(defisland ~reactive-islands/demo/dynamic-class ()\n (let ((danger (signal false))\n (size (signal 16)))\n (div\n (button :on-click (fn (e) (swap! danger not))\n (if (deref danger) \"Safe mode\" \"Danger mode\"))\n (button :on-click (fn (e) (swap! size (fn (s) (+ s 2))))\n \"Bigger\")\n ;; Reactive class — recomputed when danger changes\n (div :class (str \"p-3 rounded font-medium \"\n (if (deref danger)\n \"bg-red-100 text-red-800\"\n \"bg-green-100 text-green-800\"))\n ;; Reactive style — recomputed when size changes\n :style (str \"font-size:\" (deref size) \"px\")\n \"This element's class and style are reactive.\"))))" "lisp"))
|
||||
(p "React equivalent: " (code "className={danger ? 'red' : 'green'}") " and " (code "style={{fontSize: size}}") ". In SX the " (code "str") " + " (code "if") " + " (code "deref") " pattern handles it — no " (code "classnames") " library needed. For complex conditional classes, use a " (code "computed") " or a CSSX " (code "defcomp") " that returns a class string."))
|
||||
|
||||
(~docs/section :title "11. Resource + Suspense Pattern" :id "demo-resource"
|
||||
(p (code "resource") " wraps an async operation into a signal with " (code "loading") "/" (code "data") "/" (code "error") " states. Combined with " (code "cond") " + " (code "deref") ", this is the suspense pattern — no special form needed.")
|
||||
(~reactive-islands/demo/resource)
|
||||
(~reactive-islands/index/demo-resource)
|
||||
(~docs/code :code (highlight "(defisland ~reactive-islands/demo/resource ()\n (let ((data (resource (fn ()\n ;; Any promise-returning function\n (promise-delayed 1500\n (dict \"name\" \"Ada Lovelace\"\n \"role\" \"First Programmer\"))))))\n ;; This IS the suspense pattern:\n (let ((state (deref data)))\n (cond\n (get state \"loading\")\n (div \"Loading...\")\n (get state \"error\")\n (div \"Error: \" (get state \"error\"))\n :else\n (div (get (get state \"data\") \"name\"))))))" "lisp"))
|
||||
(p "React equivalent: " (code "Suspense") " + " (code "use()") " or " (code "useSWR") ". SX doesn't need a special " (code "suspense") " form because " (code "resource") " returns a signal and " (code "cond") " + " (code "deref") " creates reactive conditional rendering. When the promise resolves, the signal updates and the " (code "cond") " branch switches automatically."))
|
||||
|
||||
(~docs/section :title "12. Transition Pattern" :id "demo-transition"
|
||||
(p "React's " (code "startTransition") " defers non-urgent updates so typing stays responsive. In SX: " (code "schedule-idle") " + " (code "batch") ". The filter runs during idle time, not blocking the input event.")
|
||||
(~reactive-islands/demo/transition)
|
||||
(~reactive-islands/index/demo-transition)
|
||||
(~docs/code :code (highlight "(defisland ~reactive-islands/demo/transition ()\n (let ((query (signal \"\"))\n (all-items (list \"Signals\" \"Effects\" ...))\n (filtered (signal (list)))\n (pending (signal false)))\n (reset! filtered all-items)\n ;; Filter effect — deferred via schedule-idle\n (effect (fn ()\n (let ((q (lower (deref query))))\n (if (= q \"\")\n (do (reset! pending false)\n (reset! filtered all-items))\n (do (reset! pending true)\n (schedule-idle (fn ()\n (batch (fn ()\n (reset! filtered\n (filter (fn (item)\n (contains? (lower item) q))\n all-items))\n (reset! pending false))))))))))\n (div\n (input :bind query :placeholder \"Filter...\")\n (when (deref pending) (span \"Filtering...\"))\n (ul (map (fn (item) (li :key item item))\n (deref filtered))))))" "lisp"))
|
||||
(p "React equivalent: " (code "startTransition(() => setFiltered(...))") ". SX uses " (code "schedule-idle") " (" (code "requestIdleCallback") " under the hood) to defer the expensive " (code "filter") " operation, and " (code "batch") " to group the result into one update. Fine-grained signals already avoid the jank that makes transitions critical in React — this pattern is for truly expensive computations."))
|
||||
|
||||
|
||||
194
sx/sx/scopes.sx
Normal file
194
sx/sx/scopes.sx
Normal file
@@ -0,0 +1,194 @@
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Scopes — the unified primitive beneath provide, collect!, and spreads
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
;; ---- Demo components ----
|
||||
|
||||
(defcomp ~geography/demo-scope-basic ()
|
||||
(div :class "space-y-2"
|
||||
(scope "demo-theme" :value "violet"
|
||||
(div :class "rounded-lg p-3 bg-violet-50 border border-violet-200"
|
||||
(p :class "text-sm text-violet-800 font-semibold"
|
||||
(str "Inside scope: theme = " (context "demo-theme")))
|
||||
(p :class "text-xs text-stone-500" "scope creates a named scope. context reads it.")))
|
||||
(div :class "rounded-lg p-3 bg-stone-50 border border-stone-200"
|
||||
(p :class "text-sm text-stone-600" "Outside scope: no context available."))))
|
||||
|
||||
(defcomp ~geography/demo-scope-emit ()
|
||||
(div :class "space-y-2"
|
||||
(scope "demo-deps"
|
||||
(div :class "rounded-lg p-3 bg-stone-50 border border-stone-200"
|
||||
(p :class "text-sm text-stone-700"
|
||||
(emit! "demo-deps" "lodash")
|
||||
(emit! "demo-deps" "react")
|
||||
"Components emit their dependencies upward."))
|
||||
(div :class "rounded-lg p-3 bg-violet-50 border border-violet-200"
|
||||
(p :class "text-sm text-violet-800 font-semibold" "Emitted:")
|
||||
(ul :class "text-xs text-stone-600 list-disc pl-5"
|
||||
(map (fn (d) (li (code d))) (emitted "demo-deps")))))))
|
||||
|
||||
(defcomp ~geography/demo-scope-dedup ()
|
||||
(div :class "space-y-2"
|
||||
(div :class "rounded-lg p-3 bg-stone-50 border border-stone-200"
|
||||
(p :class "text-sm text-stone-700"
|
||||
(collect! "demo-css-dedup" ".card { padding: 1rem }")
|
||||
(collect! "demo-css-dedup" ".card { padding: 1rem }")
|
||||
(collect! "demo-css-dedup" ".btn { color: blue }")
|
||||
"Three collect! calls, two identical. Only unique values kept."))
|
||||
(div :class "rounded-lg p-3 bg-violet-50 border border-violet-200"
|
||||
(p :class "text-sm text-violet-800 font-semibold"
|
||||
(str "Collected: " (len (collected "demo-css-dedup")) " rules"))
|
||||
(ul :class "text-xs text-stone-600 list-disc pl-5"
|
||||
(map (fn (r) (li (code r))) (collected "demo-css-dedup"))))))
|
||||
|
||||
|
||||
;; ---- Layout helper ----
|
||||
|
||||
(defcomp ~geography/scopes-demo-example (&key demo code)
|
||||
(div :class "grid grid-cols-1 lg:grid-cols-2 gap-4 my-6 items-start"
|
||||
(div :class "border border-dashed border-stone-300 rounded-lg p-4 bg-stone-50 min-h-[80px]"
|
||||
demo)
|
||||
(div :class "not-prose bg-stone-100 rounded-lg p-4 overflow-x-auto"
|
||||
(pre :class "text-sm leading-relaxed whitespace-pre-wrap break-words" (code code)))))
|
||||
|
||||
|
||||
;; ---- Page content ----
|
||||
|
||||
(defcomp ~geography/scopes-content ()
|
||||
(~docs/page :title "Scopes"
|
||||
|
||||
(p :class "text-stone-500 text-sm italic mb-8"
|
||||
"The unified primitive. " (code "scope") " creates a named scope with an optional value "
|
||||
"and an accumulator. " (code "provide") ", " (code "collect!") ", spreads, islands — "
|
||||
"they all resolve to scope operations at the platform level.")
|
||||
|
||||
;; =====================================================================
|
||||
;; I. The primitive
|
||||
;; =====================================================================
|
||||
|
||||
(~docs/section :title "The primitive" :id "primitive"
|
||||
|
||||
(p (code "scope") " is a special form that pushes a named scope, evaluates its body, "
|
||||
"then pops it. The scope has three properties: a name, a downward value, and an "
|
||||
"upward accumulator.")
|
||||
|
||||
(~docs/code :code (highlight "(scope name body...) ;; scope with no value\n(scope name :value v body...) ;; scope with downward value" "lisp"))
|
||||
|
||||
(p "Within the body, " (code "context") " reads the value, " (code "emit!") " appends "
|
||||
"to the accumulator, and " (code "emitted") " reads what was accumulated.")
|
||||
|
||||
(~geography/scopes-demo-example
|
||||
:demo (~geography/demo-scope-basic)
|
||||
:code (highlight "(scope \"theme\" :value \"violet\"\n (context \"theme\")) ;; → \"violet\"\n\n;; Nested scopes shadow:\n(scope \"x\" :value \"outer\"\n (scope \"x\" :value \"inner\"\n (context \"x\")) ;; → \"inner\"\n (context \"x\")) ;; → \"outer\"" "lisp")))
|
||||
|
||||
;; =====================================================================
|
||||
;; II. Sugar forms
|
||||
;; =====================================================================
|
||||
|
||||
(~docs/section :title "Sugar forms" :id "sugar"
|
||||
|
||||
(p "Nobody writes " (code "scope") " directly. The sugar forms are the API:")
|
||||
|
||||
(~docs/table
|
||||
:headers (list "Sugar" "Expands to" "Used for")
|
||||
:rows (list
|
||||
(list "provide" "(scope name :value v body...)" "Downward context passing")
|
||||
(list "collect!" "Lazy root scope + dedup emit" "CSS rule accumulation")
|
||||
(list "Spreads" "(scope \"element-attrs\" ...)" "Child-to-parent attrs (implicit)")))
|
||||
|
||||
(~docs/subsection :title "provide — scope with a value"
|
||||
(p (code "(provide name value body...)") " is exactly "
|
||||
(code "(scope name :value value body...)") ". It exists because "
|
||||
"the two-arg form is the common case.")
|
||||
(~docs/code :code (highlight ";; These are equivalent:\n(provide \"theme\" {:primary \"violet\"}\n (h1 \"hello\"))\n\n(scope \"theme\" :value {:primary \"violet\"}\n (h1 \"hello\"))" "lisp")))
|
||||
|
||||
(~docs/subsection :title "collect! — lazy root scope with dedup"
|
||||
(p (code "collect!") " is the most interesting sugar. When called, if no scope exists "
|
||||
"for that name, it lazily creates a root scope with deduplication enabled. "
|
||||
"Then it emits into it.")
|
||||
(~geography/scopes-demo-example
|
||||
:demo (~geography/demo-scope-dedup)
|
||||
:code (highlight ";; collect! creates a lazy root scope:\n(collect! \"css\" \".card { pad: 1rem }\")\n(collect! \"css\" \".card { pad: 1rem }\") ;; deduped!\n(collect! \"css\" \".btn { color: blue }\")\n(collected \"css\") ;; → 2 rules\n\n;; Equivalent to:\n(scope \"css\" ;; with dedup\n (emit! \"css\" ...)\n (emitted \"css\"))" "lisp"))
|
||||
(p (code "collected") " is an alias for " (code "emitted") ". "
|
||||
(code "clear-collected!") " clears the accumulator."))
|
||||
|
||||
(~docs/subsection :title "Spreads — implicit element scope"
|
||||
(p "Every element rendering function wraps its children in "
|
||||
(code "(scope-push! \"element-attrs\" nil)") ". Spread children "
|
||||
(code "emit!") " their attrs into this scope. After rendering, the element "
|
||||
"merges the emitted attrs.")
|
||||
(p "See the "
|
||||
(a :href "/sx/(geography.(spreads))" :class "text-violet-600 hover:underline" "spreads article")
|
||||
" for the full mechanism.")))
|
||||
|
||||
;; =====================================================================
|
||||
;; III. Accumulator: upward data flow
|
||||
;; =====================================================================
|
||||
|
||||
(~docs/section :title "Upward data flow" :id "upward"
|
||||
|
||||
(~geography/scopes-demo-example
|
||||
:demo (~geography/demo-scope-emit)
|
||||
:code (highlight "(scope \"deps\"\n (emit! \"deps\" \"lodash\")\n (emit! \"deps\" \"react\")\n (emitted \"deps\")) ;; → (\"lodash\" \"react\")" "lisp"))
|
||||
|
||||
(p "Accumulation always goes to the " (em "nearest") " enclosing scope with that name. "
|
||||
"This is what makes nested elements work — a spread inside a nested "
|
||||
(code "span") " emits to the " (code "span") "'s scope, not an outer "
|
||||
(code "div") "'s scope."))
|
||||
|
||||
;; =====================================================================
|
||||
;; IV. Platform implementation
|
||||
;; =====================================================================
|
||||
|
||||
(~docs/section :title "Platform implementation" :id "platform"
|
||||
|
||||
(p "Each platform (Python, JavaScript) maintains a single data structure:")
|
||||
|
||||
(~docs/code :code (highlight "_scope_stacks = {} ;; {name: [{value, emitted: [], dedup: bool}]}" "python"))
|
||||
|
||||
(p "Six operations on this structure:")
|
||||
|
||||
(~docs/table
|
||||
:headers (list "Operation" "Purpose")
|
||||
:rows (list
|
||||
(list "scope-push!(name, value)" "Push {value, emitted: [], dedup: false}")
|
||||
(list "scope-pop!(name)" "Pop the most recent scope")
|
||||
(list "context(name, default?)" "Read value from nearest scope")
|
||||
(list "emit!(name, value)" "Append to nearest scope's accumulator (respects dedup)")
|
||||
(list "emitted(name)" "Read accumulated values from nearest scope")
|
||||
(list "collect!(name, value)" "Lazy push root scope with dedup, then emit")))
|
||||
|
||||
(p (code "provide-push!") " and " (code "provide-pop!") " are aliases for "
|
||||
(code "scope-push!") " and " (code "scope-pop!") ". "
|
||||
"All adapter code uses " (code "scope-push!") "/" (code "scope-pop!") " directly."))
|
||||
|
||||
;; =====================================================================
|
||||
;; V. Unification
|
||||
;; =====================================================================
|
||||
|
||||
(~docs/section :title "What scope unifies" :id "unification"
|
||||
|
||||
(p "Before scopes, the platform had two separate mechanisms:")
|
||||
|
||||
(~docs/code :code (highlight ";; Before: two mechanisms\n_provide_stacks = {} ;; {name: [{value, emitted: []}]}\n_collect_buckets = {} ;; {name: [values...]}\n\n;; After: one mechanism\n_scope_stacks = {} ;; {name: [{value, emitted: [], dedup: bool}]}" "python"))
|
||||
|
||||
(p "The unification is not just code cleanup. It means:")
|
||||
(ul :class "space-y-1"
|
||||
(li (code "collect!") " can be nested inside " (code "provide") " scopes — "
|
||||
"they share the same stack.")
|
||||
(li "A component can " (code "emit!") " and " (code "collect!") " into the same scope — "
|
||||
"they use the same accumulator.")
|
||||
(li "The dedup flag is per-scope, not per-mechanism — a " (code "provide") " scope "
|
||||
"has no dedup, a " (code "collect!") " root scope has dedup."))
|
||||
|
||||
(p "See the "
|
||||
(a :href "/sx/(etc.(plan.scoped-effects))" :class "text-violet-600 hover:underline" "scoped effects plan")
|
||||
" for the full design rationale and future phases (reactive scopes, morph scopes).")
|
||||
|
||||
(~docs/note
|
||||
(p (strong "Spec: ") "The " (code "scope") " special form is in "
|
||||
(a :href "/sx/(language.(spec.(explore.evaluator)))" :class "font-mono text-violet-600 hover:underline text-sm" "eval.sx")
|
||||
". Platform primitives are declared in "
|
||||
(a :href "/sx/(language.(spec.(explore.boundary)))" :class "font-mono text-violet-600 hover:underline text-sm" "boundary.sx")
|
||||
" (Tier 5: Scoped effects).")))))
|
||||
@@ -89,8 +89,9 @@
|
||||
(p :class "text-stone-500 text-sm italic mb-8"
|
||||
"A spread is a value that, when returned as a child of an element, "
|
||||
"injects attributes onto its parent instead of rendering as content. "
|
||||
"Internally, spreads work through " (code "provide") "/" (code "emit!") " — "
|
||||
"every element creates a provider scope, and spread children emit into it.")
|
||||
"Internally, spreads work through "
|
||||
(a :href "/sx/(geography.(scopes))" :class "text-violet-600 hover:underline" "scopes")
|
||||
" — every element creates a scope, and spread children emit into it.")
|
||||
|
||||
;; =====================================================================
|
||||
;; I. How it works
|
||||
@@ -125,9 +126,9 @@
|
||||
;; =====================================================================
|
||||
|
||||
(~docs/section :title "collect! — the other upward channel" :id "collect"
|
||||
(p "Spreads use " (code "provide") "/" (code "emit!") " (scoped, no dedup). "
|
||||
(code "collect!") "/" (code "collected") " is a separate upward channel — "
|
||||
"global, with automatic deduplication. Used for CSS rule accumulation.")
|
||||
(p "Spreads use " (code "scope") "/" (code "emit!") " (scoped, no dedup). "
|
||||
(code "collect!") "/" (code "collected") " is also backed by scopes — "
|
||||
"a lazy root scope with automatic deduplication. Used for CSS rule accumulation.")
|
||||
(~docs/code :code (highlight ";; Deep inside a component tree:\n(collect! \"cssx\" \".sx-bg-red-500{background:red}\")\n\n;; At the flush point (once, in the layout):\n(let ((rules (collected \"cssx\")))\n (clear-collected! \"cssx\")\n (raw! (str \"<style>\" (join \"\" rules) \"</style>\")))" "lisp"))
|
||||
(p "Both are upward communication through the render tree, but with different "
|
||||
"semantics — " (code "emit!") " is scoped to the nearest provider, "
|
||||
|
||||
@@ -617,6 +617,12 @@
|
||||
;; Provide / Emit! section (under Geography)
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defpage scopes-index
|
||||
:path "/geography/scopes/"
|
||||
:auth :public
|
||||
:layout :sx-docs
|
||||
:content (~layouts/doc :path "/sx/(geography.(scopes))" (~geography/scopes-content)))
|
||||
|
||||
(defpage provide-index
|
||||
:path "/geography/provide/"
|
||||
:auth :public
|
||||
|
||||
Reference in New Issue
Block a user