1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140 | ;; The view layer's infrastructure: the mount point, the ONE dispatch function,
;; the coalescing render loop, and the handle registry.
;;
;; FREE BUCKET (dev/docs/CLJS.md): nothing here is on the wire and nothing here
;; is persisted, so this file owes the protocol nothing. What it does owe is the
;; three rules the renderer decision came with.
;;
;; 1. THE RENDERER IS REPLICANT, and the API in use is four functions —
;; `replicant.dom/render`, `/unmount`, `/set-dispatch!`, plus
;; `replicant.string/render` in the tests — and two keywords,
;; `:replicant/key` and `:replicant/on-mount`. That small a surface is the
;; mitigation recorded against the one real argument the decision has to
;; live with (CLJS.md: "replicant is essentially one person's library").
;;
;; 2. REPLICANT DOES NOT SCHEDULE. `replicant.dom/render` reconciles inline,
;; which is the property that makes Phase 5b's near-bottom? → render → pin
;; `scrollTop` three lines in one tick. So the rAF dirty-flag loop ui.cljs
;; has always used is still the coalescer, and it lives here once instead of
;; once per screen — but `paint!` is the synchronous door out of it. A
;; scheduler you cannot bypass would split that measurement from its
;; fix-up, which is precisely what reagent and React 18 were rejected for.
;;
;; 3. NON-SERIALISABLE HANDLES NEVER ENTER VIEW DATA. A MediaStream, an
;; RTCPeerConnection, a DOM node: those are identity, not content. They live
;; in the registry below, the view carries only the id, and `on-handle`
;; looks the id up in a life-cycle hook. Two consequences, both load-bearing
;; for Phase 5c: the call screen stays snapshot-testable (it is the one
;; region you would otherwise have to give up on), and a keyed re-order can
;; never detach a live `srcObject`, because the vdom never held one. The
;; price is that a stream ARRIVING is a network event, not a user event, and
;; the view will not re-run by itself — `revision` is the integer to project
;; into the state so that it does.
;;
;; XSS IS STRUCTURAL from here on. Hiccup text goes through the DOM's
;; textContent, so there is no escape function left to forget to call, and the
;; hand-rolled `esc` dies screen by screen as Phase 5 converts them.
;; test/source-hygiene.test.mjs enforces both halves: `.-innerHTML` may be
;; assigned only in the not-yet-converted files it names, and `esc` may be
;; defined only there too. Both lists shrink to empty; neither may grow.
(ns sueta.app.view
(:require [replicant.dom :as rd]))
;; ---- the one dispatch ------------------------------------------------------
;;
;; Replicant's dispatch is global (`set-dispatch!` binds one var), so there is
;; exactly one of them for the whole app and it forwards to whichever screen is
;; currently mounted. Data handlers — `{:on {:click [:open secret]}}` — are the
;; reason the view stays comparable by value: a closure in the hiccup would make
;; every snapshot an `#object[Function]` and every golden vector a lie.
(defonce ^:private handler (atom nil))
(defn- dispatch
"`replicant.core/*dispatch*`: (event-data, handler-data) for a DOM event,
(hook-data, hook-data) for a data life-cycle hook."
[event-data handler-data]
(when-some [h @handler] (h event-data handler-data))
js/undefined)
;; ---- the handle registry ---------------------------------------------------
(defonce ^:private handles (atom {}))
(defonce ^:private rev (atom 0))
(defn handle
"The live object parked under `id`, or nil."
[id]
(get @handles id))
(defn revision
"Bumped whenever the registry changes. Project THIS into view state — never
the handles themselves — so that a stream arriving re-runs the view."
[]
@rev)
(defn put-handle!
"Park a non-serialisable handle. Idempotent: re-parking the identical object
is not a change and does not bump the revision. Returns the revision."
[id x]
(if (identical? x (get @handles id))
@rev
(do (swap! handles assoc id x)
(swap! rev inc))))
(defn forget-handle!
"Drop the handle under `id`. Returns the revision."
[id]
(if (contains? @handles id)
(do (swap! handles dissoc id)
(swap! rev inc))
@rev))
(defn on-handle
"A replicant life-cycle hook that resolves `id` against the registry and calls
`(f node handle)`. Silent when either is absent — a tile can be rendered
before its stream lands, and the next revision brings the hook back."
[id f]
(fn [hook-data]
(let [node (:replicant/node hook-data)
h (handle id)]
(when (and (some? node) (some? h)) (f node h))
js/undefined)))
;; ---- mount + the render loop -----------------------------------------------
(defn mount!
"Own `root` with replicant. `render-hiccup` is a thunk returning this screen's
hiccup and `handle-event` becomes the dispatch target. Paints once,
synchronously, then returns {:paint! :schedule! :unmount!}:
:paint! render NOW — for the tick-local read-render-fixup dances
:schedule! render at most once per animation frame (the default)
:unmount! give `root` back: replicant forgets its vdom, the dispatch
goes idle, and — the part that is easy to miss — an animation
frame ALREADY queued becomes a no-op. Without that last bit a
screen that unmounts one tick after an interaction paints into
a detached node, which re-runs every mount hook: a second
language picker, a second subscription, a leak per visit."
[root render-hiccup handle-event]
(reset! handler handle-event)
(rd/set-dispatch! dispatch)
(let [live (volatile! true)
queued (volatile! false)
paint! (fn paint! []
(vreset! queued false)
(when @live (rd/render root (render-hiccup)))
js/undefined)
schedule! (fn schedule! []
(when (and @live (not @queued))
(vreset! queued true)
(js/requestAnimationFrame (fn [_] (when @queued (paint!)) js/undefined)))
js/undefined)]
(paint!)
{:paint! paint!
:schedule! schedule!
:unmount! (fn unmount! []
(vreset! live false)
(reset! handler nil)
(rd/unmount root)
js/undefined)}))
|