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:
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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. */
|
||||
|
||||
101
tests/smoke/create-open-relationship-note.spec.ts
Normal file
101
tests/smoke/create-open-relationship-note.spec.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user