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
@@ -36,6 +36,7 @@ import * as devtoolsFeature from "phlex/reactive/features/devtools"
36
36
  import * as hintsFeature from "phlex/reactive/features/hints"
37
37
  import * as deferFeature from "phlex/reactive/features/defer"
38
38
  import * as sortableFeature from "phlex/reactive/features/sortable"
39
+ import * as presenceFeature from "phlex/reactive/features/presence"
39
40
 
40
41
  // phlex/reactive/runtime — the reactive controller WITHOUT its feature
41
42
  // modules (issue #275): the code both client entries share. It is not an
@@ -199,6 +200,8 @@ export function registerReactiveStreamRender() {
199
200
  if (typeof document === "undefined" || typeof document.addEventListener !== "function") return
200
201
  streamRenderRegistered = true
201
202
  document.addEventListener("turbo:before-stream-render", (event) => {
203
+ streamIntoRoots(event)
204
+ streamModals(event)
202
205
  // The opt-in entry sees every stream (it may hold one for a module the
203
206
  // incoming content needs); with no entry hook, a loaded effects module
204
207
  // wraps the stream directly — in the default entry, always.
@@ -210,6 +213,149 @@ export function registerReactiveStreamRender() {
210
213
  // The names this registration had while it was two listeners.
211
214
  export { registerReactiveStreamRender as registerReactiveDismiss, registerReactiveStreamRender as registerReactiveEffects }
212
215
 
216
+ // --- A stream into a connected root (issue #356) ---
217
+ // A morph of a root makes it re-read its feature markers (#boundRootMorph); a
218
+ // stream that renders INTO it — append, prepend or update of the root or a
219
+ // child, before, after or replace of a child — fires neither a lifecycle nor
220
+ // a morph of the root, so a marker it brings was never connected. The roots
221
+ // a stream renders into are resolved here, before anything (effects, the opt-in
222
+ // entry's hold) wraps Turbo's render; once that render has run, each one still
223
+ // connected is told (STREAMED, not bubbling: an outer root's content is not
224
+ // its own). The root re-runs its connected features' seeds and connects what
225
+ // the new content needs, in both entries.
226
+ const STREAMED = "reactive:streamed"
227
+
228
+ function streamIntoRoots(event) {
229
+ const detail = event.detail
230
+ const render = detail?.render
231
+ const stream = detail?.newStream ?? event.target
232
+ const action = stream?.getAttribute?.("action")
233
+ const into = /^(append|prepend|update)$/.test(action)
234
+ if (typeof render !== "function" || !(into || /^(before|after|replace)$/.test(action))) return
235
+ const id = stream.getAttribute("target")
236
+ const selector = stream.getAttribute("targets")
237
+ const roots = new Set()
238
+ for (const target of id ? [document.getElementById(id)] : selector ? document.querySelectorAll(selector) : []) {
239
+ const root = (into ? target : target?.parentElement)?.closest?.('[data-controller~="reactive"]')
240
+ if (root) roots.add(root)
241
+ }
242
+ if (roots.size === 0) return
243
+ detail.render = async (el) => {
244
+ await render(el)
245
+ for (const root of roots) if (root.isConnected) root.dispatchEvent(new Event(STREAMED))
246
+ }
247
+ }
248
+
249
+ // --- Modal dialogs (issue #365) ---
250
+ // show_modal / close_dialog reach the browser's own modal dialog: the top
251
+ // layer, the focus trap, ::backdrop, Escape and focus restored to the opener.
252
+ // A dialog the SERVER renders open with data-reactive-modal becomes modal as
253
+ // soon as the client sees it: at a root's connect (a full page, a replaced
254
+ // root), after a morph of a root or of the page, and after a stream whose
255
+ // content holds a dialog. Duck-typed on tagName (the submitFormFor precedent:
256
+ // fake-node and test friendly).
257
+ function isDialog(el) {
258
+ return el?.tagName === "DIALOG" && typeof el.showModal === "function"
259
+ }
260
+
261
+ // `:modal` is the only way to tell a modal dialog from one opened with the
262
+ // `open` attribute. An engine that does not know the pseudo-class throws a
263
+ // SyntaxError; it then counts as not modal (show_modal reopens it modally).
264
+ function isModal(dialog) {
265
+ try {
266
+ return dialog.matches(":modal")
267
+ } catch {
268
+ return false
269
+ }
270
+ }
271
+
272
+ function showModal(el) {
273
+ if (!isDialog(el)) {
274
+ // Said where a zero-target op is (issue #237): under verbose_errors or debug.
275
+ if (el?.closest?.('[data-reactive-verbose="true"], [data-reactive-debug="true"]')) {
276
+ console.warn(`[phlex-reactive] show_modal targets a <dialog>, not <${el.localName}> — skipped`)
277
+ }
278
+ return
279
+ }
280
+ if (!el.isConnected || isModal(el)) return
281
+ // showModal() throws on a dialog already open non-modally: close it first,
282
+ // in the same task, so nothing paints in between.
283
+ if (el.open) el.close()
284
+ el.showModal()
285
+ }
286
+
287
+ function closeDialog(el) {
288
+ if (isDialog(el) && el.open) el.close()
289
+ }
290
+
291
+ // Make every open dialog[data-reactive-modal] in `scope` modal — `scope` itself
292
+ // too, for a root that is the dialog.
293
+ function upgradeModals(scope) {
294
+ const sel = "dialog[data-reactive-modal]"
295
+ const dialogs = [...(scope?.querySelectorAll?.(sel) ?? [])]
296
+ if (scope?.matches?.(sel)) dialogs.push(scope)
297
+ for (const dialog of dialogs) {
298
+ if (dialog.open && !isModal(dialog)) showModal(dialog)
299
+ }
300
+ }
301
+
302
+ // After a stream whose content holds a dialog has rendered, upgrade the page's
303
+ // marked dialogs. Document-wide: a replace's target is gone by then.
304
+ function streamModals(event) {
305
+ const detail = event.detail
306
+ const render = detail?.render
307
+ const stream = detail?.newStream ?? event.target
308
+ if (typeof render !== "function" || !stream?.querySelector?.("template")?.content?.querySelector?.("dialog")) return
309
+ detail.render = async (el) => {
310
+ await render(el)
311
+ upgradeModals(document)
312
+ }
313
+ }
314
+
315
+ let modalsRegistered = false
316
+
317
+ export function registerReactiveModals() {
318
+ if (modalsRegistered) return
319
+ if (typeof document === "undefined" || typeof document.addEventListener !== "function") return
320
+ modalsRegistered = true
321
+ // A morph of a root (a reply or broadcast morph, a page refresh) may have
322
+ // inserted an open marked dialog or opened one; a page morph covers dialogs
323
+ // outside any root.
324
+ document.addEventListener("turbo:morph-element", (event) => {
325
+ if (event.target?.matches?.('[data-controller~="reactive"]')) upgradeModals(event.target)
326
+ })
327
+ document.addEventListener("turbo:morph", () => upgradeModals(document))
328
+ // A morph that removes `open` from a modal dialog (the server now renders it
329
+ // closed): close() it instead, which leaves the top layer, ends the page's
330
+ // inertness and restores focus. Removing the attribute alone left all that
331
+ // in place under the older spec text, and close() is a no-op once `open` is
332
+ // gone — so the removal is cancelled and close() removes it. Idiomorph asks
333
+ // "remove" for EVERY attribute of the old element, kept or not, and that ask
334
+ // carries no new element: whether `open` is really leaving is read from the
335
+ // element morph's new render, which comes first.
336
+ const closing = new WeakSet()
337
+ document.addEventListener("turbo:before-morph-element", (event) => {
338
+ const el = event.target
339
+ const next = event.detail?.newElement
340
+ if (!isDialog(el) || !next) return
341
+ if (next.hasAttribute?.("open")) closing.delete(el)
342
+ else closing.add(el)
343
+ })
344
+ document.addEventListener("turbo:before-morph-attribute", (event) => {
345
+ const el = event.target
346
+ const detail = event.detail
347
+ if (detail?.attributeName !== "open" || detail.mutationType !== "remove") return
348
+ if (!closing.has(el) || !el.open || !isModal(el)) return
349
+ closing.delete(el)
350
+ event.preventDefault()
351
+ el.close()
352
+ })
353
+ }
354
+
355
+ export function __resetReactiveModalsForTest() {
356
+ modalsRegistered = false
357
+ }
358
+
213
359
  // For the opt-in entry: what to do with a stream before its render — hold it
214
360
  // for a module the incoming content needs. `handle(event, effects)` gets the
215
361
  // loaded effects module (or undefined) and wraps the stream with it itself.
@@ -237,6 +383,66 @@ export function __resetReactiveStreamRenderForTest() {
237
383
  // for the attribute AND the property. Nothing else
238
384
  // (a root's token, a field's class) is held.
239
385
  // data-reactive-morph-value on a field lets the morph write it while focused.
386
+ // --- An engine without contenteditable="plaintext-only" (issue #355) ---
387
+ // reactive_editable renders contenteditable="plaintext-only": the browser
388
+ // itself then pastes plain text and inserts "\n" for Enter. An engine without
389
+ // it reads the value as INVALID — the element is not editable, cannot even be
390
+ // focused — so there every reactive editable is switched to "true" (and the
391
+ // bindings feature does the plain-text paste itself). However it reaches the
392
+ // page: present at load, appended by a stream into a root that is already
393
+ // connected, loaded in a frame, written back by a morph of an unfocused
394
+ // editable, or made reactive by a morph. One observer, only in such an engine; elsewhere the probe is all
395
+ // that runs. Detected once per page.
396
+ let plaintextOnly
397
+ let plaintextObserver = null
398
+ const PLAINTEXT_EDITABLE = "[data-reactive-editable][contenteditable=plaintext-only]"
399
+
400
+ function plaintextSupported() {
401
+ if (plaintextOnly === undefined) {
402
+ try {
403
+ const probe = document.createElement("div")
404
+ probe.contentEditable = "plaintext-only"
405
+ plaintextOnly = probe.contentEditable === "plaintext-only"
406
+ } catch {
407
+ plaintextOnly = false
408
+ }
409
+ }
410
+ return plaintextOnly
411
+ }
412
+
413
+ function makeEditable(el) {
414
+ if (el.matches?.(PLAINTEXT_EDITABLE)) el.setAttribute("contenteditable", "true")
415
+ el.querySelectorAll?.(PLAINTEXT_EDITABLE).forEach(makeEditable)
416
+ }
417
+
418
+ export function registerReactivePlaintext() {
419
+ // (No element factory, no page to observe: never probe a stub document.)
420
+ if (plaintextObserver || typeof document === "undefined" || typeof document.createElement !== "function") return
421
+ if (plaintextSupported()) return
422
+ makeEditable(document)
423
+ plaintextObserver = new MutationObserver((records) => {
424
+ for (const record of records) {
425
+ if (record.type === "attributes") makeEditable(record.target)
426
+ else record.addedNodes.forEach(makeEditable)
427
+ }
428
+ })
429
+ plaintextObserver.observe(document.documentElement, {
430
+ childList: true,
431
+ subtree: true,
432
+ attributes: true,
433
+ // (a morph can also make an existing plaintext-only element reactive)
434
+ attributeFilter: ["contenteditable", "data-reactive-editable"],
435
+ })
436
+ }
437
+
438
+ // For tests: what the probe answers (undefined probes again), with no
439
+ // observer installed.
440
+ export function __setReactivePlaintextOnlyForTest(value) {
441
+ plaintextObserver?.disconnect()
442
+ plaintextObserver = null
443
+ plaintextOnly = value
444
+ }
445
+
240
446
  let morphFocusRegistered = false
241
447
 
242
448
  //
@@ -244,8 +450,8 @@ let morphFocusRegistered = false
244
450
  // CHILDREN: a focused one keeps them (text and caret) while every attribute
245
451
  // still morphs — copied here from the render, then the element's morph is
246
452
  // cancelled (cancelling is all-or-nothing in Idiomorph). Its contenteditable
247
- // stays as it is: the bindings may have downgraded it for an engine without
248
- // plaintext-only. Every morph of an editable, held or not, records the
453
+ // stays as it is: the runtime switches it to "true" in an engine without
454
+ // plaintext-only (registerReactivePlaintext, #355). Every morph of an editable, held or not, records the
249
455
  // render's text as its SERVER TEXT (the Escape revert, the dirty baseline).
250
456
  function holdsTypedValue(el) {
251
457
  return (
@@ -486,6 +692,8 @@ export function registerReactiveActions() {
486
692
  registerReactiveDefer()
487
693
  registerReactiveStreamRender()
488
694
  registerReactiveMorphFocus()
695
+ registerReactiveModals()
696
+ registerReactivePlaintext()
489
697
  registerReactiveOffline()
490
698
  registerReactiveDev()
491
699
  }
@@ -742,6 +950,11 @@ const CLIENT_OPS = Object.freeze({
742
950
  focus: (el) => el.focus?.(),
743
951
  focus_first: (el) => firstFocusable(el)?.focus?.(),
744
952
 
953
+ // Modal dialog ops (issue #365): HTMLDialogElement#showModal / #close.
954
+ // ACTOR-ONLY like focus (BROADCAST_REFUSED_OPS server-side).
955
+ show_modal: (el) => showModal(el),
956
+ close_dialog: (el) => closeDialog(el),
957
+
745
958
  // Text op (issue #159): set textContent — XSS-safe by construction (never
746
959
  // innerHTML), strictly less powerful than set_attr. Change-guarded like
747
960
  // #mirrorText. With global: true it is the cross-root text escape: paint a
@@ -766,6 +979,13 @@ const CLIENT_OPS = Object.freeze({
766
979
  }
767
980
  },
768
981
 
982
+ // Write this tab's presence state (issue #358) through the root's
983
+ // presence_set action. The feature warns outside a reactive_presence root.
984
+ presence: (el, args) =>
985
+ __SPLIT__
986
+ ? withFeature("presence", (presence) => presence.set(el, args.state))
987
+ : presenceFeature.set(el, args.state),
988
+
769
989
  // Dispatch a bubbling CustomEvent (issue #96). RAW element.dispatchEvent — the
770
990
  // controller SHADOWS Stimulus's this.dispatch helper, so it must not be used.
771
991
  dispatch: (el, args) => {
@@ -922,9 +1142,13 @@ function parseOps(raw) {
922
1142
  // record(s) whose descriptor matches the firing event: event.type, the key
923
1143
  // filter, and window-boundness.
924
1144
 
925
- // Stimulus's default keyMappings (letters and digits map to themselves). A
1145
+ // Stimulus's default keyMappings (letters and digits map to themselves), plus
1146
+ // delete and backspace (issue #366): Stimulus lacks those two, so a shortcut
1147
+ // naming one binds a bare keydown and only shortcutApplies matches it. A
926
1148
  // custom Stimulus schema is not mirrored — an unknown filter never matches.
927
1149
  const KEY_FILTER_MAP = Object.freeze({
1150
+ backspace: "Backspace",
1151
+ delete: "Delete",
928
1152
  enter: "Enter",
929
1153
  tab: "Tab",
930
1154
  esc: "Escape",
@@ -963,6 +1187,25 @@ function keyFilterMatches(filter, event, keyMappings) {
963
1187
  return typeof key === "string" && key.toLowerCase() === event.key.toLowerCase()
964
1188
  }
965
1189
 
1190
+ // Issue #357: a shortcut: ("mod+z", or { keys, inEditable }) is a window
1191
+ // keydown whose `mod` is meta on Apple platforms and ctrl elsewhere. Ruby
1192
+ // renders a meta twin and a ctrl twin; Stimulus filters the key, and this
1193
+ // decides whether the twin that fired is this platform's and whether the
1194
+ // target is one the user is typing in. Either miss is a complete no-op. A
1195
+ // delete/backspace chord (issue #366) has no twins and no Stimulus filter:
1196
+ // its bare keydown hears every key, and this check alone picks the chord.
1197
+ // (Stimulus typecasts a digit shortcut, "1", to a number: hence String().)
1198
+ const shortcutKeys = (spec) => (spec && typeof spec === "object" ? spec.keys : String(spec))
1199
+ // A shortcut record keeps a bare `on: "keydown"`, so bindingMatches lets every
1200
+ // keydown through and this check, per record, picks the chord that fired.
1201
+ function shortcutApplies(spec, event, keyMappings) {
1202
+ const target = event.target
1203
+ if (spec?.inEditable !== true && (target?.isContentEditable || /^(input|textarea|select)$/.test(target?.localName)))
1204
+ return false
1205
+ const mod = /[Mm]ac|iP/.test(navigator.userAgentData?.platform ?? navigator.platform) ? "meta" : "ctrl"
1206
+ return keyFilterMatches(shortcutKeys(spec).replace(/^mod\+/, `${mod}+`), event, keyMappings)
1207
+ }
1208
+
966
1209
  // Parse data-reactive-ops-param into binding records. Stimulus typecasts a
967
1210
  // lone record to an object; several records stay a string (JSON.parse fails on
968
1211
  // the join) and are split here. A LEGACY [[op, args]] list (array or JSON
@@ -1283,6 +1526,11 @@ function sortableNeeded(root) {
1283
1526
  return Boolean(root.matches?.(lists) || root.querySelector?.(lists))
1284
1527
  }
1285
1528
 
1529
+ // Presence (issue #358): the root is a reactive_presence roster.
1530
+ function presenceNeeded(root) {
1531
+ return root.getAttribute?.("data-reactive-presence") != null
1532
+ }
1533
+
1286
1534
  const PRODUCTION_FEATURES = [
1287
1535
  // (Its `waiting` hook is the opt-in entry's: core.js.) The draft restore
1288
1536
  // writes what a request reads, so it gates.
@@ -1295,6 +1543,9 @@ const PRODUCTION_FEATURES = [
1295
1543
  ["compute", [computeNeeded, null, null, true]],
1296
1544
  // Nothing it does at connect is read by a request: it never gates.
1297
1545
  ["sortable", [sortableNeeded, null]],
1546
+ // Its announce is a request of its own; nothing it does at connect is read
1547
+ // by another request: it never gates.
1548
+ ["presence", [presenceNeeded, null]],
1298
1549
  [
1299
1550
  // Stream effects and dismissing flashes are document-level: the module
1300
1551
  // has no connect. A root that DECLARES an effect only gets it fetched
@@ -1458,7 +1709,7 @@ function featureTimeoutMs() {
1458
1709
  export function reactiveFeatureNames() {
1459
1710
  return __SPLIT__
1460
1711
  ? [...FEATURES.keys()]
1461
- : ["persist", "defer", "form", "bindings", "compute", "sortable", "effects", "hints", "devtools"]
1712
+ : ["persist", "defer", "form", "bindings", "compute", "sortable", "presence", "effects", "hints", "devtools"]
1462
1713
  }
1463
1714
 
1464
1715
  export function __setReactiveFeatureForTest(name, needs, load, waiting, gates) {
@@ -1481,6 +1732,7 @@ export function __loadReactiveFeatureForTest(name) {
1481
1732
  bindings: bindingsFeature,
1482
1733
  compute: computeFeature,
1483
1734
  sortable: sortableFeature,
1735
+ presence: presenceFeature,
1484
1736
  effects: effectsFeature,
1485
1737
  hints: hintsFeature,
1486
1738
  devtools: devtoolsFeature,
@@ -1534,6 +1786,7 @@ const FORM = 4
1534
1786
  const BINDINGS = 8
1535
1787
  const COMPUTE = 16
1536
1788
  const SORTABLE = 32
1789
+ const PRESENCE = 64
1537
1790
 
1538
1791
  // The default entry's features are part of this file: install them now, as
1539
1792
  // the opt-in entry does when one arrives.
@@ -1755,7 +2008,7 @@ export default class extends Controller {
1755
2008
  #featureTurns = new Map() // name -> settles once that feature has had its turn to connect
1756
2009
  #featureEpoch = 0
1757
2010
  #featureHandle // the `core` handle features receive, built on first use
1758
- // The default entry (issue #305): one bit per feature, PERSIST … SORTABLE —
2011
+ // The default entry (issue #305): one bit per feature, PERSIST … PRESENCE —
1759
2012
  // asked for (its marker read true) and connected on this connection.
1760
2013
  #featuresAsked = 0
1761
2014
  #featuresOn = 0
@@ -1802,15 +2055,20 @@ export default class extends Controller {
1802
2055
  // The same listener hears an editable being focused (issue #351): a stream
1803
2056
  // can append a root's first reactive_editable without morphing the root,
1804
2057
  // and its keys and paste need the bindings feature before anything is
1805
- // typed into it.
2058
+ // typed into it. And it hears a stream that rendered into the root
2059
+ // (STREAMED, issue #356): the features already connected re-run their
2060
+ // seeds first (a morph's own listeners do that for a morph), then what the
2061
+ // new content needs connects.
1806
2062
  if (this.element.getAttribute?.("data-reactive-token-value") != null || deferNeeded(this.element)) {
1807
2063
  this.#boundRootMorph = (event) => {
1808
2064
  if (event.type === "focusin" ? !event.target.hasAttribute?.("data-reactive-editable") : event.target !== this.element) return
2065
+ if (event.type === STREAMED) this.#reseed()
1809
2066
  if (__SPLIT__) this.#loadFeatures(true)
1810
2067
  else this.#connectFeaturesNow(true)
1811
2068
  }
1812
2069
  this.element.addEventListener?.("turbo:morph-element", this.#boundRootMorph)
1813
2070
  this.element.addEventListener?.("focusin", this.#boundRootMorph)
2071
+ this.element.addEventListener?.(STREAMED, this.#boundRootMorph)
1814
2072
  }
1815
2073
 
1816
2074
  // Feature modules (issue #275): connect the ones that are here and START
@@ -1830,6 +2088,9 @@ export default class extends Controller {
1830
2088
  if (__SPLIT__) this.#loadFeatures()
1831
2089
  else this.#connectFeaturesNow()
1832
2090
 
2091
+ // A dialog the server rendered open with data-reactive-modal (issue #365).
2092
+ upgradeModals(this.element)
2093
+
1833
2094
  // LAST, after every feature above is wired: a replayed trigger must find
1834
2095
  // the controller exactly as a live event after connect would.
1835
2096
  this.#announceConnected()
@@ -1849,6 +2110,7 @@ export default class extends Controller {
1849
2110
  this.#connectFeatureNow(BINDINGS, "bindings", bindingsNeeded, (core) => bindingsFeature.connect(this, core, morphed))
1850
2111
  this.#connectFeatureNow(COMPUTE, "compute", computeNeeded, (core) => computeFeature.connect(this, core, morphed))
1851
2112
  this.#connectFeatureNow(SORTABLE, "sortable", sortableNeeded, (core) => sortableFeature.connect(this, core))
2113
+ this.#connectFeatureNow(PRESENCE, "presence", presenceNeeded, (core) => presenceFeature.connect(this, core))
1852
2114
  }
1853
2115
  }
1854
2116
 
@@ -1886,6 +2148,7 @@ export default class extends Controller {
1886
2148
  if (on & BINDINGS) this.#disconnectFeatureNow("bindings", () => bindingsFeature.disconnect(this, core))
1887
2149
  if (on & COMPUTE) this.#disconnectFeatureNow("compute", () => computeFeature.disconnect(this, core))
1888
2150
  if (on & SORTABLE) this.#disconnectFeatureNow("sortable", () => sortableFeature.disconnect(this, core))
2151
+ if (on & PRESENCE) this.#disconnectFeatureNow("presence", () => presenceFeature.disconnect(this, core))
1889
2152
  }
1890
2153
  }
1891
2154
 
@@ -2074,7 +2337,9 @@ export default class extends Controller {
2074
2337
  ),
2075
2338
  listnavOptions: (event) => this.#listnavOptions(event),
2076
2339
  collectFields: () => this.#collectFields(),
2077
- // A reactive_editable's value and its last server-rendered text (#351).
2340
+ // A reactive_editable's value and its last server-rendered text (#351),
2341
+ // and whether the engine has contenteditable="plaintext-only" (#355).
2342
+ plain: plaintextSupported,
2078
2343
  text: editableValue,
2079
2344
  served: serverText,
2080
2345
  // The confirm gate, as dispatch() runs it: the resolver inside the chain
@@ -2089,15 +2354,17 @@ export default class extends Controller {
2089
2354
 
2090
2355
  // Re-run the connect-time seeds that read field values, in connect() order
2091
2356
  // — for a feature that changed those values after connect() ran (the draft
2092
- // restore). Each is the same re-sync its feature runs after a morph; a root
2093
- // that did not opt into one skips it. on-complete re-ARMS without firing.
2357
+ // restore), or content a stream rendered into the root (issue #356). Each is
2358
+ // the same re-sync its feature runs after a morph; a root that did not opt
2359
+ // into one skips it. on-complete re-ARMS without firing. Form and bindings
2360
+ // also wire a binding whose gate reads true only now (a stream's content).
2094
2361
  #reseed() {
2095
2362
  if (!__SPLIT__) {
2096
- if (this.#featuresOn & FORM) formFeature.scan(this, this.#featureCore())
2363
+ if (this.#featuresOn & FORM) formFeature.reseed(this)
2097
2364
  if (this.#featuresOn & BINDINGS) bindingsFeature.reseed(this)
2098
2365
  if (this.#featuresOn & COMPUTE) computeFeature.seed(this)
2099
2366
  } else {
2100
- this.#features.get("form")?.scan(this, this.#featureCore())
2367
+ this.#features.get("form")?.reseed(this)
2101
2368
  this.#features.get("bindings")?.reseed(this)
2102
2369
  this.#features.get("compute")?.seed(this)
2103
2370
  }
@@ -2270,6 +2537,7 @@ export default class extends Controller {
2270
2537
  if (this.#boundRootMorph) {
2271
2538
  this.element.removeEventListener?.("turbo:morph-element", this.#boundRootMorph)
2272
2539
  this.element.removeEventListener?.("focusin", this.#boundRootMorph)
2540
+ this.element.removeEventListener?.(STREAMED, this.#boundRootMorph)
2273
2541
  }
2274
2542
  // Early triggers (issue #273): a disconnected root records again.
2275
2543
  earlyState().connected.delete(this.element)
@@ -2290,6 +2558,13 @@ export default class extends Controller {
2290
2558
  // a per-controller promise makes each dispatch wait for the previous one, so
2291
2559
  // it always uses the freshest token.
2292
2560
  dispatch(event) {
2561
+ // A shortcut (issue #357) that is the other platform's twin, or pressed
2562
+ // in a field the user is typing in, is a complete no-op: the native
2563
+ // default (text undo) proceeds and nothing is announced. Checked FIRST: a
2564
+ // delete/backspace shortcut (issue #366) binds a bare window keydown, so
2565
+ // every key the user presses lands here.
2566
+ const shortcut = event.params?.shortcut
2567
+ if (shortcut != null && !shortcutApplies(shortcut, event, this.application?.schema?.keyMappings)) return
2293
2568
  // A :once trigger already replayed on connect (issue #273) is spent.
2294
2569
  if (earlyOnceSwallows(event, "dispatch", this.application?.schema?.keyMappings, this.element)) return
2295
2570
  // `window` (renamed: never shadow the global) and `outside` are the event-
@@ -2345,7 +2620,11 @@ export default class extends Controller {
2345
2620
  // (which has no form-navigation default to lose) skips it and flips now, and
2346
2621
  // the failure revert snaps it back. A `change`-bound trigger is unaffected —
2347
2622
  // `change` isn't cancelable, so preventDefault was already a no-op there.
2348
- if (!windowBound && !this.#keepsNativeToggle(optimistic, target)) event.preventDefault()
2623
+ //
2624
+ // A shortcut (issue #357) IS prevented: its key filter has already narrowed
2625
+ // the window binding to one chord the page claims (mod+s must not also open
2626
+ // the browser's save dialog).
2627
+ if ((shortcut != null || !windowBound) && !this.#keepsNativeToggle(optimistic, target)) event.preventDefault()
2349
2628
 
2350
2629
  // A reactive_lazy(on:) shell's trigger (issue #276) goes through the one
2351
2630
  // materialize entry point, which dedupes it against the observer, the
@@ -2450,6 +2729,12 @@ export default class extends Controller {
2450
2729
  // the component resets whatever they toggled (by design — a signed action
2451
2730
  // owns state that must survive re-renders).
2452
2731
  runOps(event) {
2732
+ // A lone shortcut record (Stimulus typecasts it to an object) whose chord
2733
+ // did not fire bails before any bookkeeping: a delete/backspace shortcut
2734
+ // (issue #366) binds a bare window keydown that hears every key. Several
2735
+ // records are checked one by one below.
2736
+ const lone = event.params?.ops
2737
+ if (lone?.shortcut != null && !shortcutApplies(lone.shortcut, event, this.application?.schema?.keyMappings)) return
2453
2738
  if (earlyOnceSwallows(event, "runOps", this.application?.schema?.keyMappings, this.element)) return
2454
2739
  if (bindingsAlreadyRan(event, this)) return
2455
2740
  const params = event.params ?? {}
@@ -2484,6 +2769,10 @@ export default class extends Controller {
2484
2769
  // A detail miss (issue #346) is a complete no-op — checked before the
2485
2770
  // once record is spent, preventDefault and the confirm gate.
2486
2771
  if (!detailMatches(record, event)) continue
2772
+ // So is a shortcut that is not this platform's twin or fired in an
2773
+ // editable target (issue #357).
2774
+ if (record.shortcut != null && !shortcutApplies(record.shortcut, event, this.application?.schema?.keyMappings))
2775
+ continue
2487
2776
  if (onceBindingSpent(this, event, record)) continue
2488
2777
  this.#runBinding(record.legacy ? { ...params, ops: record.ops } : record, event, trigger)
2489
2778
  }
@@ -2491,7 +2780,7 @@ export default class extends Controller {
2491
2780
 
2492
2781
  // One on_client binding (issue #95): its outside guard, preventDefault rule,
2493
2782
  // confirm gate, then its ops — every flag read from the binding itself.
2494
- #runBinding({ ops, confirm, confirmWhen, outside, window: windowBound }, event, trigger) {
2783
+ #runBinding({ ops, confirm, confirmWhen, outside, window: windowBound, shortcut }, event, trigger) {
2495
2784
  // Outside guard FIRST — identical semantics to dispatch() (issue #80): an
2496
2785
  // outside: trigger is a COMPLETE no-op for events inside this root, before
2497
2786
  // preventDefault and before any op runs.
@@ -2503,7 +2792,8 @@ export default class extends Controller {
2503
2792
  // would kill native clicks site-wide (issue #80 rationale). Runs BEFORE the
2504
2793
  // (possibly async) confirm gate below — a native default can't wait for a
2505
2794
  // pending dialog (same ordering as dispatch()).
2506
- if (!windowBound) event.preventDefault()
2795
+ // A shortcut (issue #357) is prevented: it is one chord the page claims.
2796
+ if (shortcut != null || !windowBound) event.preventDefault()
2507
2797
 
2508
2798
  // Resolve the effective confirm message — static string, or the conditional
2509
2799
  // Hash form (issue #179) evaluated over collected fields (a promise of it
@@ -3044,6 +3334,12 @@ export default class extends Controller {
3044
3334
  // already gets the action's HTTP response. Harmless without pgbus.
3045
3335
  const connectionId = this.#connectionId()
3046
3336
  if (connectionId) headers["X-Pgbus-Connection"] = connectionId
3337
+ // A presence root's own connection (issue #358): the row its presence
3338
+ // writes land on, whatever source comes first on the page.
3339
+ const presence =
3340
+ presenceNeeded(this.element) &&
3341
+ document.getElementById(`reactive-presence-src-${this.element.id}`)?.getAttribute("connection-id")
3342
+ if (presence) headers["X-Reactive-Presence"] = presence
3047
3343
 
3048
3344
  // ONLY `fetch` itself is the network boundary — offline, DNS, a reset
3049
3345
  // connection. Everything below this inner try (reading the body,
@@ -356,6 +356,11 @@ module Phlex
356
356
  raise ArgumentError, "#{self}: reactive_dormant takes true or false, got #{dormant.inspect}"
357
357
  end
358
358
 
359
+ # Issue #358: a dormant root never connects, so it could never join.
360
+ # A ClientBindings-only class has no presence declaration to refuse.
361
+ refuse_dormant_presence! if dormant && respond_to?(:reactive_presence_declaration) &&
362
+ reactive_presence_declaration
363
+
359
364
  Phlex::Reactive::Dormant.reject_visible_lazy!(self) if dormant
360
365
 
361
366
  Registry.write_scalar(self, :dormant, dormant)