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.

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.

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.