All bugs
mediumHidden state corruption

Undo uses up its history entry but doesn't revert the checklist

Undo looks like it worked: the button disables and the board re-renders. The snapshot it restored had already been changed by the edit it was meant to undo.

taskboardFound 2026-10-19Full report in Replay QA

What the recording showed

SET_CHECK_ITEM sets item.done in place, and snapshot() only shallow-copies cards. A logpoint at boardReducer.js:55 returned sharedItemObject: true: the saved past state and the live board shared the same checklist item.

Actual

The Undo button in the board header consumed a history entry (its state flipped from enabled to disabled), but the board it was supposed to revert did not change: the Review card "Customer interview synthesis" still displays checklist badge "☑ 1/2" instead of being restored to "☑ 0/2". The undo action fired yet produced no visible revert of the checklist edit.

Expected

Clicking "↶ Undo" in the board header should revert the last board mutation — the checklist edit — so the Review-column card "Customer interview synthesis" badge is restored to "☑ 0/2" (its pre-edit state), while consuming that one history entry.

Session replay

Chronology of events

1
The app at 0:04.50:04.5
0:04.5

Toggle the first checklist item checkbox ("Tag transcripts") in the "Customer interview synthesis" card detail dialog, changing it from unchecked to checked.

2 evidence probes (UserInteractions, InspectElement)
UserInteractions {"mode":"interactions","type":"click"}
→ # User Interactions

**Filter:** click only
**Showing 1–4 of 4 interactions**

| # | Time | Type | Detail | Point |
|---|------|------|--------|-------|
| 0 | 21395ms | click | Click at (889, 375) | Point:1 |
| 1 | 57208ms | click | Click at (302, 307) | Point:2 |
| 2 | 73540ms | click | Click at (983, 108) | Point:3 |
| 3 | 102096ms | click | Click at (1218, 110) | Point:4 |

## Next Steps

- Use DescribePoint with a point reference to inspect the code at a specific interaction
- Use Evaluate with a point reference for ad-hoc value inspection at that moment
- Use `type: 'click'` or `type: 'keypress'` to filter by interaction type

## Playwright-triggered interactions (driver-dispatched input)

The rows and counts above enumerate DOM-recorded input events only. Input a test driver dispatched
programmatically (a Playwright `keyboard.press`, `fill`, driver `click`, ...) can be absent from them
entirely; these are the driver-dispatched input steps of this recording's Playwright run, distilled
from `PlaywrightSteps`. A completed row is affirmative evidence the input WAS dispatched; a failed row
is one attempt, never evidence that none was dispatched.

**Playwright key presses:** 0 — 0 completed, 0 failed
**Playwright pointer input:** 8 — 8 completed, 0 failed

| Step | Time | Method | Params | Result |
| --- | --- | --- | --- | --- |
| 188 | 20433ms | mouse.move | {"x":889.59375,"y":375.671875,"steps":24} | completed |
| 196 | 21376ms | locator.click |  | completed |
| 289 | 5623…
InspectElement {"point":"Point:2","element":"main input[type=checkbox]"}
→ Element:7 is `<input type="checkbox" style="">`.
Element:7 is rendered by React component CardModal at Point:34:
Relevant code: http://localhost:3203/src/components/CardModal.jsx line 132 column 22
No elements are obstructing the center point of this element.

Element:7 has 13px width and 13px height.
Margin: top 3px right 3px bottom 3px left 4px.
Padding: top 0px right 0px bottom 0px left 0px.

Distances to its parent element:
- Top overflows parent by 8.00px
- Right overflows parent by 459.00px
- Bottom overflows parent by 8.00px
- Left overflows parent by 8.00px

### Parent Element:8

Element:8 is `<label></label>`.
Element:8 is rendered by React component CardModal at Point:35:
Relevant code: http://localhost:3203/src/components/CardModal.jsx line 131 column 20

Element:8 has 480px width and 29px height.
Margin: top 0px right 0px bottom 0px left 0px.
Padding: top 5px right 4px bottom 5px left 4px.

Distances to its parent element:
- Top is aligned with parent boundary
- Right is aligned with parent boundary
- Bottom is aligned with parent boundary
- Left is aligned with parent boundary

### Parent Element:9

Element:9 is `<li></li>`.
Element:9 is rendered by React component CardModal at Point:36:
Relevant code: http://localhost:3203/src/components/CardModal.jsx line 130 column 18

Element:9 has 480px width and 29px height.
Margin: top 0px right 0px bottom 0px left 0px.
Padding: top 0px right 0px bottom 0px left 0px.

Distances to its parent element:
- Top is aligned wit…
2
The app at 0:08.20:08.2
0:08.2

