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

0:04.5Toggle 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…
0:08.2After 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…
0:08.2After 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
Verify the starting page
Open the Review-column card whose checklist has zero of two items done
Toggle the checkbox of the first checklist item
Close the card detail dialog
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
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 | 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.