phlex-reactive 0.14.4 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +95 -0
  3. data/README.md +211 -4
  4. data/app/controllers/phlex/reactive/actions_controller.rb +28 -18
  5. data/app/javascript/phlex/reactive/core.js +5 -5
  6. data/app/javascript/phlex/reactive/core.min.js +3 -3
  7. data/app/javascript/phlex/reactive/core.min.js.map +4 -4
  8. data/app/javascript/phlex/reactive/early.js +36 -9
  9. data/app/javascript/phlex/reactive/early.min.js +2 -2
  10. data/app/javascript/phlex/reactive/early.min.js.map +3 -3
  11. data/app/javascript/phlex/reactive/features/bindings.js +125 -140
  12. data/app/javascript/phlex/reactive/features/bindings.min.js +2 -2
  13. data/app/javascript/phlex/reactive/features/bindings.min.js.map +3 -3
  14. data/app/javascript/phlex/reactive/features/form.js +63 -41
  15. data/app/javascript/phlex/reactive/features/form.min.js +2 -2
  16. data/app/javascript/phlex/reactive/features/form.min.js.map +3 -3
  17. data/app/javascript/phlex/reactive/features/presence.js +205 -0
  18. data/app/javascript/phlex/reactive/features/presence.min.js +4 -0
  19. data/app/javascript/phlex/reactive/features/presence.min.js.map +10 -0
  20. data/app/javascript/phlex/reactive/inspect.js +5 -1
  21. data/app/javascript/phlex/reactive/inspect.min.js +2 -2
  22. data/app/javascript/phlex/reactive/inspect.min.js.map +3 -3
  23. data/app/javascript/phlex/reactive/reactive_controller.js +3 -1
  24. data/app/javascript/phlex/reactive/reactive_controller.min.js +3 -3
  25. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +8 -7
  26. data/app/javascript/phlex/reactive/runtime.js +310 -14
  27. data/lib/phlex/reactive/component/dsl.rb +5 -0
  28. data/lib/phlex/reactive/component/helpers.rb +94 -8
  29. data/lib/phlex/reactive/component/presence_declaration.rb +118 -0
  30. data/lib/phlex/reactive/component/registry.rb +3 -1
  31. data/lib/phlex/reactive/component.rb +1 -0
  32. data/lib/phlex/reactive/defer.rb +5 -0
  33. data/lib/phlex/reactive/engine.rb +9 -4
  34. data/lib/phlex/reactive/js.rb +44 -0
  35. data/lib/phlex/reactive/presence/component_methods.rb +130 -0
  36. data/lib/phlex/reactive/presence/roster.rb +90 -0
  37. data/lib/phlex/reactive/presence/transport.rb +154 -0
  38. data/lib/phlex/reactive/presence.rb +162 -0
  39. data/lib/phlex/reactive/presence_render_job.rb +26 -0
  40. data/lib/phlex/reactive/shortcut.rb +118 -0
  41. data/lib/phlex/reactive/streamable.rb +12 -3
  42. data/lib/phlex/reactive/version.rb +1 -1
  43. data/lib/phlex/reactive.rb +10 -0
  44. metadata +11 -1
@@ -35,6 +35,9 @@
35
35
  // `@window` descriptor instead of walking up from the target. They are never
36
36
  // prevented (dispatch() does not prevent a window binding either) and live a
37
37
  // short TTL in the controller (a hotkey replayed seconds late is wrong).
38
+ // A shortcut: (issue #357) is the exception: the chord is prevented, as
39
+ // dispatch() prevents it, and the other platform's `mod` twin or a press in an
40
+ // editable field is neither queued nor prevented.
38
41
  //
39
42
  // Limits: outside: triggers and @document bindings are not recorded (an
40
43
  // outside click before connect has nothing to close); a key filter is matched
@@ -80,15 +83,17 @@ const matching = (el, event, win) =>
80
83
  descriptors(el).filter((d) => d.win === win && d.type === event.type && keyMatches(d.filter, event))
81
84
 
82
85
  // Queue one firing for `el`'s root, unless that root has connected.