After the dialog was closed, click the "↶ Undo" button in the board header. At this moment the Review card "Customer interview synthesis" shows badge "☑ 1/2".

2 evidence probes (UserInteractions, InspectElement)
UserInteractions {"mode":"interactions","type":"click"}
→ # User Interactions

**Filter:** click only
**Showing 1–4 of 4 interactions**

| # | Time | Type | Detail | Point |
|---|------|------|--------|-------|
| 0 | 21395ms | click | Click at (889, 375) | Point:1 |
| 1 | 57208ms | click | Click at (302, 307) | Point:2 |
| 2 | 73540ms | click | Click at (983, 108) | Point:3 |
| 3 | 102096ms | click | Click at (1218, 110) | Point:4 |

## Next Steps

- Use DescribePoint with a point reference to inspect the code at a specific interaction
- Use Evaluate with a point reference for ad-hoc value inspection at that moment
- Use `type: 'click'` or `type: 'keypress'` to filter by interaction type

## Playwright-triggered interactions (driver-dispatched input)

The rows and counts above enumerate DOM-recorded input events only. Input a test driver dispatched
programmatically (a Playwright `keyboard.press`, `fill`, driver `click`, ...) can be absent from them
entirely; these are the driver-dispatched input steps of this recording's Playwright run, distilled
from `PlaywrightSteps`. A completed row is affirmative evidence the input WAS dispatched; a failed row
is one attempt, never evidence that none was dispatched.

**Playwright key presses:** 0 — 0 completed, 0 failed
**Playwright pointer input:** 8 — 8 completed, 0 failed

| Step | Time | Method | Params | Result |
| --- | --- | --- | --- | --- |
| 188 | 20433ms | mouse.move | {"x":889.59375,"y":375.671875,"steps":24} | completed |
| 196 | 21376ms | locator.click |  | completed |
| 289 | 5623…
InspectElement {"point":"Point:4","element":".board-actions button"}
→ Element:1 is `<button class="btn" title="Undo (Ctrl+Z)"></button>`.
Element:1 is rendered by React component BoardPage at Point:7:
Relevant code: http://localhost:3203/src/pages/BoardPage.jsx line 52 column 10
No elements are obstructing the center point of this element.

Element:1 has 75.359375px width and 32px height.
Margin: top 0px right 0px bottom 0px left 0px.
Padding: top 7px right 12px bottom 7px left 12px.

Distances to its parent element:
- Top is aligned with parent boundary
- Right is aligned with parent boundary
- Bottom is aligned with parent boundary
- Left is aligned with parent boundary

### Parent Element:2

Element:2 is `<div class="board-actions"></div>`.
Element:2 is rendered by React component BoardPage at Point:8:
Relevant code: http://localhost:3203/src/pages/BoardPage.jsx line 51 column 8

Element:2 has 75.359375px width and 32px height.
Margin: top 0px right 0px bottom 0px left 0px.
Padding: top 0px right 0px bottom 0px left 0px.

Distances to its parent element:
- Top overflows parent by 14.00px
- Right is aligned with parent boundary
- Bottom is aligned with parent boundary
- Left overflows parent by 1156.64px

### Parent Element:3

Element:3 is `<div class="board-header"></div>`.
Element:3 is rendered by React component BoardPage at Point:9:
Relevant code: http://localhost:3203/src/pages/BoardPage.jsx line 44 column 6

Element:3 has 1232px width and 46px height.
Margin: top 0px right 0px bottom 20px left 0px.
Padding: top 0px right 0px bottom 0p…
3
The app at 0:08.20:08.2
0:08.2

After clicking Undo, the board re-rendered but the "Customer interview synthesis" card still shows badge "☑ 1/2", and the Undo button is now disabled (its history entry was consumed) — the checklist edit was not reverted.

1 evidence probe (Evaluate)
Evaluate {"scope":"global","expression":"(() => { const badgeEls = Array.from(document.querySelectorAll('*')).filter(e => e.children.length === 0 && /☑|✓/.test(e.textContent || '') && /\\d\\/\\d/.test(e.textContent || '')); const undoBtn = Array.from(document.querySelectorAll('button')).find(b => b.textContent.includes('Undo')); return { badgeTexts: badgeEls.map(e => e.textContent.trim()).filter(t => /1\\/2|0\\/2/.test(t)), undoDisabled: undoBtn ? undoBtn.disabled : null }; })()"}
→ Evaluated at Point:33 (last rendered frame, scope global).
{
  "badgeTexts": [
    "☑ 0/2",
    "☑ 0/2",
    "☑ 1/2"
  ],
  "undoDisabled": true
}

