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.
lines() reads createdAt, so the composer's insert has to pass
createdAt: new Date(). The optimistic row has no createdAt until the
server's broadcast lands, and dayOf throws on it.
Reactions
A reaction belongs to a message, and a windowed feed holds only the messages
it has loaded. Store the reactions on the message row and they arrive with it:
in the first page, in every more(), and in the live update when someone
reacts. A second view of the reactions table would hold every reaction in the
room, and a lookup inside the row would scan it for each message.
elements create migration 'add reactions'
-- add reactions
alter table chatMessages add column reactions jsonb not null default '{}';
create table chatReactions (
id uuid primary key default uuidGenerateV7(),
createdAt timestamptz not null default now(),
messageId uuid not null references chatMessages(id) on delete cascade,
userId uuid not null references chatUsers(id) on delete cascade,
emoji text not null,
unique (messageId, userId, emoji)
);
-- Fold each reaction into its message's reactions column, in one update.
create or replace function chatReactionsApply() returns trigger
language plpgsql as $$
begin
if tg_op = 'INSERT' then
update chatMessages
set reactions = jsonb_set(
reactions,
array[new.emoji],
coalesce(reactions -> new.emoji, '[]') || to_jsonb(new.userId::text))
where id = new.messageId;
else
update chatMessages
set reactions = case
when (reactions -> old.emoji) - old.userId::text = '[]'
then reactions - old.emoji
else jsonb_set(reactions, array[old.emoji], (reactions -> old.emoji) - old.userId::text)
end
where id = old.messageId;
end if;
return null;
end;
$$;
create trigger chatReactionsApplyTrigger
after insert or delete on chatReactions
for each row execute function chatReactionsApply();
-- The notify trigger from chat-rooms, now carrying reactions.
create or replace function chatMessagesNotify() returns trigger
language plpgsql as $$
declare
r record;
payload text;
begin
r := coalesce(new, old);
payload := json_build_object(
'op', lower(tg_op),
'data', json_build_object(
'id', r.id,
'createdAt', json_build_object('$type', 'Date', '$value', (extract(epoch from r.createdAt) * 1000)::bigint),
'roomId', r.roomId,
'userId', r.userId,
'userName', r.userName,
'body', r.body,
'reactions', r.reactions
)
)::text;
-- NOTIFY takes under 8000 bytes. A larger row goes as its id, and the
-- server reads the row back.
if octet_length(payload) >= 8000 then
payload := json_build_object('op', lower(tg_op), 'id', r.id)::text;
end if;
perform pg_notify(channel_name('chatMessages:roomId=' || r.roomId), payload);
return r;
end;
$$;
chatReactions is the source of truth, one row per user, emoji and message;
the unique constraint makes a double click harmless. chatReactionsApply folds
each insert or delete into chatMessages.reactions with jsonb operators inside
one update, shaped { "👍": ["<userId>", ...] }. Two people reacting at
once queue on the row lock, and neither overwrites the other. That update
fires chatMessagesNotify, redefined here to carry reactions, so the changed
row reaches every page with the room open.
In app/shared/services/chat.ts, add the field and keep it out of the
browser's hands:
import { ForbiddenError, LiveTable, session } from "@elements/app";
export interface Message {
// ...
reactions: Record<string, string[]>;
}
export let chatMessages: LiveTable<Message> = new LiveTable<Message>({
channel: (partition) =>
partition ? `chatMessages:${partition}` : "chatMessages",
insert: (item) => {
session.isLoggedInOrThrow();
return chatMessages.insert({ ...item, reactions: {} });
},
update: () => {
throw new ForbiddenError();
},
});
A message starts with no reactions whatever the browser sent, and no update
through the view can write them. Pass reactions: {} in the composer's
insert as well, so the optimistic row has something to render.
A reaction is toggled by an @rpc in the room's template.ehtml. It takes
the user from the session, never from an argument, so a user can add or remove
only their own:
/** @rpc */
export function toggleReaction(messageId: string, emoji: string) {
session.isLoggedInOrThrow();
let userId = session.getOrThrow("userId");
let removed = sql(`
delete from chatReactions
where messageId = ${messageId} and userId = ${userId} and emoji = ${emoji}
returning id
`);
if (removed.empty()) {
sql(`
insert into chatReactions (messageId, userId, emoji)
values (${messageId}, ${userId}, ${emoji})
on conflict do nothing
`);
}
}
The row reads the count and your own state straight off the message:
<li e:for={line of lines(messages)} e:key={(line: Line) => line.id} class="msg">
<strong e:if={line.startsRun}>{line.message.userName}</strong>
<span class="body">{line.message.body}</span>
<button e:for={[emoji, users] of Object.entries(line.message.reactions)}
e:key={([emoji]) => emoji}
class={users.includes(session.get("userId")!) ? "reaction mine" : "reaction"}
onclick={() => toggleReaction(line.message.id, emoji)}>
{emoji} {users.length}
</button>
<button onclick={() => toggleReaction(line.message.id, "👍")}>+👍</button>
</li>
The count is the array's length and "mine" is whether it holds your id. The buttons are a loop inside the row, which the first rule below warns about. It costs nothing here: the row changes exactly when its reactions do, so rebuilding its buttons is the update.
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.
- Child rows a window has to page with, such as reactions, go on the parent row, not in a second view.
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.