Grouped Feed Without Flicker
elements man recipes/grouped-feed Read as markdownA chat feed with day separators and consecutive messages from one person grouped into a run. The shape matters more than the styling: written one way every arriving message repaints the whole board, and written another way only the new row is touched.
The shape that repaints everything
The obvious approach groups the rows first and loops twice, an outer loop over days and an inner loop over that day's messages:
// Do not do this.
function byDay(messages: LiveView<Message>): [string, Message[]][] { ... }
<div e:for={[day, rows] of byDay(messages)} e:key={([day]) => day}>
<p class="day">{day}</p>
<div e:for={m of rows}>...</div>
</div>
The outer key is stable, and it still repaints. Each group is a tuple holding a fresh inner array on every evaluation, so the row diff sees the same key with a different value and treats it as an update, and an update re-renders that group and everything nested inside it. One arriving message rebuilds every row of its day. With an enter animation on a row, that is a visible flicker across the board.
The shape that patches
Keep the loop flat. Compute one line per message, and carry the facts that depend on its neighbour as fields on that line:
interface Line {
id: string;
message: Message;
dayLabel: string;
startsDay: boolean;
startsRun: boolean;
}
function dayOf(m: Message): string {
return m.createdAt.toDateString();
}
function lines(messages: LiveView<Message>): Line[] {
let out: Line[] = [];
let prev: Message | undefined;
for (let m of messages.sort((a, b) => +a.createdAt - +b.createdAt)) {
let startsDay = prev === undefined || dayOf(prev) !== dayOf(m);
out.push({
id: m.id,
message: m,
dayLabel: dayOf(m),
startsDay,
startsRun:
startsDay ||
prev === undefined ||
prev.userName !== m.userName ||
+m.createdAt - +prev.createdAt > 5 * 60 * 1000,
});
prev = m;
}
return out;
}
<ul class="feed">
<li e:for={line of lines(messages)} e:key={(line: Line) => line.id} class="msg">
<p e:if={line.startsDay} class="day">{line.dayLabel}</p>
<strong e:if={line.startsRun}>{line.message.userName}</strong>
<span class="body">{line.message.body}</span>
</li>
</ul>
The day separator and the author name become e:if on the row that starts
them, so there is no second level to rebuild. lines() mints a fresh wrapper
for every message on every change and the rows still patch: e:key pins each
line to its message id, and a wrapper whose fields hold the same values is not
a new value. Only a row whose facts actually moved re-renders, which is what
you want when a message arrives and the row above it stops being the last of
its run.
Measured on a live insert: a browser holding four rows received a fifth and kept all four original elements.
Rules of thumb
- One
e:forover one flat list. A loop inside a loop rebuilds the inner one whenever the outer row updates. e:keytakes a function:e:key={(line: Line) => line.id}.- Put a grouping decision on the row as a boolean, not in the structure.
- A wrapper object per row is free. A fresh array per group is not.
Related
livetable/mutations: inserting through a view, which is what broadcasts.recipes/chat-rooms: the full chat app this feed belongs in.html:e:for,e:ifande:key.