Skip to content

Commit 40f59d5

Browse files
Stvadclaude
andcommitted
fix(mobile): keep the soft keyboard up when tapping between editing blocks
Tapping from one editing block straight into another dropped the soft keyboard and exited edit mode, so it took a second tap to resume editing. `isEditing` is one flag shared across the UI-state surface, owned by whichever block holds `focusedBlockLocation`. On a block->block tap the tapped block's `focusBlock(edit:true)` sets the flag true, while the outgoing editor's blur handler clears it -- an identity-less write that, because it commits asynchronously, can land AFTER the handoff and clobber the flag the new block just set, exiting edit mode entirely. It only misfires under that timing (slow builds, touch emulation, and the iOS path where the soft-keyboard proxy input holds focus), which is why it didn't repro on fast native paths. Fix: `exitEditModeForBlock` reads the focused location INSIDE the tx (commit-consistent -- the same `tx.get` pattern `focusBlock` uses to preserve edit mode) and clears `isEditing` only if this block still owns it, a compare-and-swap. Whichever of the two txs commits second sees the other's effect, so both interleavings settle on the tapped block editing. Unlike a DOM-focus heuristic it's oblivious to where focus physically sits (proxy input, incoming block's shell), so it holds on iOS too. Verified in a Chrome block->block repro driven via the live repo: cold and warm, both directions, and with a body-level proxy-input focus simulating the iOS keyboard-grab path -- edit mode + focus move cleanly to the tapped block every time. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent cc69413 commit 40f59d5

3 files changed

Lines changed: 97 additions & 3 deletions

File tree

src/components/BlockEditor.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Block } from '../data/block'
33
import {
44
editorSelection,
55
editorFocusRequestProp,
6+
exitEditModeForBlock,
67
isFocusedBlock,
78
type EditorSelectionState,
89
} from '@/data/properties.js'
@@ -34,7 +35,7 @@ export const BlockEditor = ({
3435
const cm = useRef<ReactCodeMirrorRef>(null)
3536
const [editorView, setEditorView] = useState<EditorView | null>(null)
3637

37-
const [isEditing, setIsEditing] = useIsEditing()
38+
const [isEditing] = useIsEditing()
3839
const inEditMode = useInEditMode(block.id)
3940
const blockContext = useBlockContext()
4041
const renderScopeId = typeof blockContext.renderScopeId === 'string'
@@ -289,7 +290,11 @@ export const BlockEditor = ({
289290
return
290291
}
291292
if (keepalive === 'yield') return
292-
setIsEditing(false)
293+
// Clear edit mode only if THIS block still owns it. A block→block
294+
// tap may have already handed edit mode to the tapped block; an
295+
// unconditional clear would race that handoff and drop it (the
296+
// "keyboard hides / needs a second tap" bug). See exitEditModeForBlock.
297+
void exitEditModeForBlock(uiStateBlock, block.id, renderScopeId)
293298
})
294299
}}
295300
extensions={mergedExtensions}

src/data/properties.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,39 @@ export const focusBlock = async (
379379
}, {scope: ChangeScope.UiState, description: 'focus block'})
380380
}
381381

