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.
## 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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
RowinMessageList.tsxwas wrapped inmemo, 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.
threadEmailswas a fresh.map().filter(). Rows now read their own message and conversation from the store:useMail((s) => s.emails[id]), plus auseShallowselector for the thread's messages.The handlers changed on almost every render.
onRowClickdepended onidsandselected.onContextdepended on the objectuseMenu()returns, which is new every render.fireSwipedepended onactions.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
selectedflag.scopewas a new array each render, which also defeated the participant-nameuseMemo. It's now memoized.Refreshes replaced every message object.
mergeEmailnow keeps the existing object when none of the fetched properties differ (keywordsandmailboxIdsare 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.mainOn this branch, in the same tab:
list-refresh.test.tshas 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 againstmain.npm test -w web(1,320 tests),tscandvite buildare clean.Merged 2026-09-16 as coffey-labs/ihasmail@e3cd56314b
Rebuilt from: git history, session transcript.