Manual Recipes Grouped Feed Without Flicker

Grouped Feed Without Flicker

elements man recipes/grouped-feed Read as markdown

A 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:for over one flat list. A loop inside a loop rebuilds the inner one whenever the outer row updates.
  • e:key takes 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:if and e:key.