Skip to content

Stabilize ListBoard row press callback to avoid full re-renders on open#651

Open
wyne wants to merge 1 commit into
mainfrom
claude/brave-mccarthy-5192d0
Open

Stabilize ListBoard row press callback to avoid full re-renders on open#651
wyne wants to merge 1 commit into
mainfrom
claude/brave-mccarthy-5192d0

Conversation

@wyne

@wyne wyne commented Jun 13, 2026

Copy link
Copy Markdown
Owner

Problem

Profiling a first-open of the ListBoard (React DevTools) showed two full re-renders of the entire ListBoard subtree right after mount:

Commit Duration What rendered Scheduled by
3 159ms Entire ListBoard subtree (~1205 fibers) ListBoard
5 121ms Identical ListBoard subtree again (~1205 fibers) ListBoard

That's ~280ms of avoidable work (~50 player rows × ~24 elements each, re-rendered twice).

Root cause

handleRowPress was a useCallback with boardLayout in its dependency array. On first open the container's layout settles in multiple onLayout passes (initial mount, then again after the GameSheet bottom sheet mounts and changes available height). Each setBoardLayout:

  1. recreated handleRowPress with a new identity,
  2. which changed the onRowPress prop on every MemoizedPlayerRow,
  3. defeating React.memo and forcing all rows to re-render — twice.

The rows were already correctly memoized; the unstable callback was busting it.

Fix

Mirror boardLayout into a useRef and read boardLayoutRef.current inside handleRowPress, removing boardLayout from its dependency array. The callback now keeps a stable identity across layout passes, so the rows bail out of React.memo. The boardLayout state is retained only for rendering DialOverlay's dimensions (fresh at press time, so rotation still works).

Each layout pass now re-renders only ListBoard itself (a handful of fibers) instead of the full ~1205-fiber subtree.

Testing

  • All 12 ListBoard.test.tsx tests pass (including the row-press → DialOverlay path this change touches).
  • tsc --noEmit clean.

🤖 Generated with Claude Code

On first open the board layout settles in multiple onLayout passes. Each
setBoardLayout recreated handleRowPress (boardLayout was in its dep array),
which changed the onRowPress prop on every MemoizedPlayerRow and forced the
entire row list to re-render. Profiling showed two full ListBoard subtree
re-renders (~1205 fibers each, ~280ms combined) right after mount.

Read the latest layout from a ref inside handleRowPress so the callback keeps
a stable identity across layout passes; rows now bail out of React.memo. The
boardLayout state is retained only for rendering DialOverlay's dimensions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Coverage after merging claude/brave-mccarthy-5192d0 into main will be

61.45%

Coverage Report
FileStmtsBranchesFuncsLinesUncovered Lines
redux
   GamesSlice.ts54.09%27.50%56.52%64.58%101, 109, 131–133, 148, 167, 178, 185, 187, 190–192, 242, 256, 269, 278–279, 279, 279, 285, 285, 285–286, 288, 29, 290–291, 294, 294, 294, 294, 294–295, 298–299, 301, 303, 303, 303, 303, 303–304, 306, 306, 306, 308–309, 312, 312, 312, 314, 52, 63, 80, 85–86, 86, 86, 98–99, 99, 99
   PlayersSlice.ts89.90%100%87.50%85.11%103, 105, 33, 50–51, 68–69
   SettingsSlice.ts46.81%25%42.11%54.17%101, 104, 54, 57, 63, 66, 72, 75, 84, 84, 84, 90, 93, 98
   backup.ts0%0%0%0%11, 27–29, 31, 45–49, 51, 51, 51–52, 57, 60, 64–66, 71, 71, 71, 73–75, 78–79, 81–82, 85, 85, 85, 85, 85–87, 90–93, 95, 97
   hooks.ts100%100%100%100%
   selectors.ts100%100%100%100%
   store.ts0%0%0%0%10, 12, 12, 12–13, 13, 13–15, 18, 21–23, 23, 23, 23, 23–24, 28, 54, 61, 68, 75, 93
   testStore.ts0%100%0%0%11–15, 20–23, 25, 42–44, 5
