fix(notes): serialize immediate note creation writes

This commit is contained in:
lucaronin
2026-05-02 03:56:48 +02:00
parent db16f2ff5e
commit c9698b853e
4 changed files with 154 additions and 20 deletions

View File

@@ -220,7 +220,11 @@ describe('useNoteActions hook', () => {
await flushAsyncWork()
})
await act(async () => {
vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS * 2)
vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS)
await flushAsyncWork()
})
await act(async () => {
vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS)
await flushAsyncWork()
})
@@ -471,7 +475,11 @@ describe('useNoteActions hook', () => {
await flushAsyncWork()
})
await act(async () => {
vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS * 2)
vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS)
await flushAsyncWork()
})
await act(async () => {
vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS)
await flushAsyncWork()
})

View File

@@ -261,7 +261,11 @@ describe('useNoteCreation hook', () => {
await flushImmediateCreate()
})
await act(async () => {
vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS * 2)
vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS)
await flushImmediateCreate()
})
await act(async () => {
vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS)
await flushImmediateCreate()
})
const filenames = addEntry.mock.calls.map(([e]: [VaultEntry]) => e.filename)
@@ -326,6 +330,49 @@ describe('useNoteCreation hook', () => {
vi.restoreAllMocks()
})
it('waits for slow immediate note persistence before starting the queued create', async () => {
vi.useFakeTimers()
vi.mocked(isTauri).mockReturnValue(true)
vi.spyOn(Date, 'now').mockReturnValue(1700000000000)
let resolveFirstWrite: () => void
const firstWrite = new Promise<void>((resolve) => {
resolveFirstWrite = resolve
})
vi.mocked(invoke)
.mockImplementationOnce(() => firstWrite)
.mockResolvedValue(undefined)
const createCalls = () => vi.mocked(invoke).mock.calls.filter(([command]) => command === 'create_note_content')
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
await act(async () => {
result.current.handleCreateNoteImmediate()
result.current.handleCreateNoteImmediate()
await flushImmediateCreate()
})
expect(createCalls()).toHaveLength(1)
await act(async () => {
vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS * 3)
await flushImmediateCreate()
})
expect(createCalls()).toHaveLength(1)
await act(async () => {
resolveFirstWrite()
await flushImmediateCreate()
})
await act(async () => {
vi.advanceTimersByTime(RAPID_CREATE_NOTE_SETTLE_MS)
await flushImmediateCreate()
})
expect(createCalls()).toHaveLength(2)
expect(addEntry).toHaveBeenCalledTimes(2)
vi.restoreAllMocks()
})
it('handleCreateNoteImmediate accepts custom type', async () => {
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
await act(async () => {

View File

@@ -536,18 +536,26 @@ function useImmediateCreateQueue(config: ImmediateCreateQueueConfig): (type?: st
const queuedImmediateCreatesRef = useRef<ImmediateCreateRequest[]>([])
const immediateCreateLockedRef = useRef(false)
const immediateCreateTimerRef = useRef<number | null>(null)
const queueMountedRef = useRef(true)
const { latestDepsRef, syncDeps } = useLatestImmediateCreateDeps(config, pendingSlugsRef)
const executeRequest = useCallback((request: ImmediateCreateRequest) => {
const executeRequest = useCallback(async (request: ImmediateCreateRequest): Promise<void> => {
const deps = latestDepsRef.current
if (!deps) return
void createNoteImmediate(deps, request.type).then((didCreate) => trackImmediateCreate(request, didCreate))
try {
const didCreate = await createNoteImmediate(deps, request.type)
trackImmediateCreate(request, didCreate)
} catch (error) {
console.warn('Failed to create immediate note:', error)
}
}, [latestDepsRef])
const scheduleQueuedBurst = useCallback(function scheduleQueuedBurst() {
if (!queueMountedRef.current) return
if (immediateCreateTimerRef.current !== null) return
immediateCreateTimerRef.current = window.setTimeout(() => {
immediateCreateTimerRef.current = window.setTimeout(async () => {
immediateCreateTimerRef.current = null
const next = queuedImmediateCreatesRef.current.shift()
if (!next) {
@@ -555,14 +563,18 @@ function useImmediateCreateQueue(config: ImmediateCreateQueueConfig): (type?: st
return
}
executeRequest(next)
await executeRequest(next)
scheduleQueuedBurst()
}, RAPID_CREATE_NOTE_SETTLE_MS)
}, [executeRequest])
useEffect(() => () => {
if (immediateCreateTimerRef.current !== null) {
window.clearTimeout(immediateCreateTimerRef.current)
useEffect(() => {
queueMountedRef.current = true
return () => {
queueMountedRef.current = false
if (immediateCreateTimerRef.current !== null) {
window.clearTimeout(immediateCreateTimerRef.current)
}
}
}, [])
@@ -575,8 +587,7 @@ function useImmediateCreateQueue(config: ImmediateCreateQueueConfig): (type?: st
}
immediateCreateLockedRef.current = true
executeRequest(request)
scheduleQueuedBurst()
void executeRequest(request).then(scheduleQueuedBurst)
}, [syncDeps, executeRequest, scheduleQueuedBurst])
}

View File

@@ -5,7 +5,10 @@ import { triggerMenuCommand } from './testBridge'
interface CreateNoteProbe {
createCalls: string[]
startedCreateCalls: string[]
getBeforeCreate: string[]
activeCreateCount: number
maxConcurrentCreates: number
}
interface ProbeWindow {
@@ -14,6 +17,7 @@ interface ProbeWindow {
}
let tempVaultDir: string
const CREATE_NOTE_WRITE_DELAY_MS = 350
async function pinFixtureHandlers(page: Page): Promise<void> {
await page.evaluate(() => {
@@ -32,22 +36,34 @@ async function pinFixtureHandlers(page: Page): Promise<void> {
}
async function recordCreateNoteCalls(page: Page): Promise<void> {
await page.evaluate(() => {
await page.evaluate((writeDelayMs) => {
const probeWindow = window as typeof window & ProbeWindow
const handlers = probeWindow.__mockHandlers as Record<string, (args?: unknown) => unknown>
const originalCreate = handlers.create_note_content.bind(handlers)
const probe: CreateNoteProbe = { createCalls: [], getBeforeCreate: [] }
const probe: CreateNoteProbe = {
activeCreateCount: 0,
createCalls: [],
getBeforeCreate: [],
maxConcurrentCreates: 0,
startedCreateCalls: [],
}
probeWindow.__createNoteBackingFileProbe = probe
handlers.create_note_content = async (args?: unknown) => {
const notePath = String((args as { path?: unknown } | undefined)?.path ?? '')
await new Promise((resolve) => setTimeout(resolve, 100))
const result = await originalCreate(args)
probe.createCalls.push(notePath)
return result
probe.startedCreateCalls.push(notePath)
probe.activeCreateCount += 1
probe.maxConcurrentCreates = Math.max(probe.maxConcurrentCreates, probe.activeCreateCount)
try {
await new Promise((resolve) => setTimeout(resolve, writeDelayMs))
return await originalCreate(args)
} finally {
probe.activeCreateCount -= 1
probe.createCalls.push(notePath)
}
}
})
}, CREATE_NOTE_WRITE_DELAY_MS)
}
async function rejectReadsBeforeCreate(page: Page): Promise<void> {
@@ -77,10 +93,36 @@ async function installCreateNoteBackingFileProbe(page: Page): Promise<void> {
async function readProbe(page: Page): Promise<CreateNoteProbe> {
return page.evaluate(() => {
const probeWindow = window as typeof window & ProbeWindow
return probeWindow.__createNoteBackingFileProbe ?? { createCalls: [], getBeforeCreate: [] }
return probeWindow.__createNoteBackingFileProbe ?? {
activeCreateCount: 0,
createCalls: [],
getBeforeCreate: [],
maxConcurrentCreates: 0,
startedCreateCalls: [],
}
})
}
async function dispatchMenuCommandBurst(page: Page, commandId: string, count: number): Promise<void> {
await page.waitForFunction(
() => typeof window.__laputaTest?.dispatchBrowserMenuCommand === 'function',
undefined,
{ timeout: 5_000 },
)
await page.evaluate(({ commandId: id, count: commandCount }) => {
const testWindow = window as typeof window & {
__laputaTest?: { dispatchBrowserMenuCommand?: (commandId: string) => void }
}
const dispatchBrowserMenuCommand = testWindow.__laputaTest?.dispatchBrowserMenuCommand
if (typeof dispatchBrowserMenuCommand !== 'function') {
throw new Error('Tolaria test bridge is missing dispatchBrowserMenuCommand')
}
for (let index = 0; index < commandCount; index += 1) {
dispatchBrowserMenuCommand(id)
}
}, { commandId, count })
}
test.beforeEach(async ({ page }, testInfo) => {
testInfo.setTimeout(60_000)
tempVaultDir = createFixtureVaultCopy()
@@ -112,3 +154,29 @@ test('@smoke creating a note writes its backing file before reload can read it',
expect(errors.filter((message) => message.includes('File does not exist'))).toEqual([])
expect(fs.readFileSync(createCalls[0], 'utf8')).toContain('type: Note')
})
test('@smoke rapid note creation waits for each backing file write', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (error) => errors.push(error.message))
await dispatchMenuCommandBurst(page, 'file-new-note', 2)
await expect.poll(() => readProbe(page), { timeout: 7_000 }).toMatchObject({
createCalls: [
expect.stringMatching(/untitled-note-\d+\.md$/),
expect.stringMatching(/untitled-note-\d+(?:-\d+)?\.md$/),
],
getBeforeCreate: [],
maxConcurrentCreates: 1,
startedCreateCalls: [
expect.stringMatching(/untitled-note-\d+\.md$/),
expect.stringMatching(/untitled-note-\d+(?:-\d+)?\.md$/),
],
})
const { createCalls, getBeforeCreate, maxConcurrentCreates } = await readProbe(page)
expect(new Set(createCalls).size).toBe(2)
expect(getBeforeCreate).toEqual([])
expect(maxConcurrentCreates).toBe(1)
expect(errors.filter((message) => message.includes('#185') || message.includes('Maximum update depth'))).toEqual([])
})