382+
/** Exit edit mode on behalf of `blockId` — but only if that block still
383+
* owns edit mode when the tx commits.
384+
*
385+
* `isEditing` is a single flag shared across the UI-state surface, so an
386+
* unconditional clear is identity-less: it can't tell *whose* edit mode it
387+
* ends. During a block→block tap the tapped block's `focusBlock(edit:true)`
388+
* and the outgoing editor's blur-driven exit race. An anonymous clear that
389+
* commits *after* the handoff clobbers the flag the new block just set,
390+
* dropping edit mode entirely (on a soft keyboard it hides, needing a
391+
* second tap) — and it only misfires under that timing, which is why it
392+
* doesn't repro on fast/native paths.
393+
*
394+
* Reading the focused location INSIDE the tx (commit-consistent — the same
395+
* `tx.get` pattern `focusBlock` uses to preserve edit mode) makes this a
396+
* compare-and-swap: whichever of the two txs commits second sees the
397+
* other's effect, so both interleavings settle on the tapped block editing.
398+
* Unlike a DOM-focus heuristic it's oblivious to *where* focus physically
399+
* sits (the iOS soft-keyboard proxy input, the incoming block's shell, …). */
400+
export const exitEditModeForBlock = async (
401+
uiStateBlock: Block,
402+
blockId: string,
403+
renderScopeId?: string,
404+
): Promise<void> => {
405+
await uiStateBlock.repo.tx(async tx => {
406+
const location = focusedBlockLocationFromProperties((await tx.get(uiStateBlock.id))?.properties)
407+
// Another block owns the focused location now (or a different render-scope
408+
// copy of this block does) → the handoff already moved on; not ours to clear.
409+
if (location && location.blockId !== blockId) return
410+
if (location && renderScopeId !== undefined && location.renderScopeId !== renderScopeId) return
411+
await tx.setProperty(uiStateBlock.id, isEditingProp, false)
412+
}, {scope: ChangeScope.UiState, description: 'exit edit mode'})
413+
}
414+
382415
export const requestEditorFocus = (uiStateBlock: Block): void => {
383416
const current = uiStateBlock.peekProperty(editorFocusRequestProp) ?? 0
384417
void uiStateBlock.set(editorFocusRequestProp, current + 1)

src/data/test/focusBlock.test.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@
22

33
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'
44
import { ChangeScope } from '@/data/api'
5-
import { focusBlock, focusedBlockLocationProp, isEditingProp, topLevelBlockIdProp } from '@/data/properties'
5+
import {
6+
exitEditModeForBlock,
7+
focusBlock,
8+
focusedBlockLocationProp,
9+
isEditingProp,
10+
topLevelBlockIdProp,
11+
} from '@/data/properties'
612
import { Repo } from '@/data/repo'
713
import { createTestDb, resetTestDb, type TestDb } from '@/data/test/createTestDb'
814
import { createTestRepo } from '@/data/test/createTestRepo'
@@ -79,3 +85,53 @@ describe('focusBlock', () => {
7985
expect(uiStateBlock.peekProperty(isEditingProp)).toBe(false)
8086
})
8187
})
88+
89+
describe('exitEditModeForBlock', () => {
90+
it('clears the edit flag when this block still owns edit mode (a genuine tap-away)', async () => {
91+
const uiStateBlock = env.repo.block('ui')
92+
await focusBlock(uiStateBlock, 'note-1', {edit: true, renderScopeId: NOTE_SCOPE})
93+
94+
await exitEditModeForBlock(uiStateBlock, 'note-1', NOTE_SCOPE)
95+
96+
expect(uiStateBlock.peekProperty(isEditingProp)).toBe(false)
97+
// The focus location is left intact — only the edit flag is cleared.
98+
expect(uiStateBlock.peekProperty(focusedBlockLocationProp)).toEqual({
99+
blockId: 'note-1',
100+
renderScopeId: NOTE_SCOPE,
101+
})
102+
})
103+
104+
it('is a no-op once another block has taken over edit mode (block→block handoff)', async () => {
105+
const uiStateBlock = env.repo.block('ui')
106+
// note-1 is editing; a tap on note-2 hands edit mode over (focusBlock
107+
// commits first) — then note-1's outgoing editor blurs and tries to exit.
108+
// The stale clear must NOT clobber note-2's edit mode (else the keyboard
109+
// drops and it takes a second tap to resume). This is the exact ordering
110+
// an unconditional `setIsEditing(false)` got wrong.
111+
await focusBlock(uiStateBlock, 'note-1', {edit: true, renderScopeId: NOTE_SCOPE})
112+
await focusBlock(uiStateBlock, 'note-2', {edit: true, renderScopeId: NOTE_SCOPE})
113+
114+
await exitEditModeForBlock(uiStateBlock, 'note-1', NOTE_SCOPE)
115+
116+
expect(uiStateBlock.peekProperty(isEditingProp)).toBe(true)
117+
expect(uiStateBlock.peekProperty(focusedBlockLocationProp)).toEqual({
118+
blockId: 'note-2',
119+
renderScopeId: NOTE_SCOPE,
120+
})
121+
})
122+
123+
it('is a no-op when another render-scope copy of the same block owns edit mode', async () => {
124+
const uiStateBlock = env.repo.block('ui')
125+
const EMBED_SCOPE = 'embed:xyz'
126+
await focusBlock(uiStateBlock, 'note-1', {edit: true, renderScopeId: NOTE_SCOPE})
127+
await focusBlock(uiStateBlock, 'note-1', {edit: true, renderScopeId: EMBED_SCOPE})
128+
129+
await exitEditModeForBlock(uiStateBlock, 'note-1', NOTE_SCOPE)
130+
131+
expect(uiStateBlock.peekProperty(isEditingProp)).toBe(true)
132+
expect(uiStateBlock.peekProperty(focusedBlockLocationProp)).toEqual({
133+
blockId: 'note-1',
134+
renderScopeId: EMBED_SCOPE,
135+
})
136+
})
137+
})

0 commit comments

Comments
 (0)