src
   Analytics.ts100%100%100%100%
   ColorPalette.ts100%100%100%100%
   Logger.ts91.23%87.50%81.82%94.74%14–15, 22
   Navigation.tsx75.68%72.73%62.50%83.33%45, 49, 70, 73, 79–80
   constants.ts100%100%100%100%
   theme.ts93.75%85.71%100%100%79
src/components
   EditGame.tsx81.40%70%71.43%88.46%25, 39, 64, 64, 64–65
   FloatingActionButton.tsx68.97%100%50%73.68%38, 41–42, 55–56
   GameListItem.tsx66.10%47.83%100%73.33%103, 20, 23–24, 24, 24–26, 26, 26–28, 28, 28–30, 30, 30–31, 73
   GameListItemPlayerName.tsx100%100%100%100%
   MenuOpenContext.tsx87.50%100%66.67%100%
   PlayerListItem.tsx0%0%0%0%123, 123, 134, 24, 32–34, 34, 34–38, 40, 40, 40, 40, 40–41, 41, 41–42, 42, 42, 44–45, 56, 63–64, 66, 72–73, 76, 79, 82–83, 90, 93, 93, 96, 96, 96–97, 99
   ScoreLogTable.tsx92.59%82.35%100%96.15%50, 52, 54, 54
src/components/AppInfo
   RotatingIcon.tsx0%0%0%0%18–19, 21–24, 26, 28–30, 37–38, 40, 40, 40–41, 44, 44, 44–48, 51, 53–54, 59
   SeedData.ts0%100%0%0%17, 47, 49–50, 52, 54–56, 58–60, 62, 68, 71, 82–85
src/components/BigButtons
   BigButton.tsx0%0%0%0%18, 18–20, 22–23, 23, 25, 25–26, 26, 48
src/components/Boards
   ListBoard.tsx86.99%72.73%84.21%98.61%123, 180, 213–214, 227, 230, 269, 29, 33, 59–61, 84, 84, 88, 92
   PlayerTile.tsx0%0%0%0%29, 39, 39, 39, 39, 39–40, 40, 40, 40, 40, 42–46, 46, 46–47, 47, 47, 47, 47–48, 48, 48–50, 50, 50, 52–54, 54, 54, 56, 59, 59, 63–64, 67–68, 70, 80, 80, 97
   TileBoard.tsx96.39%90.32%100%100%18, 78, 92
src/components/Buttons
   AppSettingsButton.tsx100%100%100%100%
   BackButton.tsx0%100%0%0%16–18, 20–21
   GameOptionsButton.tsx53.40%45%75%54.90%100, 118, 118, 118, 118, 118, 118, 118, 120–122, 124–126, 128–130, 132–135, 137–139, 141–143, 150, 156–157, 168, 170, 170, 176, 176, 176, 176, 38, 44, 46, 56, 63, 71, 92, 96
   HeaderButton.tsx100%100%100%100%
src/components/ColorPalettes
   ColorSelector.tsx100%100%100%100%
   PalettePreview.tsx0%0%0%0%10–12, 14, 19, 19, 25
   PaletteSelector.tsx0%0%0%0%13, 15–18, 20, 22, 22, 22, 24–25, 31, 37, 40, 45, 56
src/components/Headers
   RoundHeaderTitle.tsx0%0%0%0%12–14, 16, 18, 18, 18, 20–21, 21, 21–22, 22, 22, 24–25, 27–28, 28, 28, 28, 28, 30–31, 31, 34–35, 44–45, 45, 45, 47–49, 57, 59, 62, 62, 62, 62, 64, 64, 70, 70, 70, 70, 73, 73, 73, 73, 73, 75, 75, 75, 75, 84
src/components/Icons
   RematchIcon.tsx100%100%100%100%
src/components/Interactions
   InteractionComponents.ts0%100%100%0%6
   InteractionType.ts100%100%100%100%
   interactionConstants.ts100%100%100%100%
src/components/Interactions/Dial
   Dial.tsx100%100%100%100%
   DialControl.tsx54.49%48.72%59.52%55.80%117–118, 124,

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant