Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions frontend/e2e/atlas-authoring.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ test('atlas creation core: tray, placement popover, right-click create, sticky n
await expect(draftTextarea).toHaveCount(0)
const sticky = page.getByTestId('atlas-sticky-note').filter({ hasText: 'ZzE2eStickyNoteText' })
await expect(sticky).toBeVisible()
// Regression: one blur commits exactly ONE note (the same
// StrictMode updater-side-effect class as the card popover).
await expect(sticky).toHaveCount(1)
// No kind chip (a card's own front face always renders one) and a
// distinct node type from a card.
await expect(sticky.locator('[data-testid="atlas-note-file-tag"]')).toHaveCount(0)
Expand Down Expand Up @@ -139,6 +142,11 @@ test('atlas creation core: tray, placement popover, right-click create, sticky n
await popover.getByTestId('atlas-placement-submit').click()
await expect(popover).not.toBeVisible()
await expect(noteCard(page, 'ZzE2eRootCard')).toBeVisible()
// Regression: one confirm creates exactly ONE card. StrictMode
// double-invokes setState updater functions, so a service call
// living inside one fires twice -- every C-flow confirm produced
// duplicate cards until the call moved out of the updater.
await expect(noteCard(page, 'ZzE2eRootCard')).toHaveCount(1)

// --- After zooming into an area, repeat -> the new card's parent
// IS that area, not root: "Example area" seeds exactly 2 children
Expand Down
32 changes: 21 additions & 11 deletions frontend/src/atlas/useAtlasCreation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,9 +179,19 @@ export function useAtlasCreation({ parentID, allCards, notes, readOnly, screenTo

const cancelPopover = useCallback(() => setPopover(null), [])

// The service calls below live OUTSIDE any setState updater on
// purpose: updater functions must be pure -- React StrictMode
// double-invokes them, which turned every confirm into TWO created
// cards/notes (regression: duplicate creation under the dev build).
// Reading `popover` from the closure is safe here; this callback's
// identity changing when the popover opens/closes is bounded and
// never feeds the sticky-node data path that caused the React #185
// loop this file's other comments describe.
const submitPopover = useCallback((kindID: string, title: string) => {
setPopover((pending) => {
if (!pending) return null
const pending = popover
if (!pending) return
setPopover(null)
{
if (pending.mode === 'create' && pending.slotLinkFromCardID && pending.slotLinkKindID) {
// Slot-drag guided-create (goal 0081 slice A4, D1=B): atomic,
// so the map never shows a card with a missing link or a link
Expand Down Expand Up @@ -226,25 +236,25 @@ export function useAtlasCreation({ parentID, allCards, notes, readOnly, screenTo
.then(() => refreshAtlas())
.catch(console.error)
}
return null
})
}, [parentID])
}
}, [popover, parentID])

const commitDraftNote = useCallback((text: string) => {
setDraftNoteFlowPos((pos) => {
const pos = draftNoteFlowPos
const override = draftNoteParentOverride
setDraftNoteFlowPos(null)
setDraftNoteParentOverride(null)
{
const trimmed = text.trim()
if (pos && trimmed) {
const override = draftNoteParentOverride
const targetParentID = override ?? parentID
const position = override ? freeChildPosition(allCardsRef.current, override) : { X: pos.x, Y: pos.y }
void AtlasService.CreateNote(trimmed, position, targetParentID)
.then(() => refreshAtlas())
.catch(console.error)
}
return null
})
setDraftNoteParentOverride(null)
}, [parentID, draftNoteParentOverride])
}
}, [parentID, draftNoteFlowPos, draftNoteParentOverride])
const cancelDraftNote = useCallback(() => {
setDraftNoteFlowPos(null)
setDraftNoteParentOverride(null)
Expand Down
Loading