phlex-reactive 0.14.4 → 0.15.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +70 -0
- data/README.md +191 -1
- data/app/controllers/phlex/reactive/actions_controller.rb +28 -18
- data/app/javascript/phlex/reactive/core.js +5 -5
- data/app/javascript/phlex/reactive/core.min.js +3 -3
- data/app/javascript/phlex/reactive/core.min.js.map +4 -4
- data/app/javascript/phlex/reactive/early.js +28 -9
- data/app/javascript/phlex/reactive/early.min.js +2 -2
- data/app/javascript/phlex/reactive/early.min.js.map +3 -3
- data/app/javascript/phlex/reactive/features/bindings.js +125 -140
- data/app/javascript/phlex/reactive/features/bindings.min.js +2 -2
- data/app/javascript/phlex/reactive/features/bindings.min.js.map +3 -3
- data/app/javascript/phlex/reactive/features/form.js +63 -41
- data/app/javascript/phlex/reactive/features/form.min.js +2 -2
- data/app/javascript/phlex/reactive/features/form.min.js.map +3 -3
- data/app/javascript/phlex/reactive/features/presence.js +205 -0
- data/app/javascript/phlex/reactive/features/presence.min.js +4 -0
- data/app/javascript/phlex/reactive/features/presence.min.js.map +10 -0
- data/app/javascript/phlex/reactive/inspect.js +5 -1
- data/app/javascript/phlex/reactive/inspect.min.js +2 -2
- data/app/javascript/phlex/reactive/inspect.min.js.map +3 -3
- data/app/javascript/phlex/reactive/reactive_controller.js +3 -1
- data/app/javascript/phlex/reactive/reactive_controller.min.js +3 -3
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +8 -7
- data/app/javascript/phlex/reactive/runtime.js +175 -14
- data/lib/phlex/reactive/component/dsl.rb +5 -0
- data/lib/phlex/reactive/component/helpers.rb +79 -8
- data/lib/phlex/reactive/component/presence_declaration.rb +118 -0
- data/lib/phlex/reactive/component/registry.rb +3 -1
- data/lib/phlex/reactive/component.rb +1 -0
- data/lib/phlex/reactive/defer.rb +5 -0
- data/lib/phlex/reactive/engine.rb +9 -4
- data/lib/phlex/reactive/js.rb +24 -0
- data/lib/phlex/reactive/presence/component_methods.rb +130 -0
- data/lib/phlex/reactive/presence/roster.rb +90 -0
- data/lib/phlex/reactive/presence/transport.rb +154 -0
- data/lib/phlex/reactive/presence.rb +162 -0
- data/lib/phlex/reactive/presence_render_job.rb +26 -0
- data/lib/phlex/reactive/shortcut.rb +105 -0
- data/lib/phlex/reactive/streamable.rb +7 -3
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +10 -0
- 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,7 @@ 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)
|
|
202
204
|
// The opt-in entry sees every stream (it may hold one for a module the
|
|
203
205
|
// incoming content needs); with no entry hook, a loaded effects module
|
|
204
206
|
// wraps the stream directly — in the default entry, always.
|
|
@@ -210,6 +212,39 @@ export function registerReactiveStreamRender() {
|
|
|
210
212
|
// The names this registration had while it was two listeners.
|
|
211
213
|
export { registerReactiveStreamRender as registerReactiveDismiss, registerReactiveStreamRender as registerReactiveEffects }
|
|
212
214
|
|
|
215
|
+
// --- A stream into a connected root (issue #356) ---
|
|
216
|
+
// A morph of a root makes it re-read its feature markers (#boundRootMorph); a
|
|
217
|
+
// stream that renders INTO it — append, prepend or update of the root or a
|
|
218
|
+
// child, before, after or replace of a child — fires neither a lifecycle nor
|
|
219
|
+
// a morph of the root, so a marker it brings was never connected. The roots
|
|
220
|
+
// a stream renders into are resolved here, before anything (effects, the opt-in
|
|
221
|
+
// entry's hold) wraps Turbo's render; once that render has run, each one still
|
|
222
|
+
// connected is told (STREAMED, not bubbling: an outer root's content is not
|
|
223
|
+
// its own). The root re-runs its connected features' seeds and connects what
|
|
224
|
+
// the new content needs, in both entries.
|
|
225
|
+
const STREAMED = "reactive:streamed"
|
|
226
|
+
|
|
227
|
+
function streamIntoRoots(event) {
|
|
228
|
+
const detail = event.detail
|
|
229
|
+
const render = detail?.render
|
|
230
|
+
const stream = detail?.newStream ?? event.target
|
|
231
|
+
const action = stream?.getAttribute?.("action")
|
|
232
|
+
const into = /^(append|prepend|update)$/.test(action)
|
|
233
|
+
if (typeof render !== "function" || !(into || /^(before|after|replace)$/.test(action))) return
|
|
234
|
+
const id = stream.getAttribute("target")
|
|
235
|
+
const selector = stream.getAttribute("targets")
|
|
236
|
+
const roots = new Set()
|
|
237
|
+
for (const target of id ? [document.getElementById(id)] : selector ? document.querySelectorAll(selector) : []) {
|
|
238
|
+
const root = (into ? target : target?.parentElement)?.closest?.('[data-controller~="reactive"]')
|
|
239
|
+
if (root) roots.add(root)
|
|
240
|
+
}
|
|
241
|
+
if (roots.size === 0) return
|
|
242
|
+
detail.render = async (el) => {
|
|
243
|
+
await render(el)
|
|
244
|
+
for (const root of roots) if (root.isConnected) root.dispatchEvent(new Event(STREAMED))
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
213
248
|
// For the opt-in entry: what to do with a stream before its render — hold it
|
|
214
249
|
// for a module the incoming content needs. `handle(event, effects)` gets the
|
|
215
250
|
// loaded effects module (or undefined) and wraps the stream with it itself.
|
|
@@ -237,6 +272,66 @@ export function __resetReactiveStreamRenderForTest() {
|
|
|
237
272
|
// for the attribute AND the property. Nothing else
|
|
238
273
|
// (a root's token, a field's class) is held.
|
|
239
274
|
// data-reactive-morph-value on a field lets the morph write it while focused.
|
|
275
|
+
// --- An engine without contenteditable="plaintext-only" (issue #355) ---
|
|
276
|
+
// reactive_editable renders contenteditable="plaintext-only": the browser
|
|
277
|
+
// itself then pastes plain text and inserts "\n" for Enter. An engine without
|
|
278
|
+
// it reads the value as INVALID — the element is not editable, cannot even be
|
|
279
|
+
// focused — so there every reactive editable is switched to "true" (and the
|
|
280
|
+
// bindings feature does the plain-text paste itself). However it reaches the
|
|
281
|
+
// page: present at load, appended by a stream into a root that is already
|
|
282
|
+
// connected, loaded in a frame, written back by a morph of an unfocused
|
|
283
|
+
// editable, or made reactive by a morph. One observer, only in such an engine; elsewhere the probe is all
|
|
284
|
+
// that runs. Detected once per page.
|
|
285
|
+
let plaintextOnly
|
|
286
|
+
let plaintextObserver = null
|
|
287
|
+
const PLAINTEXT_EDITABLE = "[data-reactive-editable][contenteditable=plaintext-only]"
|
|
288
|
+
|
|
289
|
+
function plaintextSupported() {
|
|
290
|
+
if (plaintextOnly === undefined) {
|
|
291
|
+
try {
|
|
292
|
+
const probe = document.createElement("div")
|
|
293
|
+
probe.contentEditable = "plaintext-only"
|
|
294
|
+
plaintextOnly = probe.contentEditable === "plaintext-only"
|
|
295
|
+
} catch {
|
|
296
|
+
plaintextOnly = false
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return plaintextOnly
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function makeEditable(el) {
|
|
303
|
+
if (el.matches?.(PLAINTEXT_EDITABLE)) el.setAttribute("contenteditable", "true")
|
|
304
|
+
el.querySelectorAll?.(PLAINTEXT_EDITABLE).forEach(makeEditable)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function registerReactivePlaintext() {
|
|
308
|
+
// (No element factory, no page to observe: never probe a stub document.)
|
|
309
|
+
if (plaintextObserver || typeof document === "undefined" || typeof document.createElement !== "function") return
|
|
310
|
+
if (plaintextSupported()) return
|
|
311
|
+
makeEditable(document)
|
|
312
|
+
plaintextObserver = new MutationObserver((records) => {
|
|
313
|
+
for (const record of records) {
|
|
314
|
+
if (record.type === "attributes") makeEditable(record.target)
|
|
315
|
+
else record.addedNodes.forEach(makeEditable)
|
|
316
|
+
}
|
|
317
|
+
})
|
|
318
|
+
plaintextObserver.observe(document.documentElement, {
|
|
319
|
+
childList: true,
|
|
320
|
+
subtree: true,
|
|
321
|
+
attributes: true,
|
|
322
|
+
// (a morph can also make an existing plaintext-only element reactive)
|
|
323
|
+
attributeFilter: ["contenteditable", "data-reactive-editable"],
|
|
324
|
+
})
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// For tests: what the probe answers (undefined probes again), with no
|
|
328
|
+
// observer installed.
|
|
329
|
+
export function __setReactivePlaintextOnlyForTest(value) {
|
|
330
|
+
plaintextObserver?.disconnect()
|
|
331
|
+
plaintextObserver = null
|
|
332
|
+
plaintextOnly = value
|
|
333
|
+
}
|
|
334
|
+
|
|
240
335
|
let morphFocusRegistered = false
|
|
241
336
|
|
|
242
337
|
//
|
|
@@ -244,8 +339,8 @@ let morphFocusRegistered = false
|
|
|
244
339
|
// CHILDREN: a focused one keeps them (text and caret) while every attribute
|
|
245
340
|
// still morphs — copied here from the render, then the element's morph is
|
|
246
341
|
// cancelled (cancelling is all-or-nothing in Idiomorph). Its contenteditable
|
|
247
|
-
// stays as it is: the
|
|
248
|
-
// plaintext-only. Every morph of an editable, held or not, records the
|
|
342
|
+
// stays as it is: the runtime switches it to "true" in an engine without
|
|
343
|
+
// plaintext-only (registerReactivePlaintext, #355). Every morph of an editable, held or not, records the
|
|
249
344
|
// render's text as its SERVER TEXT (the Escape revert, the dirty baseline).
|
|
250
345
|
function holdsTypedValue(el) {
|
|
251
346
|
return (
|
|
@@ -486,6 +581,7 @@ export function registerReactiveActions() {
|
|
|
486
581
|
registerReactiveDefer()
|
|
487
582
|
registerReactiveStreamRender()
|
|
488
583
|
registerReactiveMorphFocus()
|
|
584
|
+
registerReactivePlaintext()
|
|
489
585
|
registerReactiveOffline()
|
|
490
586
|
registerReactiveDev()
|
|
491
587
|
}
|
|
@@ -766,6 +862,13 @@ const CLIENT_OPS = Object.freeze({
|
|
|
766
862
|
}
|
|
767
863
|
},
|
|
768
864
|
|
|
865
|
+
// Write this tab's presence state (issue #358) through the root's
|
|
866
|
+
// presence_set action. The feature warns outside a reactive_presence root.
|
|
867
|
+
presence: (el, args) =>
|
|
868
|
+
__SPLIT__
|
|
869
|
+
? withFeature("presence", (presence) => presence.set(el, args.state))
|
|
870
|
+
: presenceFeature.set(el, args.state),
|
|
871
|
+
|
|
769
872
|
// Dispatch a bubbling CustomEvent (issue #96). RAW element.dispatchEvent — the
|
|
770
873
|
// controller SHADOWS Stimulus's this.dispatch helper, so it must not be used.
|
|
771
874
|
dispatch: (el, args) => {
|
|
@@ -963,6 +1066,23 @@ function keyFilterMatches(filter, event, keyMappings) {
|
|
|
963
1066
|
return typeof key === "string" && key.toLowerCase() === event.key.toLowerCase()
|
|
964
1067
|
}
|
|
965
1068
|
|
|
1069
|
+
// Issue #357: a shortcut: ("mod+z", or { keys, inEditable }) is a window
|
|
1070
|
+
// keydown whose `mod` is meta on Apple platforms and ctrl elsewhere. Ruby
|
|
1071
|
+
// renders a meta twin and a ctrl twin; Stimulus filters the key, and this
|
|
1072
|
+
// decides whether the twin that fired is this platform's and whether the
|
|
1073
|
+
// target is one the user is typing in. Either miss is a complete no-op.
|
|
1074
|
+
// (Stimulus typecasts a digit shortcut, "1", to a number: hence String().)
|
|
1075
|
+
const shortcutKeys = (spec) => (spec && typeof spec === "object" ? spec.keys : String(spec))
|
|
1076
|
+
// A shortcut record keeps a bare `on: "keydown"`, so bindingMatches lets every
|
|
1077
|
+
// keydown through and this check, per record, picks the chord that fired.
|
|
1078
|
+
function shortcutApplies(spec, event, keyMappings) {
|
|
1079
|
+
const target = event.target
|
|
1080
|
+
if (spec?.inEditable !== true && (target?.isContentEditable || /^(input|textarea|select)$/.test(target?.localName)))
|
|
1081
|
+
return false
|
|
1082
|
+
const mod = /[Mm]ac|iP/.test(navigator.userAgentData?.platform ?? navigator.platform) ? "meta" : "ctrl"
|
|
1083
|
+
return keyFilterMatches(shortcutKeys(spec).replace(/^mod\+/, `${mod}+`), event, keyMappings)
|
|
1084
|
+
}
|
|
1085
|
+
|
|
966
1086
|
// Parse data-reactive-ops-param into binding records. Stimulus typecasts a
|
|
967
1087
|
// lone record to an object; several records stay a string (JSON.parse fails on
|
|
968
1088
|
// the join) and are split here. A LEGACY [[op, args]] list (array or JSON
|
|
@@ -1283,6 +1403,11 @@ function sortableNeeded(root) {
|
|
|
1283
1403
|
return Boolean(root.matches?.(lists) || root.querySelector?.(lists))
|
|
1284
1404
|
}
|
|
1285
1405
|
|
|
1406
|
+
// Presence (issue #358): the root is a reactive_presence roster.
|
|
1407
|
+
function presenceNeeded(root) {
|
|
1408
|
+
return root.getAttribute?.("data-reactive-presence") != null
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1286
1411
|
const PRODUCTION_FEATURES = [
|
|
1287
1412
|
// (Its `waiting` hook is the opt-in entry's: core.js.) The draft restore
|
|
1288
1413
|
// writes what a request reads, so it gates.
|
|
@@ -1295,6 +1420,9 @@ const PRODUCTION_FEATURES = [
|
|
|
1295
1420
|
["compute", [computeNeeded, null, null, true]],
|
|
1296
1421
|
// Nothing it does at connect is read by a request: it never gates.
|
|
1297
1422
|
["sortable", [sortableNeeded, null]],
|
|
1423
|
+
// Its announce is a request of its own; nothing it does at connect is read
|
|
1424
|
+
// by another request: it never gates.
|
|
1425
|
+
["presence", [presenceNeeded, null]],
|
|
1298
1426
|
[
|
|
1299
1427
|
// Stream effects and dismissing flashes are document-level: the module
|
|
1300
1428
|
// has no connect. A root that DECLARES an effect only gets it fetched
|
|
@@ -1458,7 +1586,7 @@ function featureTimeoutMs() {
|
|
|
1458
1586
|
export function reactiveFeatureNames() {
|
|
1459
1587
|
return __SPLIT__
|
|
1460
1588
|
? [...FEATURES.keys()]
|
|
1461
|
-
: ["persist", "defer", "form", "bindings", "compute", "sortable", "effects", "hints", "devtools"]
|
|
1589
|
+
: ["persist", "defer", "form", "bindings", "compute", "sortable", "presence", "effects", "hints", "devtools"]
|
|
1462
1590
|
}
|
|
1463
1591
|
|
|
1464
1592
|
export function __setReactiveFeatureForTest(name, needs, load, waiting, gates) {
|
|
@@ -1481,6 +1609,7 @@ export function __loadReactiveFeatureForTest(name) {
|
|
|
1481
1609
|
bindings: bindingsFeature,
|
|
1482
1610
|
compute: computeFeature,
|
|
1483
1611
|
sortable: sortableFeature,
|
|
1612
|
+
presence: presenceFeature,
|
|
1484
1613
|
effects: effectsFeature,
|
|
1485
1614
|
hints: hintsFeature,
|
|
1486
1615
|
devtools: devtoolsFeature,
|
|
@@ -1534,6 +1663,7 @@ const FORM = 4
|
|
|
1534
1663
|
const BINDINGS = 8
|
|
1535
1664
|
const COMPUTE = 16
|
|
1536
1665
|
const SORTABLE = 32
|
|
1666
|
+
const PRESENCE = 64
|
|
1537
1667
|
|
|
1538
1668
|
// The default entry's features are part of this file: install them now, as
|
|
1539
1669
|
// the opt-in entry does when one arrives.
|
|
@@ -1755,7 +1885,7 @@ export default class extends Controller {
|
|
|
1755
1885
|
#featureTurns = new Map() // name -> settles once that feature has had its turn to connect
|
|
1756
1886
|
#featureEpoch = 0
|
|
1757
1887
|
#featureHandle // the `core` handle features receive, built on first use
|
|
1758
|
-
// The default entry (issue #305): one bit per feature, PERSIST …
|
|
1888
|
+
// The default entry (issue #305): one bit per feature, PERSIST … PRESENCE —
|
|
1759
1889
|
// asked for (its marker read true) and connected on this connection.
|
|
1760
1890
|
#featuresAsked = 0
|
|
1761
1891
|
#featuresOn = 0
|
|
@@ -1802,15 +1932,20 @@ export default class extends Controller {
|
|
|
1802
1932
|
// The same listener hears an editable being focused (issue #351): a stream
|
|
1803
1933
|
// can append a root's first reactive_editable without morphing the root,
|
|
1804
1934
|
// and its keys and paste need the bindings feature before anything is
|
|
1805
|
-
// typed into it.
|
|
1935
|
+
// typed into it. And it hears a stream that rendered into the root
|
|
1936
|
+
// (STREAMED, issue #356): the features already connected re-run their
|
|
1937
|
+
// seeds first (a morph's own listeners do that for a morph), then what the
|
|
1938
|
+
// new content needs connects.
|
|
1806
1939
|
if (this.element.getAttribute?.("data-reactive-token-value") != null || deferNeeded(this.element)) {
|
|
1807
1940
|
this.#boundRootMorph = (event) => {
|
|
1808
1941
|
if (event.type === "focusin" ? !event.target.hasAttribute?.("data-reactive-editable") : event.target !== this.element) return
|
|
1942
|
+
if (event.type === STREAMED) this.#reseed()
|
|
1809
1943
|
if (__SPLIT__) this.#loadFeatures(true)
|
|
1810
1944
|
else this.#connectFeaturesNow(true)
|
|
1811
1945
|
}
|
|
1812
1946
|
this.element.addEventListener?.("turbo:morph-element", this.#boundRootMorph)
|
|
1813
1947
|
this.element.addEventListener?.("focusin", this.#boundRootMorph)
|
|
1948
|
+
this.element.addEventListener?.(STREAMED, this.#boundRootMorph)
|
|
1814
1949
|
}
|
|
1815
1950
|
|
|
1816
1951
|
// Feature modules (issue #275): connect the ones that are here and START
|
|
@@ -1849,6 +1984,7 @@ export default class extends Controller {
|
|
|
1849
1984
|
this.#connectFeatureNow(BINDINGS, "bindings", bindingsNeeded, (core) => bindingsFeature.connect(this, core, morphed))
|
|
1850
1985
|
this.#connectFeatureNow(COMPUTE, "compute", computeNeeded, (core) => computeFeature.connect(this, core, morphed))
|
|
1851
1986
|
this.#connectFeatureNow(SORTABLE, "sortable", sortableNeeded, (core) => sortableFeature.connect(this, core))
|
|
1987
|
+
this.#connectFeatureNow(PRESENCE, "presence", presenceNeeded, (core) => presenceFeature.connect(this, core))
|
|
1852
1988
|
}
|
|
1853
1989
|
}
|
|
1854
1990
|
|
|
@@ -1886,6 +2022,7 @@ export default class extends Controller {
|
|
|
1886
2022
|
if (on & BINDINGS) this.#disconnectFeatureNow("bindings", () => bindingsFeature.disconnect(this, core))
|
|
1887
2023
|
if (on & COMPUTE) this.#disconnectFeatureNow("compute", () => computeFeature.disconnect(this, core))
|
|
1888
2024
|
if (on & SORTABLE) this.#disconnectFeatureNow("sortable", () => sortableFeature.disconnect(this, core))
|
|
2025
|
+
if (on & PRESENCE) this.#disconnectFeatureNow("presence", () => presenceFeature.disconnect(this, core))
|
|
1889
2026
|
}
|
|
1890
2027
|
}
|
|
1891
2028
|
|
|
@@ -2074,7 +2211,9 @@ export default class extends Controller {
|
|
|
2074
2211
|
),
|
|
2075
2212
|
listnavOptions: (event) => this.#listnavOptions(event),
|
|
2076
2213
|
collectFields: () => this.#collectFields(),
|
|
2077
|
-
// A reactive_editable's value and its last server-rendered text (#351)
|
|
2214
|
+
// A reactive_editable's value and its last server-rendered text (#351),
|
|
2215
|
+
// and whether the engine has contenteditable="plaintext-only" (#355).
|
|
2216
|
+
plain: plaintextSupported,
|
|
2078
2217
|
text: editableValue,
|
|
2079
2218
|
served: serverText,
|
|
2080
2219
|
// The confirm gate, as dispatch() runs it: the resolver inside the chain
|
|
@@ -2089,15 +2228,17 @@ export default class extends Controller {
|
|
|
2089
2228
|
|
|
2090
2229
|
// Re-run the connect-time seeds that read field values, in connect() order
|
|
2091
2230
|
// — for a feature that changed those values after connect() ran (the draft
|
|
2092
|
-
// restore)
|
|
2093
|
-
//
|
|
2231
|
+
// restore), or content a stream rendered into the root (issue #356). Each is
|
|
2232
|
+
// the same re-sync its feature runs after a morph; a root that did not opt
|
|
2233
|
+
// into one skips it. on-complete re-ARMS without firing. Form and bindings
|
|
2234
|
+
// also wire a binding whose gate reads true only now (a stream's content).
|
|
2094
2235
|
#reseed() {
|
|
2095
2236
|
if (!__SPLIT__) {
|
|
2096
|
-
if (this.#featuresOn & FORM) formFeature.
|
|
2237
|
+
if (this.#featuresOn & FORM) formFeature.reseed(this)
|
|
2097
2238
|
if (this.#featuresOn & BINDINGS) bindingsFeature.reseed(this)
|
|
2098
2239
|
if (this.#featuresOn & COMPUTE) computeFeature.seed(this)
|
|
2099
2240
|
} else {
|
|
2100
|
-
this.#features.get("form")?.
|
|
2241
|
+
this.#features.get("form")?.reseed(this)
|
|
2101
2242
|
this.#features.get("bindings")?.reseed(this)
|
|
2102
2243
|
this.#features.get("compute")?.seed(this)
|
|
2103
2244
|
}
|
|
@@ -2270,6 +2411,7 @@ export default class extends Controller {
|
|
|
2270
2411
|
if (this.#boundRootMorph) {
|
|
2271
2412
|
this.element.removeEventListener?.("turbo:morph-element", this.#boundRootMorph)
|
|
2272
2413
|
this.element.removeEventListener?.("focusin", this.#boundRootMorph)
|
|
2414
|
+
this.element.removeEventListener?.(STREAMED, this.#boundRootMorph)
|
|
2273
2415
|
}
|
|
2274
2416
|
// Early triggers (issue #273): a disconnected root records again.
|
|
2275
2417
|
earlyState().connected.delete(this.element)
|
|
@@ -2296,9 +2438,13 @@ export default class extends Controller {
|
|
|
2296
2438
|
// modifier params (issue #80). The client decides preventDefault behavior
|
|
2297
2439
|
// from event.params — set by the Ruby on() — never by sniffing the
|
|
2298
2440
|
// Stimulus descriptor.
|
|
2299
|
-
const { action, params, debounce, throttle, confirm, confirmWhen, outside, window: windowBound, optimistic } =
|
|
2441
|
+
const { action, params, debounce, throttle, confirm, confirmWhen, outside, window: windowBound, optimistic, shortcut } =
|
|
2300
2442
|
event.params
|
|
2301
2443
|
if (!action) return
|
|
2444
|
+
// A shortcut (issue #357) that is the other platform's twin, or pressed
|
|
2445
|
+
// in a field the user is typing in, is a complete no-op: the native
|
|
2446
|
+
// default (text undo) proceeds and nothing is announced.
|
|
2447
|
+
if (shortcut != null && !shortcutApplies(shortcut, event, this.application?.schema?.keyMappings)) return
|
|
2302
2448
|
|
|
2303
2449
|
// The pending-state hint (issue #181): data-reactive-busy-param. During a
|
|
2304
2450
|
// deploy overlap a page rendered by the PREVIOUS gem still emits the old
|
|
@@ -2345,7 +2491,11 @@ export default class extends Controller {
|
|
|
2345
2491
|
// (which has no form-navigation default to lose) skips it and flips now, and
|
|
2346
2492
|
// the failure revert snaps it back. A `change`-bound trigger is unaffected —
|
|
2347
2493
|
// `change` isn't cancelable, so preventDefault was already a no-op there.
|
|
2348
|
-
|
|
2494
|
+
//
|
|
2495
|
+
// A shortcut (issue #357) IS prevented: its key filter has already narrowed
|
|
2496
|
+
// the window binding to one chord the page claims (mod+s must not also open
|
|
2497
|
+
// the browser's save dialog).
|
|
2498
|
+
if ((shortcut != null || !windowBound) && !this.#keepsNativeToggle(optimistic, target)) event.preventDefault()
|
|
2349
2499
|
|
|
2350
2500
|
// A reactive_lazy(on:) shell's trigger (issue #276) goes through the one
|
|
2351
2501
|
// materialize entry point, which dedupes it against the observer, the
|
|
@@ -2484,6 +2634,10 @@ export default class extends Controller {
|
|
|
2484
2634
|
// A detail miss (issue #346) is a complete no-op — checked before the
|
|
2485
2635
|
// once record is spent, preventDefault and the confirm gate.
|
|
2486
2636
|
if (!detailMatches(record, event)) continue
|
|
2637
|
+
// So is a shortcut that is not this platform's twin or fired in an
|
|
2638
|
+
// editable target (issue #357).
|
|
2639
|
+
if (record.shortcut != null && !shortcutApplies(record.shortcut, event, this.application?.schema?.keyMappings))
|
|
2640
|
+
continue
|
|
2487
2641
|
if (onceBindingSpent(this, event, record)) continue
|
|
2488
2642
|
this.#runBinding(record.legacy ? { ...params, ops: record.ops } : record, event, trigger)
|
|
2489
2643
|
}
|
|
@@ -2491,7 +2645,7 @@ export default class extends Controller {
|
|
|
2491
2645
|
|
|
2492
2646
|
// One on_client binding (issue #95): its outside guard, preventDefault rule,
|
|
2493
2647
|
// confirm gate, then its ops — every flag read from the binding itself.
|
|
2494
|
-
#runBinding({ ops, confirm, confirmWhen, outside, window: windowBound }, event, trigger) {
|
|
2648
|
+
#runBinding({ ops, confirm, confirmWhen, outside, window: windowBound, shortcut }, event, trigger) {
|
|
2495
2649
|
// Outside guard FIRST — identical semantics to dispatch() (issue #80): an
|
|
2496
2650
|
// outside: trigger is a COMPLETE no-op for events inside this root, before
|
|
2497
2651
|
// preventDefault and before any op runs.
|
|
@@ -2503,7 +2657,8 @@ export default class extends Controller {
|
|
|
2503
2657
|
// would kill native clicks site-wide (issue #80 rationale). Runs BEFORE the
|
|
2504
2658
|
// (possibly async) confirm gate below — a native default can't wait for a
|
|
2505
2659
|
// pending dialog (same ordering as dispatch()).
|
|
2506
|
-
|
|
2660
|
+
// A shortcut (issue #357) is prevented: it is one chord the page claims.
|
|
2661
|
+
if (shortcut != null || !windowBound) event.preventDefault()
|
|
2507
2662
|
|
|
2508
2663
|
// Resolve the effective confirm message — static string, or the conditional
|
|
2509
2664
|
// Hash form (issue #179) evaluated over collected fields (a promise of it
|
|
@@ -3044,6 +3199,12 @@ export default class extends Controller {
|
|
|
3044
3199
|
// already gets the action's HTTP response. Harmless without pgbus.
|
|
3045
3200
|
const connectionId = this.#connectionId()
|
|
3046
3201
|
if (connectionId) headers["X-Pgbus-Connection"] = connectionId
|
|
3202
|
+
// A presence root's own connection (issue #358): the row its presence
|
|
3203
|
+
// writes land on, whatever source comes first on the page.
|
|
3204
|
+
const presence =
|
|
3205
|
+
presenceNeeded(this.element) &&
|
|
3206
|
+
document.getElementById(`reactive-presence-src-${this.element.id}`)?.getAttribute("connection-id")
|
|
3207
|
+
if (presence) headers["X-Reactive-Presence"] = presence
|
|
3047
3208
|
|
|
3048
3209
|
// ONLY `fetch` itself is the network boundary — offline, DNS, a reset
|
|
3049
3210
|
// 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)
|
|
@@ -127,6 +127,10 @@ module Phlex
|
|
|
127
127
|
(on_complete = self.class.reactive_on_complete_attr)
|
|
128
128
|
data[:reactive_on_complete] = on_complete
|
|
129
129
|
end
|
|
130
|
+
# Presence (issue #358): the signed src of the roster's presence
|
|
131
|
+
# stream, from which the client mints its subscription. Only a class
|
|
132
|
+
# that declares reactive_presence has the method.
|
|
133
|
+
data.merge!(reactive_presence_data) if respond_to?(:reactive_presence_data)
|
|
130
134
|
{ data: }
|
|
131
135
|
end
|
|
132
136
|
|
|
@@ -280,6 +284,17 @@ module Phlex
|
|
|
280
284
|
# (trailing-edge) — passing both raises ArgumentError.
|
|
281
285
|
# div(**mix(reactive_root, on(:track, event: "scroll", window: true, throttle: 250)))
|
|
282
286
|
#
|
|
287
|
+
# `shortcut:` (issue #357, also RESERVED) binds a page-level keyboard
|
|
288
|
+
# chord: a window keydown whose `mod` is meta on Apple platforms and
|
|
289
|
+
# ctrl elsewhere (rendered as a meta twin and a ctrl twin; the client
|
|
290
|
+
# runs the one for its platform). Unlike a plain window binding, the
|
|
291
|
+
# client prevents the chord's native default, and it skips the chord
|
|
292
|
+
# while focus is in an input, textarea, select or contenteditable
|
|
293
|
+
# (native text undo keeps working) unless the Hash form opts in. It
|
|
294
|
+
# decides event:, window: and outside:, so passing one raises.
|
|
295
|
+
# span(**on(:undo, shortcut: "mod+z"))
|
|
296
|
+
# span(**on(:close, shortcut: { keys: "esc", in_editable: true }))
|
|
297
|
+
#
|
|
283
298
|
# `optimistic:` (issue #98) — a small, ALWAYS-REVERSIBLE vocabulary of
|
|
284
299
|
# COSMETIC hints the client applies the instant the trigger fires and
|
|
285
300
|
# REVERTS if the round trip fails, so a click/toggle gives instant feedback
|
|
@@ -324,9 +339,9 @@ module Phlex
|
|
|
324
339
|
# busy_on for scoped indicators.
|
|
325
340
|
# button(**on(:save, busy: "Saving…")) { "Save" }
|
|
326
341
|
# button(**on(:destroy, confirm: "Sure?", busy: { add_class: "opacity-50" })) { "Delete" }
|
|
327
|
-
def on(action_name, event:
|
|
342
|
+
def on(action_name, event: nil, debounce: nil, throttle: nil, confirm: nil,
|
|
328
343
|
window: false, once: false, outside: false, optimistic: nil, busy: nil,
|
|
329
|
-
**params)
|
|
344
|
+
shortcut: nil, **params)
|
|
330
345
|
reject_removed_on_kwargs!(action_name, params)
|
|
331
346
|
# A typo'd or forgotten action renders fine and only surfaces as an
|
|
332
347
|
# opaque 403 at CLICK time (the endpoint's default-deny). Under
|
|
@@ -362,8 +377,11 @@ module Phlex
|
|
|
362
377
|
"exclusive (debounce is trailing-edge, throttle is leading-edge); pick one"
|
|
363
378
|
end
|
|
364
379
|
|
|
365
|
-
|
|
366
|
-
|
|
380
|
+
# Guarded here, not inside: building the caller label allocates (on() is a hot path).
|
|
381
|
+
shortcut = shortcut_for!("on(#{action_name.inspect})", shortcut, event:, window:, outside:) unless shortcut.nil?
|
|
382
|
+
event ||= "click"
|
|
383
|
+
window_bound = window || outside || !shortcut.nil?
|
|
384
|
+
action = trigger_descriptors(event, shortcut, window_bound, "dispatch", once)
|
|
367
385
|
attrs = {
|
|
368
386
|
data: {
|
|
369
387
|
action:,
|
|
@@ -384,6 +402,9 @@ module Phlex
|
|
|
384
402
|
# The client decides preventDefault behavior from event.params (never by
|
|
385
403
|
# sniffing the descriptor), so EVERY window binding flags the param.
|
|
386
404
|
attrs[:data][:reactive_window_param] = "true" if window_bound
|
|
405
|
+
# Issue #357: the client reads the shortcut to pick the twin for its
|
|
406
|
+
# platform, skip editable targets and prevent the chord.
|
|
407
|
+
attrs[:data][:reactive_shortcut_param] = shortcut_param_attr(shortcut) if shortcut
|
|
387
408
|
# Force type="button" for click triggers so a bare button inside a <form>
|
|
388
409
|
# can't submit it — EXCEPT when checked: :keep is declared: that hint's
|
|
389
410
|
# whole point is to let a click-bound checkbox/radio flip natively, and a
|
|
@@ -406,7 +427,9 @@ module Phlex
|
|
|
406
427
|
# modifiers (#80): outside-click-to-close a dropdown is
|
|
407
428
|
# div(**mix(reactive_root, on_client(:click, js.hide("#menu"), outside: true)))
|
|
408
429
|
# Window-bound triggers are never preventDefault-ed by the client and skip
|
|
409
|
-
# the forced type="button".
|
|
430
|
+
# the forced type="button". `shortcut:` (issue #357) works as on()'s, on
|
|
431
|
+
# a bare :keydown only:
|
|
432
|
+
# div(**mix(reactive_root, on_client(:keydown, js.toggle("#palette"), shortcut: "mod+k")))
|
|
410
433
|
#
|
|
411
434
|
# Issue #271: each call emits ONE self-describing binding record —
|
|
412
435
|
# data-reactive-ops-param='{"on":"click","window":true,"outside":true,"ops":[…]}'
|
|
@@ -437,7 +460,8 @@ module Phlex
|
|
|
437
460
|
# Validation is loud: only a non-empty Phlex::Reactive::JS chain is
|
|
438
461
|
# accepted — a dead trigger should fail at render, not no-op in the
|
|
439
462
|
# browser.
|
|
440
|
-
def on_client(event, ops, window: false, once: false, outside: false, confirm: nil, detail: nil
|
|
463
|
+
def on_client(event, ops, window: false, once: false, outside: false, confirm: nil, detail: nil,
|
|
464
|
+
shortcut: nil)
|
|
441
465
|
unless ops.is_a?(Phlex::Reactive::JS)
|
|
442
466
|
raise ArgumentError,
|
|
443
467
|
"on_client expects a Phlex::Reactive::JS chain (e.g. js.toggle(\"#menu\")), " \
|
|
@@ -454,11 +478,15 @@ module Phlex
|
|
|
454
478
|
"submit event this trigger is bound to. Bind the submit op to another event " \
|
|
455
479
|
"(change/input), or gate it behind a reducer's $ops / reactive_on_complete."
|
|
456
480
|
end
|
|
457
|
-
|
|
481
|
+
shortcut = client_shortcut!(event, shortcut, window:, outside:)
|
|
482
|
+
window_bound = window || outside || !shortcut.nil?
|
|
458
483
|
record = client_binding_record(event, ops, window_bound:, outside:, once:, confirm:, detail:)
|
|
484
|
+
# The record's `on` stays the bare keydown: the client reads a key
|
|
485
|
+
# filter from `on`, and `mod` is not a key.
|
|
486
|
+
record["shortcut"] = shortcut.param if shortcut
|
|
459
487
|
attrs = {
|
|
460
488
|
data: {
|
|
461
|
-
action:
|
|
489
|
+
action: trigger_descriptors(event, shortcut, window_bound, "runOps", once),
|
|
462
490
|
# Space-free (issue #271): compact JSON has no structural
|
|
463
491
|
# whitespace, so every space is inside a string, where \u0020
|
|
464
492
|
# parses to the same string — and mix's space-join stays splittable.
|
|
@@ -1732,6 +1760,49 @@ module Phlex
|
|
|
1732
1760
|
"on(#{action_name.inspect}) #{key}: was removed in issue #181 — use #{REMOVED_ON_KWARGS[key]}"
|
|
1733
1761
|
end
|
|
1734
1762
|
|
|
1763
|
+
# Issue #357: parse on()'s shortcut: (nil when absent). A shortcut
|
|
1764
|
+
# decides the event, the window binding and the containment rule, so
|
|
1765
|
+
# an explicit event:/window:/outside: next to it is a mistake.
|
|
1766
|
+
def shortcut_for!(caller, shortcut, event:, window:, outside:)
|
|
1767
|
+
{ "event:" => event, "window:" => window, "outside:" => outside }.each do |name, value|
|
|
1768
|
+
next unless value
|
|
1769
|
+
|
|
1770
|
+
raise ArgumentError,
|
|
1771
|
+
"#{caller} got shortcut: with #{name} — a shortcut is always a window keydown " \
|
|
1772
|
+
"binding; drop #{name}"
|
|
1773
|
+
end
|
|
1774
|
+
Phlex::Reactive::Shortcut.parse(shortcut)
|
|
1775
|
+
end
|
|
1776
|
+
|
|
1777
|
+
# on_client's event is positional: with shortcut: it must be the bare
|
|
1778
|
+
# keydown (the key filter comes from the shortcut).
|
|
1779
|
+
def client_shortcut!(event, shortcut, window:, outside:)
|
|
1780
|
+
return if shortcut.nil?
|
|
1781
|
+
|
|
1782
|
+
unless event == "keydown"
|
|
1783
|
+
raise ArgumentError,
|
|
1784
|
+
"on_client(#{event.inspect}) got shortcut: — bind it to :keydown " \
|
|
1785
|
+
"(on_client(:keydown, ops, shortcut: \"mod+k\")); the key comes from the shortcut"
|
|
1786
|
+
end
|
|
1787
|
+
shortcut_for!("on_client(:keydown)", shortcut, event: nil, window:, outside:)
|
|
1788
|
+
end
|
|
1789
|
+
|
|
1790
|
+
# The data-action descriptors of an on()/on_client trigger: one, or a
|
|
1791
|
+
# shortcut's meta and ctrl twins (space-joined, as mix joins them).
|
|
1792
|
+
# The common, shortcut-less path is ONE interpolation (on() is a hot path).
|
|
1793
|
+
def trigger_descriptors(event, shortcut, window_bound, method, once)
|
|
1794
|
+
return "#{event}#{"@window" if window_bound}->reactive##{method}#{":once" if once}" unless shortcut
|
|
1795
|
+
|
|
1796
|
+
shortcut.filters.map { "keydown.#{it}@window->reactive##{method}#{":once" if once}" }.join(" ")
|
|
1797
|
+
end
|
|
1798
|
+
|
|
1799
|
+
# A Hash param is JSON (Stimulus's param reader parses it back into an
|
|
1800
|
+
# object); the String form stays a plain string.
|
|
1801
|
+
def shortcut_param_attr(shortcut)
|
|
1802
|
+
param = shortcut.param
|
|
1803
|
+
param.is_a?(Hash) ? param.to_json : param
|
|
1804
|
+
end
|
|
1805
|
+
|
|
1735
1806
|
# Normalize + validate a pending-state hint (issue #181), returning its
|
|
1736
1807
|
# JSON wire form. ONE vocabulary + ONE normalizer for both optimistic: (kind
|
|
1737
1808
|
# :optimistic, reverts on FAILURE) and busy: (kind :busy, reverts on
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
module Component
|
|
6
|
+
# The reactive_presence macro (issue #358). Not named `Presence`: this
|
|
7
|
+
# module is in every reactive component's constant lookup, where a bare
|
|
8
|
+
# noun would shadow a host app's kit component of that name (the #233
|
|
9
|
+
# rule). The behaviour it adds lives in Phlex::Reactive::Presence.
|
|
10
|
+
module PresenceDeclaration
|
|
11
|
+
extend ActiveSupport::Concern
|
|
12
|
+
|
|
13
|
+
# A presence name is the last part of the stream name, so it is one
|
|
14
|
+
# identifier: no colon (the stream-name separator), no spaces.
|
|
15
|
+
PRESENCE_NAME = /\A[a-z][a-z0-9_]*\z/
|
|
16
|
+
|
|
17
|
+
class_methods do
|
|
18
|
+
# Declare this component the roster of a presence stream:
|
|
19
|
+
# [record, :presence, name], or [*key, :presence, name] for a
|
|
20
|
+
# record-less component.
|
|
21
|
+
#
|
|
22
|
+
# reactive_record :board
|
|
23
|
+
# reactive_presence :viewers, state: { selected: :integer }, ttl: 45
|
|
24
|
+
#
|
|
25
|
+
# `state:` is each tab's ephemeral state, typed with the same table as
|
|
26
|
+
# action params; undeclared keys are dropped. `ttl:` is how many
|
|
27
|
+
# seconds a silent tab survives. Adds the presence_set action (what
|
|
28
|
+
# js.presence sends) and `presence` (the roster) to the component.
|
|
29
|
+
def reactive_presence(name, state: {}, ttl: Phlex::Reactive::Presence::DEFAULT_TTL, key: nil)
|
|
30
|
+
require_server_actions!(:reactive_presence)
|
|
31
|
+
declaration = build_presence_declaration(name, state, ttl, key)
|
|
32
|
+
refuse_dormant_presence! if reactive_dormant?
|
|
33
|
+
# The name's fit in pgbus's stream-name budget is configuration (the
|
|
34
|
+
# queue prefix), so it is refused here; the render re-checks it.
|
|
35
|
+
transport = Phlex::Reactive::Presence::Transport
|
|
36
|
+
if transport.wired? && !transport.fits?(declaration.name)
|
|
37
|
+
raise Phlex::Reactive::ConfigurationError, transport.over_budget_message(self, declaration.name)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The generated action. Exempt from verify_authorized: the signed
|
|
41
|
+
# token already proves the actor rendered this root, and the write
|
|
42
|
+
# is typed, default-deny, ephemeral and lands only on the actor's own
|
|
43
|
+
# row (keyed by its secret SSE connection id). A component that needs
|
|
44
|
+
# a check defines authorize_presence_state!(**state); the action runs
|
|
45
|
+
# it first.
|
|
46
|
+
action :presence_set, params: declaration.state
|
|
47
|
+
skip_verify_authorized :presence_set
|
|
48
|
+
Registry.write_scalar(self, :presence, declaration)
|
|
49
|
+
include Phlex::Reactive::Presence::ComponentMethods
|
|
50
|
+
|
|
51
|
+
# Class load runs before a database exists: the signatures alone gate it.
|
|
52
|
+
transport.register_ttl(declaration.name, ttl) if transport.wired?
|
|
53
|
+
declaration
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The Phlex::Reactive::Presence::Declaration, or nil.
|
|
57
|
+
def reactive_presence_declaration
|
|
58
|
+
Registry.resolve_scalar(self, :presence, :reactive_presence_declaration)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The presence stream name a roster of `record` subscribes to — what
|
|
62
|
+
# streams_authorize sees. For a strict per-record check at connect
|
|
63
|
+
# time: compare it with the key of each record the user may view.
|
|
64
|
+
# A record-less roster (key:) ignores `record`.
|
|
65
|
+
def reactive_presence_stream_name_for(record = nil)
|
|
66
|
+
declaration = reactive_presence_declaration
|
|
67
|
+
subject = declaration.key || [record]
|
|
68
|
+
Phlex::Reactive::Presence::Transport.stream_name([*subject, :presence, declaration.name])
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def build_presence_declaration(name, state, ttl, key)
|
|
74
|
+
name = name.to_s
|
|
75
|
+
unless PRESENCE_NAME.match?(name)
|
|
76
|
+
raise ArgumentError,
|
|
77
|
+
"#{self.name}: reactive_presence name must be a lowercase identifier (it ends the stream " \
|
|
78
|
+
"name), got #{name.inspect}"
|
|
79
|
+
end
|
|
80
|
+
unless ttl.is_a?(Integer) && ttl.positive?
|
|
81
|
+
raise ArgumentError, "#{self.name}: reactive_presence ttl: must be a positive Integer (seconds), " \
|
|
82
|
+
"got #{ttl.inspect}"
|
|
83
|
+
end
|
|
84
|
+
refuse_ttl_within_heartbeat!(ttl)
|
|
85
|
+
|
|
86
|
+
state = state.transform_keys(&:to_sym)
|
|
87
|
+
if state.key?(Phlex::Reactive::Presence::NOTE_KEY.to_sym)
|
|
88
|
+
raise ArgumentError,
|
|
89
|
+
"#{self.name}: reactive_presence state key #{Phlex::Reactive::Presence::NOTE_KEY} is reserved"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
Phlex::Reactive::Presence::Declaration.new(
|
|
93
|
+
name: name.to_sym, state: state.freeze, ttl:, key: key && Array(key).dup.freeze
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# A ttl at or below the heartbeat would expire a live tab between two
|
|
98
|
+
# beats: its row would vanish and come back on the next one.
|
|
99
|
+
def refuse_ttl_within_heartbeat!(ttl)
|
|
100
|
+
heartbeat = Phlex::Reactive::Presence::Transport.heartbeat_interval
|
|
101
|
+
return if ttl > heartbeat
|
|
102
|
+
|
|
103
|
+
raise ArgumentError,
|
|
104
|
+
"#{name}: reactive_presence ttl: #{ttl} is at or below the transport's heartbeat interval " \
|
|
105
|
+
"(#{heartbeat} s), so a live tab would expire between two beats. Use more than #{heartbeat} " \
|
|
106
|
+
"(two missed beats is a good floor)."
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def refuse_dormant_presence!
|
|
110
|
+
raise ArgumentError,
|
|
111
|
+
"#{name}: reactive_presence cannot be dormant — a dormant root never connects, so its tab " \
|
|
112
|
+
"would never join the stream. Drop reactive_dormant on this component."
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|