diff --git a/src/components/InspectorPanels.test.tsx b/src/components/InspectorPanels.test.tsx index 2a95c585..9570fc6e 100644 --- a/src/components/InspectorPanels.test.tsx +++ b/src/components/InspectorPanels.test.tsx @@ -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() }) diff --git a/src/components/inspector/RelationshipsPanel.tsx b/src/components/inspector/RelationshipsPanel.tsx index 7f89d3fa..35392aa6 100644 --- a/src/components/inspector/RelationshipsPanel.tsx +++ b/src/components/inspector/RelationshipsPanel.tsx @@ -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]) diff --git a/src/hooks/useNoteActions.ts b/src/hooks/useNoteActions.ts index 1ce6c158..87dcdb47 100644 --- a/src/hooks/useNoteActions.ts +++ b/src/hooks/useNoteActions.ts @@ -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 => { + * 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 => { 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. */ diff --git a/tests/smoke/create-open-relationship-note.spec.ts b/tests/smoke/create-open-relationship-note.spec.ts new file mode 100644 index 00000000..8ca7f032 --- /dev/null +++ b/tests/smoke/create-open-relationship-note.spec.ts @@ -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 }) + }) +})