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
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
//! `FeedState::sync` — the diffing bridge from a `Signal<Vec<T>>`
//! source of truth to the keyed, append-only feed (backlog 0104) —
//! and `FeedState::sync_with`, the same bridge behind a borrow-based
//! source for items that live INSIDE a larger reactive shape
//! (first-app/0282).
//!
//! Feed order is PUSH order, so a key may only be appended when it
//! lands at the tail; everything that violates push order (mid-list
//! insert, removal, reorder, a visibility flip before the tail) takes
//! the rebuild path — `clear()` + re-push, the documented rebuild-on-
//! shrink policy, owned HERE so consumers stop re-implementing it
//! slightly wrong. The fast paths stay the default paths: appended
//! tail keys `push` (O(1)), changed fingerprints `update` in place.
//!
//! Child module of `feed` (file-size discipline). Tests live in
//! `feed_sync_tests.rs` (+ the `sync_with` cases in
//! `feed_sync_with_tests.rs`).
//!
//! OWNER: CONTENT (app-widgets wave).
use crate::reactive::{Effect, Scope, Signal};
use super::{FeedItem, FeedState};
/// How a synced feed derives identity, change, visibility and pixels
/// from one source item. Construct with [`SyncSpec::new`], add the
/// optional visibility filter with [`SyncSpec::visible`]:
///
/// ```ignore
/// feed.sync(cx, messages, SyncSpec::new(
/// |m: &Msg| m.id.clone(), // identity (stable, unique)
/// |m| m.rev, // cheap change fingerprint
/// |m| FeedItem::markdown(&m.text), // pixels, built on change only
/// ).visible(|m| !m.hidden));
/// ```
///
/// The FINGERPRINT is the change detector: any `PartialEq` value that
/// changes whenever the rendered output would (a revision counter, a
/// content hash, a small tuple). Fingerprints are the only per-item
/// work on unchanged items — `render` runs only for new keys, changed
/// fingerprints, and rebuilds.
///
/// FLOAT FINGERPRINTS MUST COMPARE BY BITS (cycle-2 review C-2): the
/// change test is `PartialEq`, and IEEE `NaN != NaN` — a fingerprint
/// that is ever `NaN` compares unequal to ITSELF, so the item
/// re-renders + re-typesets on EVERY drain with nothing changed
/// (pixels stay correct; the "render runs only on change" promise
/// silently degrades to "every source change"). Use the bit pattern
/// instead: `|m| m.progress.to_bits()` (`f32::to_bits`/`f64::to_bits`,
/// or a newtype whose `PartialEq` compares bits). More generally the
/// fingerprint type should be totally equal (reflexive: `a == a`
/// always) — integer, string and tuple fingerprints all are.
pub struct SyncSpec<T, Fp = u64> {
key: Box<dyn Fn(&T) -> String>,
fingerprint: Box<dyn Fn(&T) -> Fp>,
visible: Option<VisibleFn<T>>,
render: Box<dyn Fn(&T) -> FeedItem>,
}
/// The optional visibility predicate's slot shape (clippy-visible name).
type VisibleFn<T> = Box<dyn Fn(&T) -> bool>;
impl<T, Fp> SyncSpec<T, Fp> {
/// Identity + change detection + rendering — the required three.
/// Keys must be unique per visible snapshot (they are feed-item
/// identities: a duplicate key REPLACES, exactly like
/// [`FeedState::push`]).
pub fn new(
key: impl Fn(&T) -> String + 'static,
fingerprint: impl Fn(&T) -> Fp + 'static,
render: impl Fn(&T) -> FeedItem + 'static,
) -> SyncSpec<T, Fp> {
SyncSpec {
key: Box::new(key),
fingerprint: Box::new(fingerprint),
visible: None,
render: Box::new(render),
}
}
/// Optional visibility filter: hidden items never reach the feed.
/// This closure is the ONE truth for visibility — the old
/// "mirror predicate must stay byte-exact with the renderer"
/// consumer obligation dissolves into it. A flip on the tail item
/// appends/rebuilds honestly; a flip before the tail rebuilds.
pub fn visible(mut self, f: impl Fn(&T) -> bool + 'static) -> SyncSpec<T, Fp> {
self.visible = Some(Box::new(f));
self
}
}
/// One drain of the bridge: diff `list` against what the feed shows.
/// The SHARED core of [`FeedState::sync`] and [`FeedState::sync_with`]
/// — every semantic documented on `sync` (fast paths, rebuild policy,
/// one-writer self-heal) lives here, once. `shown` is the mirror
/// bookkeeping (the visible (key, fingerprint) sequence in push
/// order); `synced_mutations` is the one-writer detector record.
fn drain_into<T, Fp: PartialEq>(
feed: &FeedState,
spec: &SyncSpec<T, Fp>,
shown: &mut Vec<(String, Fp)>,
synced_mutations: &mut Option<u64>,
list: &[T],
) {
let SyncSpec {
key,
fingerprint,
visible,
render,
} = spec;
let is_visible = |item: &T| visible.as_ref().is_none_or(|f| f(item));
// Self-heal check (C-1): the counter moved past this bridge's own
// record — someone else wrote to the feed between drains. The
// `shown` bookkeeping no longer describes the feed, so the only
// honest move is the rebuild path. One u64 compare on every drain.
let foreign = synced_mutations.is_some_and(|recorded| recorded != feed.mutation_count());
// Pass 1 — order check against the shown prefix. Keys are the only
// per-item probe here; fingerprints are compared in pass 2 only
// when the order holds.
let mut vis_count = 0usize;
let mut prefix_holds = true;
for item in list.iter().filter(|i| is_visible(i)) {
if vis_count < shown.len() && key(item) != shown[vis_count].0 {
prefix_holds = false;
break;
}
vis_count += 1;
}
let fast = !foreign && prefix_holds && vis_count >= shown.len();
if fast {
// Fast path: in-place updates + tail appends.
for (at, item) in list.iter().filter(|i| is_visible(i)).enumerate() {
if at < shown.len() {
let fp = fingerprint(item);
if fp != shown[at].1 {
feed.update(&shown[at].0, render(item));
shown[at].1 = fp;
}
} else {
let k = key(item);
feed.push(k.clone(), render(item));
shown.push((k, fingerprint(item)));
}
}
} else {
// Rebuild path: push order broke, the list shrank, or a
// foreign write desynced the mirror — the append-only feed
// rebuilds whole (strays evicted, order restored to source
// order).
feed.clear();
shown.clear();
for item in list.iter().filter(|i| is_visible(i)) {
let k = key(item);
feed.push(k.clone(), render(item));
shown.push((k, fingerprint(item)));
}
}
// Record the counter AFTER this drain's own writes: any bump past
// this value before the next drain is a foreign mutation by
// construction.
*synced_mutations = Some(feed.mutation_count());
}
impl FeedState {
/// Mirror a `Signal<Vec<T>>` into this feed, diffing by key (the
/// fold-shaped consumer's bridge — backlog 0104). Runs immediately
/// and on every source change, inside a labeled effect owned by
/// `cx`; returns the [`Effect`] (dispose it to stop syncing —
/// otherwise it dies with the scope).
///
/// Diff semantics, in order:
/// - source keys that keep the previous VISIBLE order as a prefix:
/// changed fingerprints -> [`FeedState::update`] in place, new
/// tail keys -> [`FeedState::push`] (the O(1) hot path — an
/// append-only fold never rebuilds, test-pinned);
/// - anything else (shrink, removal, reorder, mid-list insert or
/// visibility flip) -> the REBUILD path: [`FeedState::clear`] +
/// re-push every visible item, because feed order is push order
/// and the feed is append-only.
///
/// REBUILD COST, named (cycle-2 review C-3): a rebuild re-renders
/// and re-typesets EVERY visible item, so a source that reorders
/// on every change (a most-recent-first sort, a live-resorted
/// leaderboard) pays O(visible) renders per drain, forever. For
/// feeds ordered by mutable rank, sync a STABLE order and sort at
/// render time — or accept O(visible) per change knowingly.
///
/// Contracts: the synced feed has ONE writer — this bridge.
/// Manual `push`/`update`/`stream_*`/`clear` on a synced feed is a
/// contract violation the bridge DETECTS AND SELF-HEALS (cycle-2
/// review C-1): every item mutation bumps a feed-internal counter;
/// a drain that finds the counter moved past its own record takes
/// the rebuild path, restoring the feed to exactly the source's
/// visible order — stray items are evicted, order-divergence
/// (a manually-pushed key the source appends LATER would land as
/// a replace-in-place at the old index) is repaired. The heal is
/// a safety net, not a feature: the foreign content stays on
/// screen until the NEXT source change arrives, and that drain
/// pays a full O(visible) rebuild. The
/// `render`/`key`/`fingerprint`/`visible` closures receive `&T`
/// borrowed from inside the source signal's cell — they must not
/// read the SOURCE signal reactively (same rule as
/// [`Signal::update`]); reading other signals is fine but adds
/// them to the sync effect's dependencies.
pub fn sync<T: 'static, Fp: PartialEq + 'static>(
&self,
cx: Scope,
items: Signal<Vec<T>>,
spec: SyncSpec<T, Fp>,
) -> Effect {
// One door: `sync` IS `sync_with` over the whole-signal read,
// so every `sync` test pins the shared drain core.
self.sync_with(cx, move |read| items.with(|list| read(list)), spec)
}
/// [`FeedState::sync`] behind a BORROW-BASED source
/// (first-app/0282): the bridge for items that live INSIDE a
/// larger reactive shape — one field of a `Signal<Fold>` whose
/// siblings (stats, waits, flags) mutate under the same signal, or
/// a focus-selected convo's nested vec — where no `Signal<Vec<T>>`
/// exists and a clone-mirror would copy the item vec on every
/// unrelated write.
///
/// `source` runs INSIDE the sync effect and must hand the current
/// items to the callback, borrowed in place (zero copies):
///
/// ```ignore
/// // Items are one field of a fold struct:
/// feed.sync_with(cx, move |read| fold.with(|f| read(&f.items)), spec);
/// // A focus-selected projection over two signals:
/// feed.sync_with(cx, move |read| {
/// let at = focus.get();
/// convos.with(|cs| read(&cs[at].items))
/// }, spec);
/// ```
///
/// Every signal `source` reads becomes a dependency of the sync
/// effect — the drain re-runs on ANY of them (a stats-only write
/// on a fold signal re-runs the drain, but the fingerprint walk
/// then renders nothing, test-pinned). Call the callback EXACTLY
/// ONCE per run: zero calls skip the drain (the feed keeps its
/// previous content); multiple calls drain sequentially (the last
/// call wins, paying a rebuild whenever the lists disagree).
///
/// Diff semantics, rebuild policy, the one-writer self-heal and
/// the spec-closure borrow rules are exactly [`FeedState::sync`]'s
/// — the two share one drain core, and `sync` itself delegates
/// here.
pub fn sync_with<T: 'static, Fp: PartialEq + 'static>(
&self,
cx: Scope,
source: impl Fn(&mut dyn FnMut(&[T])) + 'static,
spec: SyncSpec<T, Fp>,
) -> Effect {
let feed = self.clone();
// The mirror bookkeeping + one-writer detector. `None` until
// the first drain — the contract begins when the bridge
// attaches, so pre-attach pushes are not its business (the
// first drain appends after them exactly as before).
let mut shown: Vec<(String, Fp)> = Vec::new();
let mut synced_mutations: Option<u64> = None;
cx.effect_labeled("feed.sync", move || {
source(&mut |list: &[T]| {
drain_into(&feed, &spec, &mut shown, &mut synced_mutations, list);
});
})
}
}
#[cfg(test)]
#[path = "feed_sync_tests.rs"]
mod tests;