fix(mobile): let an unmounted quick-command row emit nothing on a refusal

The haptic I added ran before the mounted guard, so a copy pressed on a row that
then scrolled out of the list, or a sheet closed over it, still buzzed when the
rejection arrived. A buzz with no row to explain it is feedback for nothing, and
the guard was already there for the feedback state one line below.

Red-first: press, unmount, then reject. The success path already guarded first.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-20 08:59:48 -04:00
parent 60ecda47af
commit d1d9a59288
2 changed files with 41 additions and 13 deletions
+36 -10
View File
@@ -87,18 +87,20 @@ describe('the quick-command row when the pasteboard refuses the text', () => {
return button
}
function rowProps() {
return {
command: COMMAND,
first: true,
onLaunch: vi.fn(),
onEdit: vi.fn(),
onDelete: vi.fn(),
disabled: false
}
}
async function mountAndCopy(): Promise<void> {
await act(async () => {
renderer = create(
createElement(QuickCommandRow, {
command: COMMAND,
first: true,
onLaunch: vi.fn(),
onEdit: vi.fn(),
onDelete: vi.fn(),
disabled: false
})
)
renderer = create(createElement(QuickCommandRow, rowProps()))
})
await act(async () => {
copyButton().props.onPress()
@@ -112,6 +114,30 @@ describe('the quick-command row when the pasteboard refuses the text', () => {
expect(haptics.notificationAsync).toHaveBeenCalledWith('error')
})
it('emits nothing at all when the row is gone before the refusal arrives', async () => {
// A copy pressed on a row that then scrolls out of the list, or a sheet closed over it. The
// rejection still arrives, and a buzz with no row to explain it is feedback for nothing.
let refuse: ((error: Error) => void) | undefined
clipboard.setStringAsync.mockImplementation(
() =>
new Promise((_resolve, reject) => {
refuse = reject
})
)
await act(async () => {
renderer = create(createElement(QuickCommandRow, rowProps()))
})
await act(async () => {
copyButton().props.onPress()
})
act(() => renderer?.unmount())
renderer = null
await act(async () => {
refuse?.(new Error('pasteboard refused'))
})
expect(haptics.notificationAsync).not.toHaveBeenCalled()
})
it('shows the copied label and no error buzz when the write lands', async () => {
// The control: a failure assertion is only evidence if the success path reads differently.
clipboard.setStringAsync.mockResolvedValue(true)
+5 -3
View File
@@ -75,12 +75,14 @@ export function QuickCommandRow({
}
setFeedback({ body, status: 'copied' })
} catch {
// The row says so on its own control rather than in a toast; the buzz is the part a thumb
// resting on the button it just pressed can notice without looking.
triggerError()
// The guard first: a row unmounted before the refusal arrives has nothing to explain a buzz
// with, and the feedback it would set is read by a component that is gone.
if (!mountedRef.current) {
return
}
// The row says so on its own control rather than in a toast; the buzz is the part a thumb
// resting on the button it just pressed can notice without looking.
triggerError()
setFeedback({ body, status: 'failed' })
}
if (copyResetTimerRef.current) {