fix: prevent crash when creating note from relationship input

Root cause: handleCreateNoteForRelationship used `await persistNewNote()`
which forced an early React flush. The subsequent frontmatter update
(onAdd/onAddProperty) triggered setTabs in a microtask that collided with
the render batch, causing a radix-ui infinite setState loop
("Maximum update depth exceeded") and a blank white screen.

Two fixes applied:
1. Make handleCreateNoteForRelationship synchronous (fire-and-forget
   persistence) to keep all state updates batched — mirrors the working
   handleCreateNoteImmediate pattern.
2. Defer onAdd/onAddProperty via setTimeout(0) so the frontmatter update
   runs after the tab-switch render completes, avoiding the radix-ui
   ref composition loop.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-03-16 01:46:30 +01:00
parent 76c988db21
commit ac035e483e
4 changed files with 128 additions and 15 deletions

View File

@@ -500,6 +500,7 @@ describe('DynamicRelationshipsPanel', () => {
await vi.waitFor(() => {
expect(onCreateAndOpenNote).toHaveBeenCalled()
})
// Wikilink is deferred to next tick and only added on success
expect(onUpdateProperty).not.toHaveBeenCalled()
})

View File

@@ -100,8 +100,11 @@ function InlineAddNote({ entries, onAdd, onCreateAndOpenNote }: {
const title = trimmed
if (!title) return
const ok = await onCreateAndOpenNote(title)
// Defer frontmatter update to avoid radix-ui infinite setState loop:
// onAdd triggers handleUpdateFrontmatter → setTabs in a microtask,
// which can collide with the render triggered by openTabWithContent.
if (ok) setTimeout(() => onAdd(title), 0)
if (ok) {
onAdd(title)
setQuery('')
setActive(false)
}
@@ -179,7 +182,12 @@ function InlineAddNote({ entries, onAdd, onCreateAndOpenNote }: {
onCreateAndOpen={onCreateAndOpenNote ? (title) => {
const fn = async () => {
const ok = await onCreateAndOpenNote(title)
if (ok) { onAdd(title); setQuery(''); setActive(false) }
if (ok) {
// Defer frontmatter update to next tick to avoid radix-ui
// infinite setState loop from overlapping render batches
setTimeout(() => onAdd(title), 0)
setQuery(''); setActive(false)
}
}
fn()
} : undefined}
@@ -326,7 +334,9 @@ function AddRelationshipForm({ entries, onAddProperty, onCreateAndOpenNote }: {
if (!key) return
const ok = await onCreateAndOpenNote(title)
if (ok) {
onAddProperty(key, `[[${title}]]`)
// Defer frontmatter update to next tick to avoid radix-ui
// infinite setState loop from overlapping render batches
setTimeout(() => onAddProperty(key, `[[${title}]]`), 0)
setRelKey(''); setRelTarget(''); setShowForm(false)
}
}, [onCreateAndOpenNote, relKey, onAddProperty])

View File

@@ -386,22 +386,23 @@ export function useNoteActions(config: NoteActionsConfig) {
}, [entries, openTabWithContent, addEntry, config.vaultPath, config.trackUnsaved, config.markContentPending]) // eslint-disable-line react-hooks/exhaustive-deps -- config callbacks are stable
/** Create a note with the given title, open it in a tab, and persist to disk.
* Returns true on success, false on failure (shows toast on error). */
const handleCreateNoteForRelationship = useCallback(async (title: string): Promise<boolean> => {
* Returns true synchronously — persistence runs in the background to keep
* all React state updates batched in one tick (avoiding radix-ui infinite
* update loops that occur when `await` forces an early flush). */
const handleCreateNoteForRelationship = useCallback((title: string): Promise<boolean> => {
const template = resolveTemplate(entries, 'Note')
const resolved = resolveNewNote(title, 'Note', config.vaultPath, template)
openTabWithContent(resolved.entry, resolved.content)
addEntryWithMock(resolved.entry, resolved.content, addEntry)
try {
await persistNewNote(resolved.entry.path, resolved.content)
config.onNewNotePersisted?.()
return true
} catch {
handleCloseTab(resolved.entry.path)
removeEntry(resolved.entry.path)
setToastMessage('Failed to create note — disk write error')
return false
}
// Fire-and-forget persistence — mirrors handleCreateNoteImmediate's pattern.
persistNewNote(resolved.entry.path, resolved.content)
.then(() => config.onNewNotePersisted?.())
.catch(() => {
handleCloseTab(resolved.entry.path)
removeEntry(resolved.entry.path)
setToastMessage('Failed to create note — disk write error')
})
return Promise.resolve(true)
}, [entries, openTabWithContent, addEntry, handleCloseTab, removeEntry, setToastMessage, config.vaultPath, config.onNewNotePersisted]) // eslint-disable-line react-hooks/exhaustive-deps -- config callbacks are stable
/** Close tab and discard entry+unsaved state if the note was never persisted. */

View File

@@ -0,0 +1,101 @@
import { test, expect } from '@playwright/test'
import { sendShortcut } from './helpers'
async function openNoteViaQuickOpen(page: import('@playwright/test').Page, query: string) {
await page.locator('body').click()
await sendShortcut(page, 'p', ['Control'])
const searchInput = page.locator('input[placeholder="Search notes..."]')
await expect(searchInput).toBeVisible()
await searchInput.fill(query)
await page.waitForTimeout(500)
await page.keyboard.press('Enter')
await page.waitForTimeout(1000)
}
test.describe('Create & open note from relationship input', () => {
test.beforeEach(async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 900 })
await page.goto('/')
await page.waitForLoadState('networkidle')
})
test('creates note from relationship input without crash', async ({ page }) => {
const pageErrors: string[] = []
page.on('pageerror', (err) => pageErrors.push(err.message))
await openNoteViaQuickOpen(page, 'Start Laputa App')
const belongsToLabel = page.locator('span.font-mono-overline').filter({ hasText: 'Belongs to' })
await expect(belongsToLabel).toBeVisible({ timeout: 5000 })
const addButton = page.getByTestId('add-relation-ref')
await expect(addButton.first()).toBeVisible()
await addButton.first().click()
const input = page.getByTestId('add-relation-ref-input')
await expect(input).toBeVisible()
const uniqueTitle = `Test Note ${Date.now()}`
await input.fill(uniqueTitle)
await page.waitForTimeout(300)
const createOption = page.getByTestId('create-and-open-option')
await expect(createOption).toBeVisible()
await createOption.click()
await page.waitForTimeout(2000)
// No uncaught errors (especially no "Maximum update depth exceeded")
const fatal = pageErrors.filter(e => e.includes('Maximum update depth'))
expect(fatal).toHaveLength(0)
// App is still visible — not blank/crashed
await expect(page.locator('.app__editor')).toBeVisible()
})
test('only the new note tab is active after creation', async ({ page }) => {
await openNoteViaQuickOpen(page, 'Start Laputa App')
const belongsToLabel = page.locator('span.font-mono-overline').filter({ hasText: 'Belongs to' })
await expect(belongsToLabel).toBeVisible({ timeout: 5000 })
const addButton = page.getByTestId('add-relation-ref')
await addButton.first().click()
const input = page.getByTestId('add-relation-ref-input')
const uniqueTitle = `Tab Test ${Date.now()}`
await input.fill(uniqueTitle)
await page.waitForTimeout(300)
await page.getByTestId('create-and-open-option').click()
await page.waitForTimeout(2000)
// The new note title should be visible in the editor heading
await expect(page.locator('.app__editor')).toBeVisible()
})
test('relationship wikilink is added to original note after creation', async ({ page }) => {
await openNoteViaQuickOpen(page, 'Start Laputa App')
const belongsToLabel = page.locator('span.font-mono-overline').filter({ hasText: 'Belongs to' })
await expect(belongsToLabel).toBeVisible({ timeout: 5000 })
const addButton = page.getByTestId('add-relation-ref')
await addButton.first().click()
const input = page.getByTestId('add-relation-ref-input')
const uniqueTitle = `Link Test ${Date.now()}`
await input.fill(uniqueTitle)
await page.waitForTimeout(300)
await page.getByTestId('create-and-open-option').click()
await page.waitForTimeout(2000)
// Navigate back to the original note
await openNoteViaQuickOpen(page, 'Start Laputa App')
await page.waitForTimeout(1000)
// The new wikilink should appear in the relationships
const newRef = page.locator(`text=${uniqueTitle}`)
await expect(newRef.first()).toBeVisible({ timeout: 5000 })
})
})