Steps to reproduce

1

Verify the starting page

2

Open the Review-column card whose checklist has zero of two items done

3

Toggle the checkbox of the first checklist item

4

Close the card detail dialog

5

Click the Undo control in the board header

Root cause

The undo snapshot shares the very object that SET_CHECK_ITEM mutates. In http://localhost:3203/src/store/boardReducer.js the SET_CHECK_ITEM case (lines 48-56) finds the checklist item and writes item.done = action.done directly onto the existing item object, then records the pre-edit cards into state.past via record() -> history.js snapshot(). But snapshot() only shallow-copies each card ({ ...card }), so the recorded past snapshot shares the same checklist item objects as the live state. When the checkbox is toggled, the in-place mutation writes through to the snapshot that was just stored in state.past. The Logpoint at boardReducer.js:55 confirms this: the past snapshot's first checklist item is already done: true AND sharedItemObject: true (past snapshot item === current card item). When Undo then dispatches UNDO (boardReducer.js:86-91) it restores cards: state.past[state.past.length - 1] — which already contains done: true — so the badge stays "☑ 1/2" even though the past entry is consumed and the Undo button becomes disabled.

4 evidence probes (Logpoint, ReadSource, SearchSources)
Logpoint {"line":55,"path":"http://localhost:3203/src/store/boardReducer.js","expression":"(() => { const pastCard = past[past.length-1].find(c => c.title && c.title.includes('Customer')); const curCard = cards.find(c => c.id === pastCard.id); return { cardTitle: pastCard.title, pastDone: pastCard.checklist.map(i => i.done), curDone: curCard.checklist.map(i => i.done), sharedItemObject: pastCard.checklist[0] === curCard.checklist[0] }; })()"}
→ Found 1 total hit at http://localhost:3203/src/store/boardReducer.js:55

