From c6290e04e754f219dbd0511b2b1632c9cb778144 Mon Sep 17 00:00:00 2001 From: Brian Richter Date: Sun, 11 Oct 2026 12:14:19 -0700 Subject: [PATCH] fix: respect create-list dialog dismissal --- scripts/create-list-home-ui.test.mjs | 99 +++++++++ scripts/create-list-modal-ui.test.mjs | 286 ++++++++++++++++++++++++++ src/components/CreateListModal.tsx | 69 ++++++- src/components/ui/Panel.tsx | 24 ++- 4 files changed, 461 insertions(+), 17 deletions(-) create mode 100644 scripts/create-list-home-ui.test.mjs create mode 100644 scripts/create-list-modal-ui.test.mjs diff --git a/scripts/create-list-home-ui.test.mjs b/scripts/create-list-home-ui.test.mjs new file mode 100644 index 00000000..6b4757ba --- /dev/null +++ b/scripts/create-list-home-ui.test.mjs @@ -0,0 +1,99 @@ +// Exercise Home's actual create-dialog callback wiring. External services and +// unrelated child surfaces are fixtures; Home, CreateListModal and Panel are real. +import { test, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { build } from 'esbuild'; +import { pathToFileURL } from 'node:url'; +import { GlobalRegistrator } from '@happy-dom/global-registrator'; +if (!GlobalRegistrator.isRegistered) GlobalRegistrator.register({ url: 'http://localhost/' }); +globalThis.IS_REACT_ACT_ENVIRONMENT = true; +const React = await import('react'); +const { MemoryRouter, useLocation } = await import('react-router-dom'); +const { render, cleanup, fireEvent, act } = await import('@testing-library/react'); +const fixture = globalThis.__createListHomeUi = {}; +const root = process.env.CREATE_DIALOG_ROOT ?? '.'; +const mocks = { + '/lib/authenticatedConvex': 'export const useMutation=()=>globalThis.__createListHomeUi.save; export const useQuery=()=>[];', + '/_generated/api': 'export const api={lists:{createList:"create",getUserLists:"lists",getLegacyListIds:"legacy"},items:{addItem:"add"},publication:{getUserBookmarkIds:"bookmarks"}};', + '/hooks/useCurrentUser': 'export const useCurrentUser=()=>({did:"did:owner",legacyDid:null,isLoading:false});', + '/hooks/useSettings': 'export const useSettings=()=>({haptic:value=>globalThis.__createListHomeUi.feedback.push(value),listSort:"newest"});', + '/hooks/useCategories': 'export const useCategories=()=>({categories:[],isLoading:false});', + '/hooks/useOffline': 'export const useOffline=()=>({isOnline:true,accountId:"account",compaction:{}});', + '/hooks/useStreaks': 'export const useStreaks=()=>({streak:null});', + '/hooks/useBilling': 'export const useBilling=()=>({isPro:false});', + '/lib/offline': 'export const cacheAllLists=async()=>{}, getAllCachedLists=async()=>[];', + '/lib/originals': 'export const createListAsset=(...args)=>globalThis.__createListHomeUi.sign(...args);', + '/lib/planLimit': 'export const isPlanLimitError=error=>error.message==="plan limit";', + '/lib/noteBody': 'export const isNote=()=>false;', + '/lib/analytics': 'export const trackListCreated=()=>{}, trackFirstListCreated=()=>{}, trackFeatureGateHit=()=>{}, trackInviteSent=()=>{};', + '/components/CreateChooser': 'import {createElement} from "react"; export const CreateChooser=({onPick})=>createElement("button",{onClick:()=>onPick("list")},"Choose list");', + '/components/TemplatePickerModal': 'import {createElement} from "react"; export const TemplatePickerModal=({onCreateBlank})=>createElement("button",{onClick:onCreateBlank},"Create blank list");', + '/components/OnboardingFlow': 'import {createElement} from "react"; export const OnboardingFlow=()=>null, isOnboardingDone=()=>true, isInviteNudgeDone=()=>false, markInviteNudgeDone=()=>{}, InviteNudge=({listName})=>createElement("aside",null,`Invitation for ${listName}`);', +}; +for (const [path, exports] of Object.entries({ + '/components/ListCard': ['ListCard'], '/components/NoteCard': ['NoteCard'], + '/components/CreateNoteModal': ['CreateNoteModal'], '/components/lists/CategoryHeader': ['CategoryHeader'], + '/components/lists/CategoryManager': ['CategoryManager'], '/components/ui/SearchInput': ['SearchInput'], + '/components/ui/SortDropdown': ['SortDropdown'], '/components/ui/Skeleton': ['HomePageSkeleton'], + '/components/ui/EmptyState': ['NoListsEmptyState', 'NoSearchResultsEmptyState'], + '/components/StreakBadge': ['StreakBadge'], '/ReferralInvite': ['ReferralInviteCurrentUser'], + '/lists/CategorySelector': ['CategorySelector'], +})) mocks[path] = exports.map(name => `export const ${name}=()=>null;`).join(''); + +await build({ entryPoints: [`${root}/src/pages/Home.tsx`], outfile: 'tmp/create-list-home-ui.mjs', + bundle: true, jsx: 'automatic', platform: 'node', format: 'esm', + external: ['react', 'react/jsx-runtime', 'react-dom', 'react-router-dom'], + plugins: [{ name: 'home-fixtures', setup(b) { + b.onResolve({ filter: /.*/ }, args => { + const key = Object.keys(mocks).find(key => args.path.endsWith(key)); + return key ? { path: key, namespace: 'fixture' } : undefined; + }); + b.onLoad({ filter: /.*/, namespace: 'fixture' }, ({ path }) => ({ contents: mocks[path] })); + } }], +}); +const { Home } = await import(pathToFileURL(`${process.cwd()}/tmp/create-list-home-ui.mjs`)); +afterEach(() => { cleanup(); localStorage.clear(); }); +const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; }; +function mountHome() { + const sign = deferred(), save = deferred(); + Object.assign(fixture, { feedback: [], sign: () => sign.promise, save: () => save.promise }); + localStorage.setItem('boop:onboarding_demo_created', 'done'); + let location; + function Location() { location = useLocation(); return null; } + const view = render(React.createElement(React.StrictMode, null, + React.createElement(MemoryRouter, { initialEntries: ['/d'] }, + React.createElement(Location), React.createElement(Home)))); + fireEvent.click(view.getByRole('button', { name: 'Create new list or note' })); + fireEvent.click(view.getByRole('button', { name: 'Choose list' })); + fireEvent.click(view.getByRole('button', { name: 'Create blank list' })); + fireEvent.change(view.getByRole('textbox', { name: 'List name' }), { target: { value: 'Weekend' } }); + fireEvent.submit(document.getElementById('create-list-form')); + return { view, sign, save, location: () => location }; +} + +for (const phase of ['sign', 'save']) { + test(`Home does not open an invite nudge after ${phase} is dismissed`, async () => { + const f = mountHome(); + if (phase === 'save') await act(async () => f.sign.resolve({ assetDid: 'asset:weekend', envelope: '{}' })); + fireEvent.keyDown(window, { key: 'Escape' }); + const feedback = [...fixture.feedback]; + await act(async () => { + f.sign.resolve({ assetDid: 'asset:weekend', envelope: '{}' }); + f.save.resolve('weekend-list'); + }); + assert.equal(f.view.queryByText('Invitation for Weekend'), null); + assert.deepEqual(fixture.feedback, feedback); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 220)); }); + assert.equal(f.view.queryByRole('dialog'), null); + assert.equal(f.view.queryByText('Invitation for Weekend'), null); + assert.equal(f.location().pathname, '/d'); + }); +} + +test('Home still opens its invite nudge when creation completes while visible', async () => { + const f = mountHome(); + await act(async () => { f.sign.resolve({ assetDid: 'asset:weekend', envelope: '{}' }); f.save.resolve('weekend-list'); }); + assert.ok(f.view.getByText('Invitation for Weekend')); + assert.ok(f.view.getByRole('heading', { name: 'List created!' })); + assert.equal(f.location().pathname, '/d'); +}); diff --git a/scripts/create-list-modal-ui.test.mjs b/scripts/create-list-modal-ui.test.mjs new file mode 100644 index 00000000..33f7d62c --- /dev/null +++ b/scripts/create-list-modal-ui.test.mjs @@ -0,0 +1,286 @@ +// Render the real CreateListModal and Panel. Only external services are mocked. +// Run with Node 22+ or the repository's Bun test command. +import { test, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { build } from 'esbuild'; +import { pathToFileURL } from 'node:url'; +import { GlobalRegistrator } from '@happy-dom/global-registrator'; + +if (!GlobalRegistrator.isRegistered) GlobalRegistrator.register({ url: 'http://localhost/' }); +globalThis.IS_REACT_ACT_ENVIRONMENT = true; +const React = await import('react'); +const { render, cleanup, fireEvent, act } = await import('@testing-library/react'); +const { MemoryRouter, useLocation, useNavigate } = await import('react-router-dom'); +const fixture = globalThis.__createListModalUi = {}; +const root = process.env.CREATE_DIALOG_ROOT ?? '.'; + +await build({ + entryPoints: [`${root}/src/components/CreateListModal.tsx`, `${root}/src/components/ui/Panel.tsx`], + outdir: 'tmp/create-dialog-ui', outbase: `${root}/src/components`, outExtension: { '.js': '.mjs' }, + bundle: true, jsx: 'automatic', platform: 'node', format: 'esm', + external: ['react', 'react/jsx-runtime', 'react-dom', 'react-router-dom'], + plugins: [{ name: 'create-list-services', setup(b) { + const mocks = { + '/lib/authenticatedConvex': 'export const useMutation=()=>globalThis.__createListModalUi.save; export const useQuery=()=>[];', + '/_generated/api': 'export const api={lists:{createList:"create",getUserLists:"lists"}};', + '/hooks/useCurrentUser': 'export const useCurrentUser=()=>({did:globalThis.__createListModalUi.did});', + '/hooks/useSettings': 'export const useSettings=()=>({haptic:value=>globalThis.__createListModalUi.feedback.push(value)});', + '/lib/originals': 'export const createListAsset=(...args)=>globalThis.__createListModalUi.sign(...args);', + '/lib/planLimit': 'export const isPlanLimitError=error=>error.message==="plan limit";', + '/lib/noteBody': 'export const isNote=()=>false;', + '/lists/CategorySelector': 'export const CategorySelector=()=>null;', + '/ReferralInvite': 'export const ReferralInviteCurrentUser=()=>null;', + '/lib/analytics': 'const track=(name)=>(...args)=>globalThis.__createListModalUi.analytics.push([name,...args]); export const trackListCreated=track("created"), trackFirstListCreated=track("first"), trackFeatureGateHit=track("limit"), trackInviteSent=track("invite");', + }; + b.onResolve({ filter: /.*/ }, args => { + const key = Object.keys(mocks).find(key => args.path.endsWith(key)); + return key ? { path: key, namespace: 'fixture' } : undefined; + }); + b.onLoad({ filter: /.*/, namespace: 'fixture' }, ({ path }) => ({ contents: mocks[path] })); + } }], +}); +const { CreateListModal } = await import(pathToFileURL(`${process.cwd()}/tmp/create-dialog-ui/CreateListModal.mjs`)); +const { Panel } = await import(pathToFileURL(`${process.cwd()}/tmp/create-dialog-ui/ui/Panel.mjs`)); + +afterEach(() => cleanup()); +function deferred() { + let resolve, reject; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} +function mount({ navigateOnCreated = false } = {}) { + const calls = { sign: [], save: [], created: [], closed: 0 }; + const requests = { sign: [], save: [] }; + Object.assign(fixture, { did: 'did:owner', feedback: [], analytics: [], + sign: (...args) => { calls.sign.push(args); const d = deferred(); requests.sign.push(d); return d.promise; }, + save: args => { calls.save.push(args); const d = deferred(); requests.save.push(d); return d.promise; }, + }); + const controls = {}; + function Harness() { + const [open, setOpen] = React.useState(true); + const [generation, setGeneration] = React.useState(0); + const [, setRevision] = React.useState(0); + controls.refresh = () => setRevision(value => value + 1); + controls.location = useLocation(); + controls.navigate = useNavigate(); + controls.open = () => { setGeneration(value => value + 1); setOpen(true); }; + controls.hide = () => setOpen(false); + return open ? React.createElement(CreateListModal, { key: generation, + onClose: () => { calls.closed++; setOpen(false); }, + onListCreated: (...args) => { calls.created.push(args); if (navigateOnCreated) controls.navigate(`/list/${args[0]}`); }, + }) : React.createElement('p', null, 'Dialog closed'); + } + const view = render(React.createElement(React.StrictMode, null, + React.createElement(MemoryRouter, { initialEntries: ['/before', '/lists'], initialIndex: 1 }, React.createElement(Harness)))); + const form = () => document.getElementById('create-list-form'); + const submit = () => fireEvent.submit(form()); + const type = value => fireEvent.change(view.getByRole('textbox', { name: 'List name' }), { target: { value } }); + const close = method => { + if (method === 'header') fireEvent.click(view.getAllByRole('button', { name: 'Close' })[0]); + else if (method === 'footer') fireEvent.click(view.getAllByRole('button', { name: /^(Close|Cancel)$/ }).at(-1)); + else if (method === 'backdrop') fireEvent.click(view.getByRole('dialog')); + else if (method === 'escape') fireEvent.keyDown(window, { key: 'Escape' }); + else if (method === 'swipe') { + const panel = view.getByRole('dialog').firstElementChild; + fireEvent.touchStart(panel, { touches: [{ clientY: 10 }] }); + fireEvent.touchMove(panel, { touches: [{ clientY: 140 }] }); + fireEvent.touchEnd(panel); + } + }; + const finishAnimation = async () => act(async () => { await new Promise(resolve => setTimeout(resolve, 220)); }); + const finishSign = async (index = 0) => act(async () => requests.sign[index].resolve({ assetDid: `asset:${index}`, envelope: '{}' })); + const finishSave = async (index = 0, id = 'listA') => act(async () => requests.save[index].resolve(id)); + return { view, calls, requests, controls, submit, type, close, finishAnimation, finishSign, finishSave }; +} + +test('rapid form submissions sign/save once and report a successful creation once', async () => { + const f = mount(); f.type(' Weekend '); + act(() => { f.submit(); f.submit(); }); + assert.equal(f.calls.sign.length, 1); + await f.finishSign(); + assert.equal(f.calls.save.length, 1); + assert.equal(f.calls.save[0].name, 'Weekend'); + await f.finishSave(); + assert.deepEqual(f.calls.created, [['listA', 'Weekend']]); + assert.equal(f.controls.location.pathname, '/lists'); + assert.ok(f.view.getByRole('heading', { name: 'List created!' })); +}); + +for (const method of ['header', 'footer', 'backdrop', 'escape', 'swipe']) { + for (const phase of ['sign', 'save']) { + for (const outcome of ['success', 'failure', 'plan limit']) { + test(`${method} dismissal during ${phase} suppresses late ${outcome}`, async () => { + const f = mount({ navigateOnCreated: true }); f.type('Old list'); f.submit(); + if (phase === 'save') await f.finishSign(); + f.close(method); + // Resolve during Panel's closing animation, before its delayed onClose. + const feedback = [...fixture.feedback]; + const analytics = [...fixture.analytics]; + await act(async () => { + const d = f.requests[phase][0]; + if (outcome === 'success') d.resolve(phase === 'sign' ? { assetDid: 'asset:old', envelope: '{}' } : 'oldList'); + else d.reject(new Error(outcome)); + }); + assert.deepEqual(f.calls.created, []); + assert.deepEqual(fixture.feedback, feedback); + assert.deepEqual(fixture.analytics, analytics); + assert.equal(f.calls.save.length, phase === 'save' ? 1 : 0); + assert.equal(f.controls.location.pathname, '/lists'); + assert.equal(f.view.queryByText('Failed to create list. Please try again.'), null); + assert.equal(f.view.queryByText("You've reached the free plan limit of 5 lists"), null); + await f.finishAnimation(); + assert.equal(f.calls.closed, 1); + assert.equal(f.view.queryByRole('dialog'), null); + await act(async () => f.controls.navigate(-1)); + assert.equal(f.controls.location.pathname, '/before', 'dismissal must not add a history entry'); + await act(async () => f.controls.navigate(1)); + assert.equal(f.controls.location.pathname, '/lists'); + }); + } + } +} + +for (const method of ['header', 'backdrop', 'escape', 'swipe']) { + test(`${method} after success closes without opening the list`, async () => { + const f = mount(); f.type('Created'); f.submit(); await f.finishSign(); await f.finishSave(); + f.close(method); await f.finishAnimation(); + assert.equal(f.calls.closed, 1); + assert.equal(f.controls.location.pathname, '/lists'); + }); +} + +for (const button of ['Go to list', 'Share with people']) { + test(`${button} explicitly navigates after success`, async () => { + const f = mount(); f.type('Created'); f.submit(); await f.finishSign(); await f.finishSave(); + fireEvent.click(f.view.getByRole('button', { name: button })); + assert.equal(f.controls.location.pathname, '/list/listA'); + assert.deepEqual(f.controls.location.state, button === 'Share with people' ? { openShare: true } : null); + }); + test(`dismissal blocks ${button} during the closing animation`, async () => { + const f = mount(); f.type('Created'); f.submit(); await f.finishSign(); await f.finishSave(); + f.close('escape'); + fireEvent.click(f.view.getByRole('button', { name: button })); + assert.equal(f.controls.location.pathname, '/lists'); + await f.finishAnimation(); + }); +} + +test('Cancel before submitting simply closes', () => { + const f = mount(); f.close('footer'); + assert.equal(f.calls.closed, 1); + assert.equal(f.calls.sign.length, 0); + assert.equal(f.controls.location.pathname, '/lists'); +}); + +test('pending close stays enabled and explains that an in-flight save may finish', () => { + const f = mount(); f.type('Pending'); f.submit(); + assert.match(f.view.getByRole('status').textContent, /already being saved may still be created/); + assert.equal(f.view.getAllByRole('button', { name: 'Close' }).at(-1).disabled, false); + assert.equal(f.view.queryByRole('button', { name: 'Cancel' }), null); +}); + +for (const phase of ['sign', 'save']) { + for (const fails of [false, true]) { + test(`unmount/remount isolates old ${phase} ${fails ? 'failure' : 'success'} from a new attempt`, async () => { + const f = mount({ navigateOnCreated: true }); f.type('Old'); f.submit(); + if (phase === 'save') await f.finishSign(); + act(() => f.controls.hide()); + act(() => f.controls.navigate('/newer')); + act(() => f.controls.open()); + f.type('New'); f.submit(); + const feedback = [...fixture.feedback]; + await act(async () => { + const d = f.requests[phase][0]; + if (fails) d.reject(new Error('late failure')); + else d.resolve(phase === 'sign' ? { assetDid: 'asset:old', envelope: '{}' } : 'oldList'); + }); + assert.deepEqual(f.calls.created, []); + assert.deepEqual(fixture.feedback, feedback); + assert.equal(f.controls.location.pathname, '/newer'); + assert.equal(f.view.getByRole('textbox', { name: 'List name' }).value, 'New'); + f.submit(); + assert.equal(f.calls.sign.length, 2, 'old finally must not unlock the newer submit'); + await f.finishSign(1); + await f.finishSave(phase === 'save' ? 1 : 0, 'newList'); + assert.deepEqual(f.calls.created, [['newList', 'New']]); + assert.equal(f.controls.location.pathname, '/list/newList'); + }); + } +} + +test('visible failure permits retry and clears the prior plan-limit message', async () => { + const f = mount(); f.type('Retry'); f.submit(); await f.finishSign(); + const originalError = console.error; console.error = () => {}; + try { await act(async () => f.requests.save[0].reject(new Error('plan limit'))); } + finally { console.error = originalError; } + assert.ok(f.view.getByText("You've reached the free plan limit of 5 lists")); + f.submit(); + assert.equal(f.view.queryByText("You've reached the free plan limit of 5 lists"), null); + assert.equal(f.calls.sign.length, 2); + await f.finishSign(1); await f.finishSave(1); + assert.equal(f.calls.created.length, 1); +}); + +for (const phase of ['sign', 'save']) { + for (const fails of [false, true]) { + test(`in-place identity change isolates old ${phase} ${fails ? 'failure' : 'success'}`, async () => { + const f = mount({ navigateOnCreated: true }); f.type('Old account'); f.submit(); + if (phase === 'save') await f.finishSign(); + fixture.did = 'did:new-owner'; + act(() => f.controls.refresh()); + assert.equal(f.view.getByRole('textbox', { name: 'List name' }).value, ''); + f.type('New account'); f.submit(); + const feedback = [...fixture.feedback]; + await act(async () => { + const d = f.requests[phase][0]; + if (fails) d.reject(new Error('late old account failure')); + else d.resolve(phase === 'sign' ? { assetDid: 'asset:old', envelope: '{}' } : 'oldList'); + }); + assert.deepEqual(f.calls.created, []); + assert.deepEqual(fixture.feedback, feedback); + assert.equal(f.controls.location.pathname, '/lists'); + f.submit(); assert.equal(f.calls.sign.length, 2); + await f.finishSign(1); await f.finishSave(phase === 'save' ? 1 : 0, 'newList'); + assert.deepEqual(f.calls.created, [['newList', 'New account']]); + }); + } +} + +test('repeated Panel dismissals trigger one start and one close', async () => { + let starts = 0, closes = 0; + const view = render(React.createElement(React.StrictMode, null, + React.createElement(Panel, { isOpen: true, onCloseStart: () => starts++, onClose: () => closes++ }, 'Panel'))); + act(() => { fireEvent.keyDown(window, { key: 'Escape' }); fireEvent.click(view.getByRole('dialog')); fireEvent.keyDown(window, { key: 'Escape' }); }); + assert.equal(starts, 1); + assert.equal(closes, 0); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 220)); }); + assert.equal(closes, 1); +}); + +test('unmount cancels an old Panel dismissal timer before another dialog opens', async () => { + const f = mount(); f.close('escape'); + act(() => f.controls.hide()); act(() => f.controls.open()); + f.type('New draft'); + await f.finishAnimation(); + assert.equal(f.calls.closed, 0); + assert.equal(f.view.getByRole('textbox', { name: 'List name' }).value, 'New draft'); +}); + +test('controlled Panel close clears the overlay and cancels its old timer before reopen', async () => { + let starts = 0, closes = 0; + const props = { onCloseStart: () => starts++, onClose: () => closes++ }; + const panel = open => React.createElement(React.StrictMode, null, React.createElement(Panel, { ...props, isOpen: open }, 'Panel')); + const view = render(panel(true)); + fireEvent.keyDown(window, { key: 'Escape' }); + view.rerender(panel(false)); + assert.equal(view.queryByRole('dialog'), null); + view.rerender(panel(true)); + assert.ok(view.getByRole('dialog')); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 220)); }); + assert.equal(closes, 0, 'canceled animation must not close a reopened panel'); + assert.ok(view.getByRole('dialog')); + fireEvent.keyDown(window, { key: 'Escape' }); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 220)); }); + assert.equal(starts, 2); + assert.equal(closes, 1); +}); diff --git a/src/components/CreateListModal.tsx b/src/components/CreateListModal.tsx index c036f998..1d1342a9 100644 --- a/src/components/CreateListModal.tsx +++ b/src/components/CreateListModal.tsx @@ -4,7 +4,7 @@ * Features improved design and dark mode support. */ -import { useState, type FormEvent } from "react"; +import { useEffect, useRef, useState, type FormEvent } from "react"; import { useMutation, useQuery } from "../lib/authenticatedConvex"; import { useNavigate, Link } from "react-router-dom"; import { api } from "../../convex/_generated/api"; @@ -24,8 +24,14 @@ interface CreateListModalProps { onListCreated?: (listId: Id<"lists">, listName: string) => void; } -export function CreateListModal({ onClose, onListCreated }: CreateListModalProps) { +export function CreateListModal(props: CreateListModalProps) { const { did } = useCurrentUser(); + // Identity changes must not let a previous account's pending work complete + // into the new dialog, even if its parent remains mounted. + return ; +} + +function CreateListDialog({ did, onClose, onListCreated }: CreateListModalProps & { did: string | null }) { const navigate = useNavigate(); const { haptic } = useSettings(); const createList = useMutation(api.lists.createList); @@ -37,9 +43,33 @@ export function CreateListModal({ onClose, onListCreated }: CreateListModalProps const [error, setError] = useState(null); const [planLimitHit, setPlanLimitHit] = useState(false); const [createdListId, setCreatedListId] = useState | null>(null); + const mountedRef = useRef(false); + const dismissedRef = useRef(false); + const closedRef = useRef(false); + const pendingRef = useRef(false); + const completedRef = useRef(false); + + useEffect(() => { + mountedRef.current = true; + return () => { mountedRef.current = false; }; + }, []); + + // Invalidate pending work as soon as dismissal starts, including Panel's + // closing animation. A mutation already sent to the server cannot be undone. + const handleCloseStart = () => { + dismissedRef.current = true; + }; + + const handleClose = () => { + handleCloseStart(); + if (closedRef.current) return; + closedRef.current = true; + onClose(); + }; const handleSubmit = async (e: FormEvent) => { e.preventDefault(); + if (!mountedRef.current || dismissedRef.current || pendingRef.current || completedRef.current) return; const trimmedName = name.trim(); if (!trimmedName) { @@ -54,12 +84,16 @@ export function CreateListModal({ onClose, onListCreated }: CreateListModalProps return; } + // React state alone does not prevent two submissions in the same event turn. + pendingRef.current = true; setError(null); + setPlanLimitHit(false); setIsCreating(true); haptic('medium'); try { const listAsset = await createListAsset(trimmedName, did); + if (!mountedRef.current || dismissedRef.current) return; const listId = await createList({ assetDid: listAsset.assetDid, @@ -68,7 +102,9 @@ export function CreateListModal({ onClose, onListCreated }: CreateListModalProps categoryId, createdAt: Date.now(), }); + if (!mountedRef.current || dismissedRef.current) return; + completedRef.current = true; haptic('success'); const newCount = (existingLists?.filter((l) => !isNote(l)).length ?? 0) + 1; trackListCreated(newCount); @@ -77,6 +113,7 @@ export function CreateListModal({ onClose, onListCreated }: CreateListModalProps setIsCreating(false); onListCreated?.(listId, trimmedName); } catch (err) { + if (!mountedRef.current || dismissedRef.current) return; console.error("Failed to create list:", err); if (isPlanLimitError(err)) { setPlanLimitHit(true); @@ -86,15 +123,20 @@ export function CreateListModal({ onClose, onListCreated }: CreateListModalProps } haptic('error'); setIsCreating(false); + } finally { + pendingRef.current = false; } }; const handleGoToList = () => { - if (createdListId) navigate(`/list/${createdListId}`); + if (!createdListId || dismissedRef.current || !mountedRef.current) return; + dismissedRef.current = true; + navigate(`/list/${createdListId}`); }; const handleShareList = () => { - if (!createdListId) return; + if (!createdListId || dismissedRef.current || !mountedRef.current) return; + dismissedRef.current = true; trackInviteSent('native_share'); navigate(`/list/${createdListId}`, { state: { openShare: true } }); }; @@ -115,8 +157,7 @@ export function CreateListModal({ onClose, onListCreated }: CreateListModalProps