83
- function enqueue(event, el, descs, win) {
86
+ function enqueue(event, el, descs, win, shortcut) {
84
87
  const root = el.closest(ROOT_SELECTOR)
85
88
  if (!root || state.connected.has(root)) return
86
89
  // dispatch() keeps the native flip of a checked: :keep checkbox/radio and
87
- // never prevents a window binding; everything else is prevented now, as the
88
- // controller would.
90
+ // never prevents a window binding — but a shortcut (issue #357), the chord
91
+ // the page claims (mod+s must not open the save dialog before connect), is
92
+ // always prevented: the keepsToggle exemption is for element-bound clicks
93
+ // only. Everything else is prevented now, as the controller would.
89
94
  const keepsToggle =
90
95
  /^(checkbox|radio)$/.test(el.type) && /"checked":"keep"/.test(el.dataset.reactiveOptimisticParam)
91
- if (!win && (!keepsToggle || descs.some((d) => d.method === "runOps"))) event.preventDefault()
96
+ if (win ? shortcut : !keepsToggle || descs.some((d) => d.method === "runOps")) event.preventDefault()
92
97
  // Every firing is queued — a `:once` repeat included: the replay skips a
93
98
  // descriptor already consumed (reactive_controller.js #replayEarly).
94
99
  const { queue } = state
@@ -132,15 +137,37 @@ function recordWindow(event) {
132
137
  }
133
138
  // Cheapest check first: once its root connects, Stimulus hears it.
134
139
  if (state.connected.has(el.closest(ROOT_SELECTOR))) continue
140
+ // A shortcut (issue #357): on() flags it on the element, on_client in its
141
+ // record (read off the raw attribute: the element's records share one
142
+ // guard here, and the replay re-checks each). The guard covers only a
143
+ // key-filtered descriptor — a shortcut's twins always carry a filter — so
144
+ // the element's other window bindings (a click, a bare keydown) behave
145
+ // as they do without a shortcut. The exception is a delete/backspace
146
+ // chord's bare keydown (below), which on that element IS the shortcut.
147
+ const ops = el.dataset.reactiveOpsParam ?? ""
148
+ const shortcut = el.dataset.reactiveShortcutParam ?? (/"shortcut":/.test(ops) && ops)
149
+ const { target } = event
150
+ const typing =
151
+ shortcut && !/inEditable":true/.test(shortcut) && (target.isContentEditable || /^(input|textarea|select)$/.test(target.localName))
152
+ // Apple platforms run the meta twin of a `mod` chord, the rest the ctrl
153
+ // one: `skip` is the other platform's.
154
+ const mod = shortcut && (/[Mm]ac|iP/.test(navigator.userAgentData?.platform ?? navigator.platform) ? "meta" : "ctrl")
155
+ const skip = /mod\+/.test(shortcut) && (mod === "meta" ? "ctrl" : "meta")
156
+ // A delete/backspace chord (issue #366) binds a BARE keydown (Stimulus
157
+ // lacks the keys): the chord itself, this platform's `mod`, decides.
158
+ const chords = (shortcut && shortcut.match(/(^|"(keys|shortcut)":")[\w+]*(delete|backspace)(?="|$)/g)) || []
159
+ const bare = (d) => !d.filter && chords[0] && d.type === "keydown"
135
160
  // outside: is not recorded — on() flags it on the element, on_client in
136
161
  // the binding record (one window record without it is enough to queue;
137
162
  // the replay skips the outside ones).
138
- const descs = matching(el, event, true).filter((d) =>
139
- d.method === "runOps"
140
- ? /"window":true(?!,"outside")/.test(el.dataset.reactiveOpsParam)
141
- : el.dataset.reactiveOutsideParam !== "true",
163
+ const descs = matching(el, event, true).filter(
164
+ (d) =>
165
+ (bare(d)
166
+ ? !typing && chords.some((chord) => keyMatches(chord.slice(chord.lastIndexOf('"') + 1).replace("mod", mod), event))
167
+ : !(d.filter && (typing || (skip && d.filter.includes(skip))))) &&
168
+ (d.method === "runOps" ? /"window":true(?!,"outside")/.test(ops) : el.dataset.reactiveOutsideParam !== "true"),
142
169
  )
143
- if (descs.length) enqueue(event, el, descs, true)
170
+ if (descs.length) enqueue(event, el, descs, true, shortcut && descs.some((d) => d.filter || bare(d)))
144
171
  }
145
172
  }
146
173
 
@@ -1,4 +1,4 @@
1
- var h=Symbol.for("phlex-reactive.early");var i=globalThis[h]??={queue:[],connected:new WeakSet};function w(t){let e=/^(?:(key\w+)\.([\w+]+)|([^@>]+))(@window)?->reactive#(dispatch|runOps)(?::!?\w+)*$/.exec(t);return e?[{token:t,type:e[1]??e[3],filter:e[2],win:!!e[4],method:e[5]}]:[]}function y(t,e){if(!t)return!0;let o=t.split("+"),r=o.pop();return(r==="esc"?"escape":r==="space"?" ":r.replace(/^(?=up|down|left|right)/,"arrow").replace("_",""))===e.key?.toLowerCase()&&["meta","ctrl","alt","shift"].every((n)=>o.includes(n)===e[`${n}Key`])}var l=(t)=>(t.dataset.action??"").split(/\s+/).flatMap(w),d=(t,e,o)=>l(t).filter((r)=>r.win===o&&r.type===e.type&&y(r.filter,e));function f(t,e,o,r){let a=e.closest('[data-controller~="reactive"],[data-reactive-dormant~="reactive"]');if(!a||i.connected.has(a))return;let n=/^(checkbox|radio)$/.test(e.type)&&/"checked":"keep"/.test(e.dataset.reactiveOptimisticParam);if(!r&&(!n||o.some((m)=>m.method==="runOps")))t.preventDefault();let{queue:s}=i;if(s.push({event:t,el:e,root:a,descs:o,win:r,at:performance.now()})>50)s.shift();let{reactiveDormant:u,controller:p=""}=a.dataset;if(u)a.dataset.controller=`${p} ${u}`.trim(),delete a.dataset.reactiveDormant}function b(t){for(let e=t.target;e?.closest;e=e.parentElement){if(!t.bubbles&&e!==t.target)break;let o=d(e,t,!1);if(o.length)f(t,e,o,!1)}}var c=new Set,E=c;function O(t){if(!t.bubbles&&t.target!==t.currentTarget)return;for(let e of c){if(!e.isConnected){c.delete(e);continue}if(i.connected.has(e.closest('[data-controller~="reactive"],[data-reactive-dormant~="reactive"]')))continue;let o=d(e,t,!0).filter((r)=>r.method==="runOps"?/"window":true(?!,"outside")/.test(e.dataset.reactiveOpsParam):e.dataset.reactiveOutsideParam!=="true");if(o.length)f(t,e,o,!0)}}function g(t=document){let e=(r)=>{if(r.nodeType!==1)return;for(let a of[r,...r.querySelectorAll('[data-action*="reactive#"]')])for(let{type:n,win:s}of l(a)){if(s)c.add(a);(s?t.defaultView:t).addEventListener(n,s?O:b,!0)}},o=()=>{for(let r of c)if(!r.isConnected)c.delete(r)};o(),e(t.documentElement),new MutationObserver((r)=>{o();for(let a of r)a.type==="attributes"?e(a.target):a.addedNodes.forEach(e)}).observe(t.documentElement,{childList:!0,subtree:!0,attributeFilter:["data-action"]})}if(globalThis.document)g();export{E as __windowBoundForTest,g as startEarly};
1
+ var g=Symbol.for("phlex-reactive.early");var p=globalThis[g]??={queue:[],connected:new WeakSet};function O(e){let t=/^(?:(key\w+)\.([\w+]+)|([^@>]+))(@window)?->reactive#(dispatch|runOps)(?::!?\w+)*$/.exec(e);return t?[{token:e,type:t[1]??t[3],filter:t[2],win:!!t[4],method:t[5]}]:[]}function h(e,t){if(!e)return!0;let o=e.split("+"),r=o.pop();return(r==="esc"?"escape":r==="space"?" ":r.replace(/^(?=up|down|left|right)/,"arrow").replace("_",""))===t.key?.toLowerCase()&&["meta","ctrl","alt","shift"].every((a)=>o.includes(a)===t[`${a}Key`])}var w=(e)=>(e.dataset.action??"").split(/\s+/).flatMap(O),y=(e,t,o)=>w(e).filter((r)=>r.win===o&&r.type===t.type&&h(r.filter,t));function b(e,t,o,r,s){let a=t.closest('[data-controller~="reactive"],[data-reactive-dormant~="reactive"]');if(!a||p.connected.has(a))return;let n=/^(checkbox|radio)$/.test(t.type)&&/"checked":"keep"/.test(t.dataset.reactiveOptimisticParam);if(r?s:!n||o.some((d)=>d.method==="runOps"))e.preventDefault();let{queue:l}=p;if(l.push({event:e,el:t,root:a,descs:o,win:r,at:performance.now()})>50)l.shift();let{reactiveDormant:u,controller:f=""}=a.dataset;if(u)a.dataset.controller=`${f} ${u}`.trim(),delete a.dataset.reactiveDormant}function E(e){for(let t=e.target;t?.closest;t=t.parentElement){if(!e.bubbles&&t!==e.target)break;let o=y(t,e,!1);if(o.length)b(e,t,o,!1)}}var i=new Set,S=i;function k(e){if(!e.bubbles&&e.target!==e.currentTarget)return;for(let t of i){if(!t.isConnected){i.delete(t);continue}if(p.connected.has(t.closest('[data-controller~="reactive"],[data-reactive-dormant~="reactive"]')))continue;let o=t.dataset.reactiveOpsParam??"",r=t.dataset.reactiveShortcutParam??(/"shortcut":/.test(o)&&o),{target:s}=e,a=r&&!/inEditable":true/.test(r)&&(s.isContentEditable||/^(input|textarea|select)$/.test(s.localName)),n=r&&(/[Mm]ac|iP/.test(navigator.userAgentData?.platform??navigator.platform)?"meta":"ctrl"),l=/mod\+/.test(r)&&(n==="meta"?"ctrl":"meta"),u=r&&r.match(/(^|"(keys|shortcut)":")[\w+]*(delete|backspace)(?="|$)/g)||[],f=(c)=>!c.filter&&u[0]&&c.type==="keydown",d=y(t,e,!0).filter((c)=>(f(c)?!a&&u.some((m)=>h(m.slice(m.lastIndexOf('"')+1).replace("mod",n),e)):!(c.filter&&(a||l&&c.filter.includes(l))))&&(c.method==="runOps"?/"window":true(?!,"outside")/.test(o):t.dataset.reactiveOutsideParam!=="true"));if(d.length)b(e,t,d,!0,r&&d.some((c)=>c.filter||f(c)))}}function T(e=document){let t=(r)=>{if(r.nodeType!==1)return;for(let s of[r,...r.querySelectorAll('[data-action*="reactive#"]')])for(let{type:a,win:n}of w(s)){if(n)i.add(s);(n?e.defaultView:e).addEventListener(a,n?k:E,!0)}},o=()=>{for(let r of i)if(!r.isConnected)i.delete(r)};o(),t(e.documentElement),new MutationObserver((r)=>{o();for(let s of r)s.type==="attributes"?t(s.target):s.addedNodes.forEach(t)}).observe(e.documentElement,{childList:!0,subtree:!0,attributeFilter:["data-action"]})}if(globalThis.document)T();export{S as __windowBoundForTest,T as startEarly};
2
2
 
3
- //# debugId=853F262C6259DDE864756E2164756E21
3
+ //# debugId=E672E1A717FD883764756E2164756E21
4
4
  //# sourceMappingURL=early.min.js.map
@@ -2,9 +2,9 @@
2
2
  "version": 3,
3
3
  "sources": ["early.js"],
4
4
  "sourcesContent": [
5
- "// phlex/reactive/early — never lose a trigger that fires before the `reactive`\n// controller connects (issue #273).\n//\n// An on()/on_client trigger is a Stimulus action: its listener only exists\n// once the controller module has loaded and connected. With a lazily loaded\n// controller (stimulus-loading's lazyLoadControllersFrom, a preload: false\n// pin, a slow network) a click or custom event in that window used to vanish:\n// no request, no error, a UI waiting forever. Import this tiny module EAGERLY\n// (`import \"phlex/reactive/early\"` in the entry point; the engine pins it with\n// preload: true) and the controller can load whenever it likes.\n//\n// How: one CAPTURE listener per trigger event type on document (the types come\n// from the `->reactive#dispatch` / `->reactive#runOps` descriptors in the page,\n// rescanned by a MutationObserver as markup arrives). When an event reaches a\n// descriptor whose root has NOT connected, it is queued and — synchronously,\n// while preventDefault still works — its native default is stopped exactly\n// where dispatch()/runOps() would stop it. connect() drains the queue\n// (reactive_controller.js #drainEarly) and replays each entry.\n//\n// The queue lives on window under a Symbol.for key, so neither module imports\n// the other and either may load first. NO Stimulus import: this module must\n// stay tiny (its gzipped size is asserted by spec/javascript/reactive_early.test.js).\n//\n// Dormant roots (issue #274): reactive_root(dormant: true) renders\n// data-reactive-dormant=\"reactive\" INSTEAD of data-controller=\"reactive\", so\n// nothing mounts (or, with a lazily loaded controller, is even fetched) until\n// one of the root's triggers fires. The first recorded trigger WAKES the root:\n// the identifier moves into data-controller, Stimulus connects it, and the\n// trigger is replayed like any other early event. A dormant root therefore\n// NEEDS this module — without it nothing ever wakes it.\n//\n// Window-bound triggers (window:, issue #303 — how a hotkey is bound) are\n// recorded too, by a capture listener on window: the keypress lands anywhere\n// on the page, so the scan keeps a registry of the elements carrying an\n// `@window` descriptor instead of walking up from the target. They are never\n// prevented (dispatch() does not prevent a window binding either) and live a\n// short TTL in the controller (a hotkey replayed seconds late is wrong).\n//\n// Limits: outside: triggers and @document bindings are not recorded (an\n// outside click before connect has nothing to close); a key filter is matched\n// against Stimulus's DEFAULT key mappings only.\n\nconst KEY = Symbol.for(\"phlex-reactive.early\")\n\n// The roots whose triggers are captured. A root counts as connected only\n// through state.connected (a WeakSet the controller fills in connect()), never\n// through markup — a Turbo snapshot clone carries data-reactive-connected over.\n// A dormant root (issue #274) is one too: it has no controller yet by design.\nconst ROOT_SELECTOR = '[data-controller~=\"reactive\"],[data-reactive-dormant~=\"reactive\"]'\n\nconst state = (globalThis[KEY] ??= { queue: [], connected: new WeakSet() })\n\n// One Stimulus descriptor token → [{ token, type, filter, win, method }] for a\n// reactive trigger, else [] (flatMap-ready). `win` marks an `@window` token;\n// `@document` never matches. Like Stimulus, a dot suffix is a key filter only\n// on a key event (`keydown.enter`); otherwise it is part of the event name\n// (`panel.opened`).\nfunction parseDescriptor(token) {\n const match = /^(?:(key\\w+)\\.([\\w+]+)|([^@>]+))(@window)?->reactive#(dispatch|runOps)(?::!?\\w+)*$/.exec(token)\n return match ? [{ token, type: match[1] ?? match[3], filter: match[2], win: !!match[4], method: match[5] }] : []\n}\n\n// Stimulus's key-filter rule against its DEFAULT mappings (esc, space, the\n// arrows, page_up/down; letters, digits, enter, tab, home, end map to\n// themselves): the key after the last \"+\" matches event.key, and the four\n// modifiers match exactly (ctrl+enter is not enter). It decides what to queue\n// and preventDefault before connect; the replay re-checks the filter against\n// the app's own key mappings (reactive_controller.js #replayEarly).\nfunction keyMatches(filter, event) {\n if (!filter) return true\n const parts = filter.split(\"+\")\n const key = parts.pop()\n const name = key === \"esc\" ? \"escape\" : key === \"space\" ? \" \" : key.replace(/^(?=up|down|left|right)/, \"arrow\").replace(\"_\", \"\")\n return name === event.key?.toLowerCase() && [\"meta\", \"ctrl\", \"alt\", \"shift\"].every((mod) => parts.includes(mod) === event[`${mod}Key`])\n}\n\nconst descriptors = (el) => (el.dataset.action ?? \"\").split(/\\s+/).flatMap(parseDescriptor)\n\nconst matching = (el, event, win) =>\n descriptors(el).filter((d) => d.win === win && d.type === event.type && keyMatches(d.filter, event))\n\n// Queue one firing for `el`'s root, unless that root has connected.\nfunction enqueue(event, el, descs, win) {\n const root = el.closest(ROOT_SELECTOR)\n if (!root || state.connected.has(root)) return\n // dispatch() keeps the native flip of a checked: :keep checkbox/radio and\n // never prevents a window binding; everything else is prevented now, as the\n // controller would.\n const keepsToggle =\n /^(checkbox|radio)$/.test(el.type) && /\"checked\":\"keep\"/.test(el.dataset.reactiveOptimisticParam)\n if (!win && (!keepsToggle || descs.some((d) => d.method === \"runOps\"))) event.preventDefault()\n // Every firing is queued — a `:once` repeat included: the replay skips a\n // descriptor already consumed (reactive_controller.js #replayEarly).\n const { queue } = state\n // Bounded: a root whose controller never registers must not grow it forever.\n // `at` is the capture time — not event.timeStamp, which is when the event\n // object was CREATED (an app may build one and dispatch it much later).\n if (queue.push({ event, el, root, descs, win, at: performance.now() }) > 50) queue.shift()\n // Wake a dormant root (issue #274) — synchronously, so by the time the\n // event bubbles to an OUTER reactive root's Stimulus listener, `el` is\n // already in this root's scope. Other controllers on the root are kept.\n const { reactiveDormant, controller = \"\" } = root.dataset\n if (reactiveDormant) {\n root.dataset.controller = `${controller} ${reactiveDormant}`.trim()\n delete root.dataset.reactiveDormant\n }\n}\n\nfunction record(event) {\n for (let el = event.target; el?.closest; el = el.parentElement) {\n if (!event.bubbles && el !== event.target) break\n const descs = matching(el, event, false)\n if (descs.length) enqueue(event, el, descs, false)\n }\n}\n\n// The elements carrying an `@window` reactive descriptor (issue #303), fed by\n// the scan. One that left the page is dropped once per mutation batch (a\n// Turbo Drive visit swaps the body, so the old page's triggers do not keep it\n// alive) or when next seen by the listener.\nconst windowBound = new Set()\nexport const __windowBoundForTest = windowBound\n\nfunction recordWindow(event) {\n // Stimulus's window listener is a bubble-phase one: a non-bubbling event\n // (focus, mouseenter) from inside the page never reaches it.\n if (!event.bubbles && event.target !== event.currentTarget) return\n for (const el of windowBound) {\n if (!el.isConnected) {\n windowBound.delete(el)\n continue\n }\n // Cheapest check first: once its root connects, Stimulus hears it.\n if (state.connected.has(el.closest(ROOT_SELECTOR))) continue\n // outside: is not recorded — on() flags it on the element, on_client in\n // the binding record (one window record without it is enough to queue;\n // the replay skips the outside ones).\n const descs = matching(el, event, true).filter((d) =>\n d.method === \"runOps\"\n ? /\"window\":true(?!,\"outside\")/.test(el.dataset.reactiveOpsParam)\n : el.dataset.reactiveOutsideParam !== \"true\",\n )\n if (descs.length) enqueue(event, el, descs, true)\n }\n}\n\n// Install the capture listeners for every reactive trigger type in `doc`, now\n// and as markup arrives. Runs on import; exported for tests and for apps that\n// render into another document.\n// addEventListener ignores a repeat of the same (type, listener, capture), so\n// every scan may simply re-add.\nexport function startEarly(doc = document) {\n const scan = (node) => {\n if (node.nodeType !== 1) return\n for (const el of [node, ...node.querySelectorAll('[data-action*=\"reactive#\"]')]) {\n for (const { type, win } of descriptors(el)) {\n if (win) windowBound.add(el)\n ;(win ? doc.defaultView : doc).addEventListener(type, win ? recordWindow : record, true)\n }\n }\n }\n // Once per batch of markup, not per added node: drop the window-bound\n // triggers that left the page (a Turbo Drive visit swaps the whole body).\n const prune = () => {\n for (const el of windowBound) if (!el.isConnected) windowBound.delete(el)\n }\n prune()\n scan(doc.documentElement)\n new MutationObserver((mutations) => {\n prune()\n for (const m of mutations) m.type === \"attributes\" ? scan(m.target) : m.addedNodes.forEach(scan)\n }).observe(doc.documentElement, { childList: true, subtree: true, attributeFilter: [\"data-action\"] })\n}\n\nif (globalThis.document) startEarly()\n"
5
+ "// phlex/reactive/early — never lose a trigger that fires before the `reactive`\n// controller connects (issue #273).\n//\n// An on()/on_client trigger is a Stimulus action: its listener only exists\n// once the controller module has loaded and connected. With a lazily loaded\n// controller (stimulus-loading's lazyLoadControllersFrom, a preload: false\n// pin, a slow network) a click or custom event in that window used to vanish:\n// no request, no error, a UI waiting forever. Import this tiny module EAGERLY\n// (`import \"phlex/reactive/early\"` in the entry point; the engine pins it with\n// preload: true) and the controller can load whenever it likes.\n//\n// How: one CAPTURE listener per trigger event type on document (the types come\n// from the `->reactive#dispatch` / `->reactive#runOps` descriptors in the page,\n// rescanned by a MutationObserver as markup arrives). When an event reaches a\n// descriptor whose root has NOT connected, it is queued and — synchronously,\n// while preventDefault still works — its native default is stopped exactly\n// where dispatch()/runOps() would stop it. connect() drains the queue\n// (reactive_controller.js #drainEarly) and replays each entry.\n//\n// The queue lives on window under a Symbol.for key, so neither module imports\n// the other and either may load first. NO Stimulus import: this module must\n// stay tiny (its gzipped size is asserted by spec/javascript/reactive_early.test.js).\n//\n// Dormant roots (issue #274): reactive_root(dormant: true) renders\n// data-reactive-dormant=\"reactive\" INSTEAD of data-controller=\"reactive\", so\n// nothing mounts (or, with a lazily loaded controller, is even fetched) until\n// one of the root's triggers fires. The first recorded trigger WAKES the root:\n// the identifier moves into data-controller, Stimulus connects it, and the\n// trigger is replayed like any other early event. A dormant root therefore\n// NEEDS this module — without it nothing ever wakes it.\n//\n// Window-bound triggers (window:, issue #303 — how a hotkey is bound) are\n// recorded too, by a capture listener on window: the keypress lands anywhere\n// on the page, so the scan keeps a registry of the elements carrying an\n// `@window` descriptor instead of walking up from the target. They are never\n// prevented (dispatch() does not prevent a window binding either) and live a\n// short TTL in the controller (a hotkey replayed seconds late is wrong).\n// A shortcut: (issue #357) is the exception: the chord is prevented, as\n// dispatch() prevents it, and the other platform's `mod` twin or a press in an\n// editable field is neither queued nor prevented.\n//\n// Limits: outside: triggers and @document bindings are not recorded (an\n// outside click before connect has nothing to close); a key filter is matched\n// against Stimulus's DEFAULT key mappings only.\n\nconst KEY = Symbol.for(\"phlex-reactive.early\")\n\n// The roots whose triggers are captured. A root counts as connected only\n// through state.connected (a WeakSet the controller fills in connect()), never\n// through markup — a Turbo snapshot clone carries data-reactive-connected over.\n// A dormant root (issue #274) is one too: it has no controller yet by design.\nconst ROOT_SELECTOR = '[data-controller~=\"reactive\"],[data-reactive-dormant~=\"reactive\"]'\n\nconst state = (globalThis[KEY] ??= { queue: [], connected: new WeakSet() })\n\n// One Stimulus descriptor token → [{ token, type, filter, win, method }] for a\n// reactive trigger, else [] (flatMap-ready). `win` marks an `@window` token;\n// `@document` never matches. Like Stimulus, a dot suffix is a key filter only\n// on a key event (`keydown.enter`); otherwise it is part of the event name\n// (`panel.opened`).\nfunction parseDescriptor(token) {\n const match = /^(?:(key\\w+)\\.([\\w+]+)|([^@>]+))(@window)?->reactive#(dispatch|runOps)(?::!?\\w+)*$/.exec(token)\n return match ? [{ token, type: match[1] ?? match[3], filter: match[2], win: !!match[4], method: match[5] }] : []\n}\n\n// Stimulus's key-filter rule against its DEFAULT mappings (esc, space, the\n// arrows, page_up/down; letters, digits, enter, tab, home, end map to\n// themselves): the key after the last \"+\" matches event.key, and the four\n// modifiers match exactly (ctrl+enter is not enter). It decides what to queue\n// and preventDefault before connect; the replay re-checks the filter against\n// the app's own key mappings (reactive_controller.js #replayEarly).\nfunction keyMatches(filter, event) {\n if (!filter) return true\n const parts = filter.split(\"+\")\n const key = parts.pop()\n const name = key === \"esc\" ? \"escape\" : key === \"space\" ? \" \" : key.replace(/^(?=up|down|left|right)/, \"arrow\").replace(\"_\", \"\")\n return name === event.key?.toLowerCase() && [\"meta\", \"ctrl\", \"alt\", \"shift\"].every((mod) => parts.includes(mod) === event[`${mod}Key`])\n}\n\nconst descriptors = (el) => (el.dataset.action ?? \"\").split(/\\s+/).flatMap(parseDescriptor)\n\nconst matching = (el, event, win) =>\n descriptors(el).filter((d) => d.win === win && d.type === event.type && keyMatches(d.filter, event))\n\n// Queue one firing for `el`'s root, unless that root has connected.\nfunction enqueue(event, el, descs, win, shortcut) {\n const root = el.closest(ROOT_SELECTOR)\n if (!root || state.connected.has(root)) return\n // dispatch() keeps the native flip of a checked: :keep checkbox/radio and\n // never prevents a window binding — but a shortcut (issue #357), the chord\n // the page claims (mod+s must not open the save dialog before connect), is\n // always prevented: the keepsToggle exemption is for element-bound clicks\n // only. Everything else is prevented now, as the controller would.\n const keepsToggle =\n /^(checkbox|radio)$/.test(el.type) && /\"checked\":\"keep\"/.test(el.dataset.reactiveOptimisticParam)\n if (win ? shortcut : !keepsToggle || descs.some((d) => d.method === \"runOps\")) event.preventDefault()\n // Every firing is queued — a `:once` repeat included: the replay skips a\n // descriptor already consumed (reactive_controller.js #replayEarly).\n const { queue } = state\n // Bounded: a root whose controller never registers must not grow it forever.\n // `at` is the capture time — not event.timeStamp, which is when the event\n // object was CREATED (an app may build one and dispatch it much later).\n if (queue.push({ event, el, root, descs, win, at: performance.now() }) > 50) queue.shift()\n // Wake a dormant root (issue #274) — synchronously, so by the time the\n // event bubbles to an OUTER reactive root's Stimulus listener, `el` is\n // already in this root's scope. Other controllers on the root are kept.\n const { reactiveDormant, controller = \"\" } = root.dataset\n if (reactiveDormant) {\n root.dataset.controller = `${controller} ${reactiveDormant}`.trim()\n delete root.dataset.reactiveDormant\n }\n}\n\nfunction record(event) {\n for (let el = event.target; el?.closest; el = el.parentElement) {\n if (!event.bubbles && el !== event.target) break\n const descs = matching(el, event, false)\n if (descs.length) enqueue(event, el, descs, false)\n }\n}\n\n// The elements carrying an `@window` reactive descriptor (issue #303), fed by\n// the scan. One that left the page is dropped once per mutation batch (a\n// Turbo Drive visit swaps the body, so the old page's triggers do not keep it\n// alive) or when next seen by the listener.\nconst windowBound = new Set()\nexport const __windowBoundForTest = windowBound\n\nfunction recordWindow(event) {\n // Stimulus's window listener is a bubble-phase one: a non-bubbling event\n // (focus, mouseenter) from inside the page never reaches it.\n if (!event.bubbles && event.target !== event.currentTarget) return\n for (const el of windowBound) {\n if (!el.isConnected) {\n windowBound.delete(el)\n continue\n }\n // Cheapest check first: once its root connects, Stimulus hears it.\n if (state.connected.has(el.closest(ROOT_SELECTOR))) continue\n // A shortcut (issue #357): on() flags it on the element, on_client in its\n // record (read off the raw attribute: the element's records share one\n // guard here, and the replay re-checks each). The guard covers only a\n // key-filtered descriptor — a shortcut's twins always carry a filter — so\n // the element's other window bindings (a click, a bare keydown) behave\n // as they do without a shortcut. The exception is a delete/backspace\n // chord's bare keydown (below), which on that element IS the shortcut.\n const ops = el.dataset.reactiveOpsParam ?? \"\"\n const shortcut = el.dataset.reactiveShortcutParam ?? (/\"shortcut\":/.test(ops) && ops)\n const { target } = event\n const typing =\n shortcut && !/inEditable\":true/.test(shortcut) && (target.isContentEditable || /^(input|textarea|select)$/.test(target.localName))\n // Apple platforms run the meta twin of a `mod` chord, the rest the ctrl\n // one: `skip` is the other platform's.\n const mod = shortcut && (/[Mm]ac|iP/.test(navigator.userAgentData?.platform ?? navigator.platform) ? \"meta\" : \"ctrl\")\n const skip = /mod\\+/.test(shortcut) && (mod === \"meta\" ? \"ctrl\" : \"meta\")\n // A delete/backspace chord (issue #366) binds a BARE keydown (Stimulus\n // lacks the keys): the chord itself, this platform's `mod`, decides.\n const chords = (shortcut && shortcut.match(/(^|\"(keys|shortcut)\":\")[\\w+]*(delete|backspace)(?=\"|$)/g)) || []\n const bare = (d) => !d.filter && chords[0] && d.type === \"keydown\"\n // outside: is not recorded — on() flags it on the element, on_client in\n // the binding record (one window record without it is enough to queue;\n // the replay skips the outside ones).\n const descs = matching(el, event, true).filter(\n (d) =>\n (bare(d)\n ? !typing && chords.some((chord) => keyMatches(chord.slice(chord.lastIndexOf('\"') + 1).replace(\"mod\", mod), event))\n : !(d.filter && (typing || (skip && d.filter.includes(skip))))) &&\n (d.method === \"runOps\" ? /\"window\":true(?!,\"outside\")/.test(ops) : el.dataset.reactiveOutsideParam !== \"true\"),\n )\n if (descs.length) enqueue(event, el, descs, true, shortcut && descs.some((d) => d.filter || bare(d)))\n }\n}\n\n// Install the capture listeners for every reactive trigger type in `doc`, now\n// and as markup arrives. Runs on import; exported for tests and for apps that\n// render into another document.\n// addEventListener ignores a repeat of the same (type, listener, capture), so\n// every scan may simply re-add.\nexport function startEarly(doc = document) {\n const scan = (node) => {\n if (node.nodeType !== 1) return\n for (const el of [node, ...node.querySelectorAll('[data-action*=\"reactive#\"]')]) {\n for (const { type, win } of descriptors(el)) {\n if (win) windowBound.add(el)\n ;(win ? doc.defaultView : doc).addEventListener(type, win ? recordWindow : record, true)\n }\n }\n }\n // Once per batch of markup, not per added node: drop the window-bound\n // triggers that left the page (a Turbo Drive visit swaps the whole body).\n const prune = () => {\n for (const el of windowBound) if (!el.isConnected) windowBound.delete(el)\n }\n prune()\n scan(doc.documentElement)\n new MutationObserver((mutations) => {\n prune()\n for (const m of mutations) m.type === \"attributes\" ? scan(m.target) : m.addedNodes.forEach(scan)\n }).observe(doc.documentElement, { childList: true, subtree: true, attributeFilter: [\"data-action\"] })\n}\n\nif (globalThis.document) startEarly()\n"
6
6
  ],
7
- "mappings": "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",
8
- "debugId": "853F262C6259DDE864756E2164756E21",
7
+ "mappings": "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",
8
+ "debugId": "E672E1A717FD883764756E2164756E21",
9
9
  "names": []
10
10
  }
@@ -64,124 +64,129 @@ export function connect(controller, core) {
64
64
  const c = ctx(controller, core)
65
65
  c.offs = []
66
66
  c.seeds = []
67
-
68
- // Show bindings (issue #161) — ONLY when this root owns one, so a component
69
- // without any pays a single probe (the dirty-tracking gate precedent). ONE
70
- // delegated listener pair on the root (input + change bubble from every
71
- // owned field — no per-field wiring, and a reactive_compute output write
72
- // dispatches a real input event, so computed values drive visibility too).
73
- // The connect sync seeds the initial state — a plain replace re-connects —
74
- // and turbo:morph-element re-syncs after an in-place morph (which keeps the
75
- // element connected, fires no Stimulus lifecycle, and may preserve a
76
- // user-edited field value the server's hidden attrs don't reflect).
77
- if (showSyncEnabled(c)) {
78
- // A select-all header's edit flips its group first (issue #319) — on
79
- // `input`, which a click fires BEFORE `change`, or the pass below would
80
- // reset the header to its group's state first (the flip is idempotent, so
81
- // the `change` that follows flips nothing). The change events it
82
- // dispatches re-enter here and are skipped while it flips, so the pass
83
- // after the loop runs once, not once per box.
84
- c.show = (event) => {
85
- if (c.flipping) return
86
- // Only a user edit pushes the header onto its group; a morph (whose
87
- // turbo:morph-element bubbles from the header too) derives it instead.
88
- if (event?.type === "input" || event?.type === "change") flipGroup(c, event.target)
89
- // Whether this root has group bindings is read at connect and again on
90
- // a morph (which may add one, and so need the observer) — never per
91
- // keystroke.
92
- else {
93
- c.groups = hasGroups(c)
94
- observeGroups(c)
67
+ // Each binding below is wired once, the first time its gate reads true:
68
+ // here, and again from reseed() — a stream can render a root's first show
69
+ // target, filter or nested list into it after connect (issue #356).
70
+ let resetting
71
+ let filtering
72
+ let tagging
73
+ let serializing
74
+ ;(c.wire = () => {
75
+ // Show bindings (issue #161) — ONLY when this root owns one, so a component
76
+ // without any pays a single probe (the dirty-tracking gate precedent). ONE
77
+ // delegated listener pair on the root (input + change bubble from every
78
+ // owned field — no per-field wiring, and a reactive_compute output write
79
+ // dispatches a real input event, so computed values drive visibility too).
80
+ // The connect sync seeds the initial state — a plain replace re-connects —
81
+ // and turbo:morph-element re-syncs after an in-place morph (which keeps the
82
+ // element connected, fires no Stimulus lifecycle, and may preserve a
83
+ // user-edited field value the server's hidden attrs don't reflect).
84
+ if (!c.show && showSyncEnabled(c)) {
85
+ // A select-all header's edit flips its group first (issue #319) — on
86
+ // `input`, which a click fires BEFORE `change`, or the pass below would
87
+ // reset the header to its group's state first (the flip is idempotent, so
88
+ // the `change` that follows flips nothing). The change events it
89
+ // dispatches re-enter here and are skipped while it flips, so the pass
90
+ // after the loop runs once, not once per box.
91
+ c.show = (event) => {
92
+ if (c.flipping) return
93
+ // Only a user edit pushes the header onto its group; a morph (whose
94
+ // turbo:morph-element bubbles from the header too) derives it instead.
95
+ if (event?.type === "input" || event?.type === "change") flipGroup(c, event.target)
96
+ // Whether this root has group bindings is read at connect and again on
97
+ // a morph (which may add one, and so need the observer) — never per
98
+ // keystroke.
99
+ else {
100
+ c.groups = hasGroups(c)
101
+ observeGroups(c)
102
+ }
103
+ syncShow(c)
95
104
  }
96
- syncShow(c)
105
+ listen(c, "input change", c.show)
106
+ seed(c, c.show)
97
107
  }
98
- listen(c, "input change", c.show)
99
- seed(c, c.show)
100
- }
101
108
 
102
- // Completion bindings (issue #226) — ONLY when the root declares
103
- // data-reactive-on-complete (the show/filter gate precedent). ONE
104
- // delegated input+change listener evaluates every binding's DNF over the
105
- // owned fields and runs its ops on the RISING EDGE — the event-driven
106
- // flip to true. The connect pass ARMS without firing (a fresh render with
107
- // already-satisfied conditions must never self-fire — the $ops seed
108
- // precedent), and turbo:morph-element re-arms the same way after an
109
- // in-place morph. Listeners added HERE run after the Stimulus-wired
110
- // recompute delegation for the same event, so the evaluation reads
111
- // compute-NORMALIZED values (and a compute output write dispatches a real
112
- // input event that re-evaluates anyway).
113
- if (onCompleteEnabled(c)) {
114
- listen(c, "input change", (event) => syncOnComplete(c, event))
115
- seed(
116
- c,
117
- (c.arm = () => {
118
- syncOnComplete(c, null)
119
- observeGroups(c)
120
- }),
121
- )
122
- }
109
+ // Completion bindings (issue #226) — ONLY when the root declares
110
+ // data-reactive-on-complete (the show/filter gate precedent). ONE
111
+ // delegated input+change listener evaluates every binding's DNF over the
112
+ // owned fields and runs its ops on the RISING EDGE — the event-driven
113
+ // flip to true. The connect pass ARMS without firing (a fresh render with
114
+ // already-satisfied conditions must never self-fire — the $ops seed
115
+ // precedent), and turbo:morph-element re-arms the same way after an
116
+ // in-place morph. Listeners added HERE run after the Stimulus-wired
117
+ // recompute delegation for the same event, so the evaluation reads
118
+ // compute-NORMALIZED values (and a compute output write dispatches a real
119
+ // input event that re-evaluates anyway).
120
+ if (!c.arm && onCompleteEnabled(c)) {
121
+ listen(c, "input change", (event) => syncOnComplete(c, event))
122
+ seed(
123
+ c,
124
+ (c.arm = () => {
125
+ syncOnComplete(c, null)
126
+ observeGroups(c)
127
+ }),
128
+ )
129
+ }
123
130
 
124
- // A form reset (issue #342) restores its fields AFTER the `reset` event
125
- // dispatches (and only if nothing cancelled it), so this waits a task, then
126
- // re-syncs the bindings and re-arms the completion latches without firing
127
- // (a reset fires no input/change, so a checked-count latch would stay high).
128
- if (c.show || c.arm) {
129
- listen(c, "reset", () =>
130
- setTimeout(() => {
131
- if (contexts.get(controller) !== c) return
132
- c.show?.()
133
- c.arm?.()
134
- }, 0),
135
- )
136
- }
131
+ // A form reset (issue #342) restores its fields AFTER the `reset` event
132
+ // dispatches (and only if nothing cancelled it), so this waits a task, then
133
+ // re-syncs the bindings and re-arms the completion latches without firing
134
+ // (a reset fires no input/change, so a checked-count latch would stay high).
135
+ if (!resetting && (resetting = c.show || c.arm)) {
136
+ listen(c, "reset", () =>
137
+ setTimeout(() => {
138
+ if (contexts.get(controller) !== c) return
139
+ c.show?.()
140
+ c.arm?.()
141
+ }, 0),
142
+ )
143
+ }
137
144
 
138
- // Option filtering (issue #163) — ONLY when the root declares the binding
139
- // (reactive_filter emits both attrs together), so a component without one
140
- // pays two attribute reads. ONE delegated input listener on the root — the
141
- // handler re-filters only for events from the NAMED input, so keystrokes in
142
- // unrelated fields on a wide form never pay a filter pass. The connect sync
143
- // seeds from the input's current value (a plain replace re-connects; back
144
- // navigation may restore typed text), and turbo:morph-element re-applies
145
- // after an in-place morph (which keeps the element connected, fires no
146
- // Stimulus lifecycle, and may preserve the user's typed query while the
147
- // server re-rendered every option visible).
148
- if (filterEnabled(c)) {
149
- listen(c, "input", (event) => filterInputEvent(c, event) && syncFilter(c))
150
- seed(c, () => syncFilter(c))
151
- }
145
+ // Option filtering (issue #163) — ONLY when the root declares the binding
146
+ // (reactive_filter emits both attrs together), so a component without one
147
+ // pays two attribute reads. ONE delegated input listener on the root — the
148
+ // handler re-filters only for events from the NAMED input, so keystrokes in
149
+ // unrelated fields on a wide form never pay a filter pass. The connect sync
150
+ // seeds from the input's current value (a plain replace re-connects; back
151
+ // navigation may restore typed text), and turbo:morph-element re-applies
152
+ // after an in-place morph (which keeps the element connected, fires no
153
+ // Stimulus lifecycle, and may preserve the user's typed query while the
154
+ // server re-rendered every option visible).
155
+ if (!filtering && (filtering = filterEnabled(c))) {
156
+ listen(c, "input", (event) => filterInputEvent(c, event) && syncFilter(c))
157
+ seed(c, () => syncFilter(c))
158
+ }
152
159
 
153
- // Tag-chip input (issue #203) — ONLY when the root names the hidden value
154
- // field (reactive_tags), so a component without one pays one attribute
155
- // read. The chip list is a CLIENT PROJECTION of the hidden field's
156
- // comma-joined value: connect seeds it (a plain replace re-connects with
157
- // the server-rendered value), and turbo:morph-element re-projects after an
158
- // in-place morph (the morph wrote server truth into the hidden field while
159
- // the chips DOM kept the pre-morph projection). Registered AFTER the
160
- // filter's listeners so a morph re-filters first and the tags pass then
161
- // re-marks selected options on the fresh visibility state.
162
- if (tagsEnabled(c)) seed(c, () => syncTags(c))
163
-
164
- // JSON-mode nested rows (issue #208) — ONLY when the root owns a list with
165
- // `as: :json`, so a form without one pays a single probe (the show/filter/
166
- // tags gate precedent). ONE delegated input + change listener re-serializes
167
- // the rows into the hidden field on every owned edit (nestedAdd/Remove call
168
- // the sync directly; this covers typing into a row's fields). The connect
169
- // seed writes the initial array (a plain replace re-connects), and
170
- // turbo:morph-element re-seeds after an in-place morph (which keeps the
171
- // element connected, fires no Stimulus lifecycle, and may have rewritten
172
- // the rows to server truth while the hidden field kept its pre-morph value).
173
- if (nestedJsonEnabled(c)) {
174
- listen(c, "input change", (event) => syncNestedJson(c, event))
175
- seed(c, () => syncAllNestedJson(c))
176
- }
160
+ // Tag-chip input (issue #203) — ONLY when the root names the hidden value
161
+ // field (reactive_tags), so a component without one pays one attribute
162
+ // read. The chip list is a CLIENT PROJECTION of the hidden field's
163
+ // comma-joined value: connect seeds it (a plain replace re-connects with
164
+ // the server-rendered value), and turbo:morph-element re-projects after an
165
+ // in-place morph (the morph wrote server truth into the hidden field while
166
+ // the chips DOM kept the pre-morph projection). Registered AFTER the
167
+ // filter's listeners so a morph re-filters first and the tags pass then
168
+ // re-marks selected options on the fresh visibility state.
169
+ if (!tagging && (tagging = tagsEnabled(c))) seed(c, () => syncTags(c))
170
+
171
+ // JSON-mode nested rows (issue #208) — ONLY when the root owns a list with
172
+ // `as: :json`, so a form without one pays a single probe (the show/filter/
173
+ // tags gate precedent). ONE delegated input + change listener re-serializes
174
+ // the rows into the hidden field on every owned edit (nestedAdd/Remove call
175
+ // the sync directly; this covers typing into a row's fields). The connect
176
+ // seed writes the initial array (a plain replace re-connects), and
177
+ // turbo:morph-element re-seeds after an in-place morph (which keeps the
178
+ // element connected, fires no Stimulus lifecycle, and may have rewritten
179
+ // the rows to server truth while the hidden field kept its pre-morph value).
180
+ if (!serializing && (serializing = nestedJsonEnabled(c))) {
181
+ listen(c, "input change", (event) => syncNestedJson(c, event))
182
+ seed(c, () => syncAllNestedJson(c))
183
+ }
184
+ })()
177
185
 
178
186
  // Editable text (issue #351). Delegated keys, paste and drop on the root,
179
187
  // whether or not it owns an editable yet: a stream can append the first one
180
188
  // into a root whose bindings connected for another marker (each handler
181
- // returns at once for an event that is not an owned editable's). The seed
182
- // downgrades plaintext-only where the engine lacks it, again after a morph
183
- // (which writes the server's attribute back).
184
- seed(c, () => downgradeEditables(c))
189
+ // returns at once for an event that is not an owned editable's).
185
190
  listen(c, "keydown", (event) => editableKey(c, event))
186
191
  listen(c, "paste drop", (event) => editablePaste(c, event))
187
192
  listen(c, "input", (event) => editableEmptied(c, event))
@@ -199,10 +204,15 @@ export function disconnect(controller) {
199
204
  }
200
205
 
201
206
  // Re-run the seeds that read field values, in connect() order — for a
202
- // feature that changed those values after connect() ran (the draft restore).
203
- // Each is the same re-sync a morph runs; on-complete re-ARMS without firing.
207
+ // feature that changed those values after connect() ran (the draft restore),
208
+ // or content a stream rendered into the root (issue #356) — then wire any
209
+ // binding whose gate reads true only now. Each seed is the same re-sync a
210
+ // morph runs; on-complete re-ARMS without firing.
204
211
  export function reseed(controller) {
205
- for (const run of contexts.get(controller)?.seeds ?? []) run()
212
+ const c = contexts.get(controller)
213
+ if (!c) return
214
+ for (const run of c.seeds) run()
215
+ c.wire()
206
216
  }
207
217
 
208
218
  // A value this module wrote is announced the way a user's edit is: a bubbling
@@ -1603,27 +1613,9 @@ function conditionalConfirmMessage(c, confirmWhen) {
1603
1613
  // --- Editable text (issue #351) ----------------------------------------------
1604
1614
  // reactive_editable renders contenteditable="plaintext-only": the browser
1605
1615
  // itself then pastes plain text and inserts "\n" for Enter. An engine without
1606
- // it reads the value as invalid — NOT editable — so the seed downgrades to
1607
- // "true" there and the paste handler below does the plain-text part. Detected
1608
- // once per page.
1609
- let plaintextOnly
1610
-
1611
- export function __setPlaintextOnlyForTest(value) {
1612
- plaintextOnly = value
1613
- }
1614
-
1615
- function plaintextSupported() {
1616
- if (plaintextOnly === undefined) {
1617
- try {
1618
- const probe = document.createElement("div")
1619
- probe.contentEditable = "plaintext-only"
1620
- plaintextOnly = probe.contentEditable === "plaintext-only"
1621
- } catch {
1622
- plaintextOnly = false
1623
- }
1624
- }
1625
- return plaintextOnly
1626
- }
1616
+ // it reads the value as invalid; the runtime switches every reactive editable
1617
+ // to "true" there (issue #355), and the paste handler below does the
1618
+ // plain-text part.
1627
1619
 
1628
1620
  // The owned editable an event came from, or null — a nested reactive root's
1629
1621
  // editables are its own (#15).
@@ -1632,13 +1624,6 @@ function ownedEditable(c, event) {
1632
1624
  return el && c.core.owns(el) ? el : null
1633
1625
  }
1634
1626
 
1635
- function downgradeEditables(c) {
1636
- if (plaintextSupported()) return
1637
- for (const el of c.root.querySelectorAll("[data-reactive-editable][contenteditable=plaintext-only]")) {
1638
- el.setAttribute("contenteditable", "true")
1639
- }
1640
- }
1641
-
1642
1627
  // Enter: a single-line field swallows it; on_enter runs its ops (root-scoped,
1643
1628
  // as on_client) or submits the field's form — in a multiline field only a
1644
1629
  // plain Enter, so Shift+Enter still breaks the line. Never while an IME is
@@ -1671,7 +1656,7 @@ function editablePaste(c, event) {
1671
1656
  const el = ownedEditable(c, event)
1672
1657
  const single = el?.getAttribute("data-reactive-editable") !== "multi"
1673
1658
  let text = (event.clipboardData ?? event.dataTransfer)?.getData("text/plain")
1674
- if (!el || text == null || (plaintextSupported() && !(single && /[\r\n]/.test(text)))) return
1659
+ if (!el || text == null || (c.core.plain() && !(single && /[\r\n]/.test(text)))) return
1675
1660
  event.preventDefault()
1676
1661
  if (single) text = text.replace(/\r?\n|\r/g, " ")
1677
1662
  el.focus()