Hit 1 at point Point:6 @ 57212.16ms:
  Expression: (() => { const pastCard = past[past.length-1].find(c => c.title && c.title.includes('Customer')); const curCard = cards.find(c => c.id === pastCard.id); return { cardTitle: pastCard.title, pastDone: pastCard.checklist.map(i => i.done), curDone: curCard.checklist.map(i => i.done), sharedItemObject: pastCard.checklist[0] === curCard.checklist[0] }; })()
  Result: {
  "cardTitle": "Customer interview synthesis",
  "pastDone": [
    true,
    false
  ],
  "curDone": [
    true,
    false
  ],
  "sharedItemObject": true
}
ReadSource {"line":48,"path":"http://localhost:3203/src/store/boardReducer.js"}
→  hits | L | code 
------|------|------
     |   23 |         board: action.board, 
     |   24 |         columns: action.columns, 
     |   25 |         cards: action.cards, 
     |   26 |       }; 
     |   27 |  
     |   28 |     case 'LOAD_FAILED': 
   0 |   29 |       return { ...state, status: state.cards.length ? 'ready' : 'error', error: action.error }; 
     |   30 |  
     |   31 |     case 'MOVE_CARD': { 
   0 |   32 |       const card = state.cards.find((c) => c.id === action.cardId); 
   0 |   33 |       if (!card || card.columnId === action.columnId) return state; 
   0 |   34 |       return { 
     |   35 |         ...state, 
   0 |   36 |         past: record(state.past, state.cards), 
   0 |   37 |         cards: [...state.cards.filter((c) => c.id !== card.id), { ...card, columnId: action.columnId }], 
     |   38 |       }; 
     |   39 |     } 
     |   40 |  
     |   41 |     case 'UPDATE_CARD': 
   0 |   42 |       return { 
     |   43 |         ...state, 
   0 |   44 |         past: record(state.past, state.cards), 
   0 |   45 |         cards: replaceCard(state.cards, action.cardId, (card) => ({ ...card, ...action.changes })), 
     |   46 |       }; 
     |   47 |  
     |  *48 |     case 'SET_CHECK_ITEM': { 
   1 |   49 |       const past = record(state.past, state.cards); 
   1 |   50 |       const cards = replaceCard(state.cards, action.cardId, (card) => { 
   1 |   51 |         const item = card.checklist.find((i) => i.id === action.itemId); 
   …
ReadSource {"line":3,"path":"http://localhost:3203/src/store/history.js"}
→  hits | L | code 
------|------|------
   1 |    1 | const LIMIT = 50; 
     |    2 |  
     |   *3 | export function snapshot(cards) { 
  18 |    4 |   return cards.map((card) => (/*<HIT>*/{ ...card })); 
     |    5 | } 
     |    6 |  
     |    7 | export function record(past, cards) { 
   1 |    8 |   const next = [...past, snapshot(cards)]; 
   0 |    9 |   return next.length > LIMIT ? next./*<HIT>*/slice(next.length - LIMIT) : next; 
     |   10 | } 
     |   11 |  
SearchSources {"pattern":"toggleChecklist|checklist.*item|UNDO|undo","pathGlob":"**/src/store/*"}
→ ### http://localhost:3203/src/store/BoardContext.jsx
 hits | L | code 
------|------|------
   1 |    1 | import { createContext, useCallback, useContext, useEffect, useMemo, useReducer, useRef, useState } from 'react'; 

     |    7 | export function BoardProvider({ children }) { 

     |  110 |  
   5 | *111 |   const undo = useCallback(async () => { 
   1 |  112 |     const { past } = stateRef.current; 

   1 |  114 |     const restored = past[past.length - 1]; 
   1 | *115 |     dispatch({ type: 'UNDO' }); 
     |  116 |     try { 

     |  118 |     } catch (err) { 
   0 | *119 |       notify(`Couldn't undo: ${err.message}`); 
     |  120 |     } 

     |  141 |       deleteCard, 
     | *142 |       undo, 
     |  143 |       resetDemo, 
     |  144 |     }), 
     | *145 |     [state, members, notice, refresh, moveCard, updateCard, setChecklistItem, addChecklistItem, addCard, deleteCard, undo, resetDemo] 
     |  146 |   );
### http://localhost:3203/src/store/boardReducer.js
 hits | L | code 
------|------|------
   1 |    1 | import { record } from "/src/store/history.js"; 

     |   16 | export function boardReducer(state, action) { 

   1 |   50 |       const cards = replaceCard(state.cards, action.cardId, (card) => { 
   1 |  *51 |         const item = card.checklist.find((i) => i.id === action.itemId); 
   1 |   52 |         if (item) item.done = action.done; 

     |   62 |           ...card, 
     |  *63 |           checklist: [...card.checklist, action.item], 
…

Root cause → actual behavior

1

Toggling the checklist checkbox dispatches SET_CHECK_ITEM, which in boardReducer.js mutates the existing checklist item in place (item.done = action.done, lines 48-56) and stores a snapshot of the cards into state.past; because history.js snapshot() shallow-copies cards ({ ...card }, lines 3-5), that stored snapshot shares the same (now-mutated) checklist item object — the Logpoint at boardReducer.js:55 returns pastDone [true, false] and sharedItemObject true for the "Customer interview synthesis" card.

3 evidence probes (Logpoint, ReadSource)
Logpoint {"line":55,"path":"http://localhost:3203/src/store/boardReducer.js","expression":"(() => { const pastCard = past[past.length-1].find(c => c.title && c.title.includes('Customer')); const curCard = cards.find(c => c.id === pastCard.id); return { cardTitle: pastCard.title, pastDone: pastCard.checklist.map(i => i.done), curDone: curCard.checklist.map(i => i.done), sharedItemObject: pastCard.checklist[0] === curCard.checklist[0] }; })()"}
→ Found 1 total hit at http://localhost:3203/src/store/boardReducer.js:55

Hit 1 at point Point:6 @ 57212.16ms:
  Expression: (() => { const pastCard = past[past.length-1].find(c => c.title && c.title.includes('Customer')); const curCard = cards.find(c => c.id === pastCard.id); return { cardTitle: pastCard.title, pastDone: pastCard.checklist.map(i => i.done), curDone: curCard.checklist.map(i => i.done), sharedItemObject: pastCard.checklist[0] === curCard.checklist[0] }; })()
  Result: {
  "cardTitle": "Customer interview synthesis",
  "pastDone": [
    true,
    false
  ],
  "curDone": [
    true,
    false
  ],
  "sharedItemObject": true
}
ReadSource {"line":48,"path":"http://localhost:3203/src/store/boardReducer.js"}
→  hits | L | code 
------|------|------
     |   23 |         board: action.board, 
     |   24 |         columns: action.columns, 
     |   25 |         cards: action.cards, 
     |   26 |       }; 
     |   27 |  
     |   28 |     case 'LOAD_FAILED': 
   0 |   29 |       return { ...state, status: state.cards.length ? 'ready' : 'error', error: action.error }; 
     |   30 |  
     |   31 |     case 'MOVE_CARD': { 
   0 |   32 |       const card = state.cards.find((c) => c.id === action.cardId); 
   0 |   33 |       if (!card || card.columnId === action.columnId) return state; 
   0 |   34 |       return { 
     |   35 |         ...state, 
   0 |   36 |         past: record(state.past, state.cards), 
   0 |   37 |         cards: [...state.cards.filter((c) => c.id !== card.id), { ...card, columnId: action.columnId }], 
     |   38 |       }; 
     |   39 |     } 
     |   40 |  
     |   41 |     case 'UPDATE_CARD': 
   0 |   42 |       return { 
     |   43 |         ...state, 
   0 |   44 |         past: record(state.past, state.cards), 
   0 |   45 |         cards: replaceCard(state.cards, action.cardId, (card) => ({ ...card, ...action.changes })), 
     |   46 |       }; 
     |   47 |  
     |  *48 |     case 'SET_CHECK_ITEM': { 
   1 |   49 |       const past = record(state.past, state.cards); 
   1 |   50 |       const cards = replaceCard(state.cards, action.cardId, (card) => { 
   1 |   51 |         const item = card.checklist.find((i) => i.id === action.itemId); 
   …
ReadSource {"line":3,"path":"http://localhost:3203/src/store/history.js"}
→  hits | L | code 
------|------|------
   1 |    1 | const LIMIT = 50; 
     |    2 |  
     |   *3 | export function snapshot(cards) { 
  18 |    4 |   return cards.map((card) => (/*<HIT>*/{ ...card })); 
     |    5 | } 
     |    6 |  
     |    7 | export function record(past, cards) { 
   1 |    8 |   const next = [...past, snapshot(cards)]; 
   0 |    9 |   return next.length > LIMIT ? next./*<HIT>*/slice(next.length - LIMIT) : next; 
     |   10 | } 
     |   11 |  
2

Clicking Undo dispatches UNDO which restores cards from state.past[state.past.length-1] and slices the past entry; since the restored snapshot already contains done:true, the Review card badge stays "☑ 1/2" while the history entry is consumed — the Evaluate at the final frame returns undoDisabled:true and the badge text "☑ 1/2" still present.

2 evidence probes (Evaluate, ReadSource)
Evaluate {"scope":"global","expression":"(() => { const badgeEls = Array.from(document.querySelectorAll('*')).filter(e => e.children.length === 0 && /☑|✓/.test(e.textContent || '') && /\\d\\/\\d/.test(e.textContent || '')); const undoBtn = Array.from(document.querySelectorAll('button')).find(b => b.textContent.includes('Undo')); return { badgeTexts: badgeEls.map(e => e.textContent.trim()).filter(t => /1\\/2|0\\/2/.test(t)), undoDisabled: undoBtn ? undoBtn.disabled : null }; })()"}
→ Evaluated at Point:33 (last rendered frame, scope global).
{
  "badgeTexts": [
    "☑ 0/2",
    "☑ 0/2",
    "☑ 1/2"
  ],
  "undoDisabled": true
}
ReadSource {"line":86,"path":"http://localhost:3203/src/store/boardReducer.js"}
→  hits | L | code 
------|------|------
     |   48 |     case 'SET_CHECK_ITEM': { 
   1 |   49 |       const past = record(state.past, state.cards); 
   1 |   50 |       const cards = replaceCard(state.cards, action.cardId, (card) => { 
   1 |   51 |         const item = card.checklist.find((i) => i.id === action.itemId); 
   1 |   52 |         if (item) item.done = action.done; 
   1 |   53 |         return { ...card, checklist: [...card.checklist] }; 
     |   54 |       }); 
   1 |   55 |       return { ...state, past, cards }; 
     |   56 |     } 
     |   57 |  
     |   58 |     case 'ADD_CHECK_ITEM': 
   0 |   59 |       return { 
     |   60 |         ...state, 
   0 |   61 |         cards: replaceCard(state.cards, action.cardId, (card) => ({ 
     |   62 |           ...card, 
     |   63 |           checklist: [...card.checklist, action.item], 
     |   64 |         })), 
     |   65 |       }; 
     |   66 |  
     |   67 |     case 'DELETE_CARD': 
   0 |   68 |       return { 
     |   69 |         ...state, 
   0 |   70 |         past: record(state.past, state.cards), 
   0 |   71 |         cards: state.cards.filter((c) => c.id !== action.cardId), 
     |   72 |       }; 
     |   73 |  
     |   74 |     case 'CARD_ADDED': 
   0 |   75 |       return { ...state, cards: [...state.cards, action.card] }; 
     |   76 |  
     |   77 |     case 'CARD_SAVED': { 
   0 |   78 |       const existing = state.cards.find((c) => c.id === action.card.id); 
   0 |   79 |     …

Is your app hiding bugs like this?

Replay QA explores every build, records what it finds, and writes the root cause.