fix: prevent cmd+n note creation crash

This commit is contained in:
lucaronin
2026-04-11 12:30:05 +02:00
parent 272f2c0b3c
commit 69e520b5aa
3 changed files with 141 additions and 65 deletions

View File

@@ -1,5 +1,22 @@
import { describe, it, expect, vi } from 'vitest'
import { dispatchMenuEvent, type MenuEventHandlers } from './useMenuEvents'
import { renderHook } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useMenuEvents, dispatchMenuEvent, type MenuEventHandlers } from './useMenuEvents'
const isTauriMock = vi.fn(() => false)
const listenMock = vi.fn()
const invokeMock = vi.fn().mockResolvedValue(undefined)
vi.mock('../mock-tauri', () => ({
isTauri: () => isTauriMock(),
}))
vi.mock('@tauri-apps/api/event', () => ({
listen: (...args: unknown[]) => listenMock(...args),
}))
vi.mock('@tauri-apps/api/core', () => ({
invoke: (...args: unknown[]) => invokeMock(...args),
}))
function makeHandlers(): MenuEventHandlers {
return {
@@ -43,6 +60,36 @@ function makeHandlers(): MenuEventHandlers {
}
}
describe('useMenuEvents', () => {
beforeEach(() => {
vi.clearAllMocks()
isTauriMock.mockReturnValue(false)
})
it('cleans up a native menu listener even if unmounted before listen resolves', async () => {
isTauriMock.mockReturnValue(true)
let resolveListen: ((teardown: () => void) => void) | null = null
const teardown = vi.fn()
listenMock.mockImplementationOnce(() => new Promise((resolve) => {
resolveListen = resolve
}))
const { unmount } = renderHook(() => useMenuEvents(makeHandlers()))
await vi.dynamicImportSettled()
expect(listenMock).toHaveBeenCalledTimes(1)
unmount()
resolveListen?.(teardown)
await vi.dynamicImportSettled()
expect(teardown).toHaveBeenCalledTimes(1)
})
})
describe('dispatchMenuEvent', () => {
// View mode events
it('view-editor-only sets editor-only mode', () => {

View File

@@ -57,50 +57,54 @@ function syncNativeMenuState(state: MenuStatePayload): void {
.catch(() => {})
}
/** Dispatch a Tauri menu event ID to the matching handler. Exported for testing. */
export function dispatchMenuEvent(id: string, h: MenuEventHandlers): void {
if (!isAppCommandId(id)) return
dispatchAppCommand(id, h)
}
/** Listen for native macOS menu events and dispatch them to the appropriate handlers. */
export function useMenuEvents(handlers: MenuEventHandlers) {
const ref = useRef(handlers)
ref.current = handlers
const hasActiveNote = handlers.activeTabPath !== null
const hasModifiedFiles = handlers.modifiedCount != null ? handlers.modifiedCount > 0 : undefined
const hasConflicts = handlers.conflictCount != null ? handlers.conflictCount > 0 : undefined
const hasRestorableDeletedNote = handlers.hasRestorableDeletedNote
// Subscribe once to Tauri menu events
function useNativeMenuEventListener(handlersRef: { current: MenuEventHandlers }) {
useEffect(() => {
if (!isTauri()) return
let cleanup: (() => void) | undefined
import('@tauri-apps/api/event').then(({ listen }) => {
const unlisten = listen<string>('menu-event', (event) => {
dispatchMenuEvent(event.payload, ref.current)
let disposed = false
let unlisten: (() => void) | null = null
import('@tauri-apps/api/event')
.then(async ({ listen }) => {
const teardown = await listen<string>('menu-event', (event) => {
dispatchMenuEvent(event.payload, handlersRef.current)
})
if (disposed) {
teardown()
return
}
unlisten = teardown
})
.catch(() => {
/* not in Tauri */
})
cleanup = () => { unlisten.then(fn => fn()) }
}).catch(() => { /* not in Tauri */ })
return () => cleanup?.()
}, [])
return () => {
disposed = true
unlisten?.()
}
}, [handlersRef])
}
function useWindowAppCommandListener(handlersRef: { current: MenuEventHandlers }) {
useEffect(() => {
const handleCommandEvent = createWindowCommandListener((detail) => {
if (isAppCommandId(detail)) {
dispatchAppCommand(detail, ref.current)
dispatchAppCommand(detail, handlersRef.current)
}
})
window.addEventListener(APP_COMMAND_EVENT_NAME, handleCommandEvent)
return () => window.removeEventListener(APP_COMMAND_EVENT_NAME, handleCommandEvent)
}, [])
}, [handlersRef])
}
function useTestMenuCommandBridge(handlersRef: { current: MenuEventHandlers }) {
useEffect(() => {
const bridge = (id: string) => {
dispatchMenuEvent(id, ref.current)
dispatchMenuEvent(id, handlersRef.current)
}
window.__laputaTest = {
@@ -113,15 +117,40 @@ export function useMenuEvents(handlers: MenuEventHandlers) {
delete window.__laputaTest.dispatchBrowserMenuCommand
}
}
}, [])
// Sync menu item enabled state when active note or git state changes
useEffect(() => {
syncNativeMenuState({
hasActiveNote,
hasModifiedFiles,
hasConflicts,
hasRestorableDeletedNote,
})
}, [hasActiveNote, hasModifiedFiles, hasConflicts, hasRestorableDeletedNote])
}, [handlersRef])
}
function useNativeMenuStateSync(state: MenuStatePayload) {
useEffect(() => {
syncNativeMenuState(state)
}, [state])
}
/** Dispatch a Tauri menu event ID to the matching handler. Exported for testing. */
export function dispatchMenuEvent(id: string, h: MenuEventHandlers): void {
if (!isAppCommandId(id)) return
dispatchAppCommand(id, h)
}
/** Listen for native macOS menu events and dispatch them to the appropriate handlers. */
export function useMenuEvents(handlers: MenuEventHandlers) {
const ref = useRef(handlers)
const hasActiveNote = handlers.activeTabPath !== null
const hasModifiedFiles = handlers.modifiedCount != null ? handlers.modifiedCount > 0 : undefined
const hasConflicts = handlers.conflictCount != null ? handlers.conflictCount > 0 : undefined
const hasRestorableDeletedNote = handlers.hasRestorableDeletedNote
useEffect(() => {
ref.current = handlers
}, [handlers])
useNativeMenuEventListener(ref)
useWindowAppCommandListener(ref)
useTestMenuCommandBridge(ref)
useNativeMenuStateSync({
hasActiveNote,
hasModifiedFiles,
hasConflicts,
hasRestorableDeletedNote,
})
}

View File

@@ -310,25 +310,23 @@ export function useNoteCreation(config: NoteCreationConfig, tabDeps: CreationTab
const queuedImmediateCreatesRef = useRef<ImmediateCreateRequest[]>([])
const immediateCreateLockedRef = useRef(false)
const immediateCreateTimerRef = useRef<number | null>(null)
const latestImmediateCreateDepsRef = useRef<ImmediateCreateDeps>({
entries,
vaultPath: config.vaultPath,
pendingSlugs: pendingSlugsRef.current,
openTabWithContent,
addEntry,
trackUnsaved: config.trackUnsaved,
markContentPending: config.markContentPending,
})
const latestImmediateCreateDepsRef = useRef<ImmediateCreateDeps | null>(null)
latestImmediateCreateDepsRef.current = {
entries,
vaultPath: config.vaultPath,
pendingSlugs: pendingSlugsRef.current,
openTabWithContent,
addEntry,
trackUnsaved: config.trackUnsaved,
markContentPending: config.markContentPending,
}
const syncImmediateCreateDeps = useCallback(() => {
latestImmediateCreateDepsRef.current = {
entries,
vaultPath: config.vaultPath,
pendingSlugs: pendingSlugsRef.current,
openTabWithContent,
addEntry,
trackUnsaved: config.trackUnsaved,
markContentPending: config.markContentPending,
}
}, [entries, config.vaultPath, openTabWithContent, addEntry, config.trackUnsaved, config.markContentPending])
useEffect(() => {
syncImmediateCreateDeps()
}, [syncImmediateCreateDeps])
const persistNew: PersistFn = useCallback(
(resolved) => createAndPersist(resolved, addEntry, openTabWithContent, {
@@ -347,12 +345,13 @@ export function useNoteCreation(config: NoteCreationConfig, tabDeps: CreationTab
}, [entries, persistNew, config.vaultPath])
const executeImmediateCreateRequest = useCallback((request: ImmediateCreateRequest) => {
createNoteImmediate(latestImmediateCreateDepsRef.current, request.type)
const deps = latestImmediateCreateDepsRef.current
if (!deps) return
createNoteImmediate(deps, request.type)
trackEvent('note_created', { has_type: request.type ? 1 : 0, creation_path: request.type ? 'type_section' : 'cmd_n' })
}, [])
const continueImmediateCreateBurstRef = useRef<() => void>(() => {})
continueImmediateCreateBurstRef.current = () => {
const continueImmediateCreateBurst = useCallback(function scheduleImmediateCreateBurst() {
if (immediateCreateTimerRef.current !== null) return
immediateCreateTimerRef.current = window.setTimeout(() => {
@@ -364,11 +363,12 @@ export function useNoteCreation(config: NoteCreationConfig, tabDeps: CreationTab
}
executeImmediateCreateRequest(next)
continueImmediateCreateBurstRef.current()
scheduleImmediateCreateBurst()
}, RAPID_CREATE_NOTE_SETTLE_MS)
}
}, [executeImmediateCreateRequest])
const handleCreateNoteImmediate = useCallback((type?: string) => {
syncImmediateCreateDeps()
const request = { type }
if (immediateCreateLockedRef.current) {
queuedImmediateCreatesRef.current.push(request)
@@ -377,8 +377,8 @@ export function useNoteCreation(config: NoteCreationConfig, tabDeps: CreationTab
immediateCreateLockedRef.current = true
executeImmediateCreateRequest(request)
continueImmediateCreateBurstRef.current()
}, [executeImmediateCreateRequest])
continueImmediateCreateBurst()
}, [syncImmediateCreateDeps, executeImmediateCreateRequest, continueImmediateCreateBurst])
useEffect(() => () => {
if (immediateCreateTimerRef.current !== null) {