Render only the message rows that changed #381

Closed
opened 2026-09-16 16:10:39 +00:00 by jcoffey-dev · 0 comments
Owner

Summary

Row in MessageList.tsx was wrapped in memo, but none of its props kept their identity across renders, so every visible row re-rendered on every store write: each star, each push and each thread load.

  • The thread's messages were rebuilt for every row, every render. threadEmails was a fresh .map().filter(). Rows now read their own message and conversation from the store: useMail((s) => s.emails[id]), plus a useShallow selector for the thread's messages.

  • The handlers changed on almost every render.

    • Five handlers were written inline in the JSX.
    • onRowClick depended on ids and selected.
    • onContext depended on the object useMenu() returns, which is new every render.
    • fireSwipe depended on actions.

    They now go through a small useStableCallback: the function's identity never changes and it always runs the latest code. The ref is updated in a layout effect, and handlers only run on events.

  • Every row received the whole selection object. It was only needed for dragging, which now reads the store when the drag starts. Rows get just their own selected flag.

  • scope was a new array each render, which also defeated the participant-name useMemo. It's now memoized.

  • Refreshes replaced every message object. mergeEmail now keeps the existing object when none of the fetched properties differ (keywords and mailboxIds are compared by value). A refresh fetches every listed message again, and a new object with the same data made every row render. All four merge sites in the mail store use it.

Related issues

None.

Translations

Adds none.

Testing

Row renders were counted in Chrome against npm run dev:mock. A temporary counter was added to the row component of both versions and isn't committed. There were 29 rows on screen, and the dev build renders twice under StrictMode, so every number below is doubled.

Action main This branch
Star one message (including the refresh the push triggers) 348 2
Tick one checkbox 58 2

On this branch, in the same tab:

  • Starring: the star toggled and came back.
  • Opening: clicking a row opened the conversation and highlighted that row.
  • Range selection: a checkbox plus shift-click selected rows 4–7.
  • Right-click menu: it opened with its usual items.
  • Layout: thread counts, stars and selection all showed normally.

list-refresh.test.ts has two new tests. One checks that a refresh keeps the object for an unchanged message; the other checks that a changed message is replaced while its unchanged neighbor is kept. Both fail against main. npm test -w web (1,320 tests), tsc and vite build are clean.

Merged 2026-09-16 as coffey-labs/ihasmail@e3cd56314b

Rebuilt from: git history, session transcript.

## Summary `Row` in `MessageList.tsx` was wrapped in `memo`, but none of its props kept their identity across renders, so every visible row re-rendered on every store write: each star, each push and each thread load. - **The thread's messages were rebuilt for every row, every render.** `threadEmails` was a fresh `.map().filter()`. Rows now read their own message and conversation from the store: `useMail((s) => s.emails[id])`, plus a `useShallow` selector for the thread's messages. - **The handlers changed on almost every render.** - Five handlers were written inline in the JSX. - `onRowClick` depended on `ids` and `selected`. - `onContext` depended on the object `useMenu()` returns, which is new every render. - `fireSwipe` depended on `actions`. They now go through a small `useStableCallback`: the function's identity never changes and it always runs the latest code. The ref is updated in a layout effect, and handlers only run on events. - **Every row received the whole selection object.** It was only needed for dragging, which now reads the store when the drag starts. Rows get just their own `selected` flag. - **`scope` was a new array each render,** which also defeated the participant-name `useMemo`. It's now memoized. - **Refreshes replaced every message object.** `mergeEmail` now keeps the existing object when none of the fetched properties differ (`keywords` and `mailboxIds` are compared by value). A refresh fetches every listed message again, and a new object with the same data made every row render. All four merge sites in the mail store use it. ## Related issues None. ## Translations Adds none. ## Testing Row renders were counted in Chrome against `npm run dev:mock`. A temporary counter was added to the row component of both versions and isn't committed. There were 29 rows on screen, and the dev build renders twice under StrictMode, so every number below is doubled. | Action | `main` | This branch | |---|---|---| | Star one message (including the refresh the push triggers) | 348 | **2** | | Tick one checkbox | 58 | **2** | On this branch, in the same tab: - **Starring:** the star toggled and came back. - **Opening:** clicking a row opened the conversation and highlighted that row. - **Range selection:** a checkbox plus shift-click selected rows 4–7. - **Right-click menu:** it opened with its usual items. - **Layout:** thread counts, stars and selection all showed normally. `list-refresh.test.ts` has two new tests. One checks that a refresh keeps the object for an unchanged message; the other checks that a changed message is replaced while its unchanged neighbor is kept. **Both fail against `main`.** `npm test -w web` (1,320 tests), `tsc` and `vite build` are clean. **Merged** 2026-09-16 as coffey-labs/ihasmail@e3cd56314bcc <sub>Rebuilt from: git history, session transcript.</sub>
This repo is archived. You cannot comment on issues.