diff --git a/client/src/components/Files/docsync/DocSyncPanel.test.tsx b/client/src/components/Files/docsync/DocSyncPanel.test.tsx index 1eee95f65..444238353 100644 --- a/client/src/components/Files/docsync/DocSyncPanel.test.tsx +++ b/client/src/components/Files/docsync/DocSyncPanel.test.tsx @@ -1,4 +1,4 @@ -// FE-DOCSYNC-PANEL-001 to FE-DOCSYNC-PANEL-022 +// FE-DOCSYNC-PANEL-001 to FE-DOCSYNC-PANEL-023 /** * The document-sync dialog shell (#2391). @@ -49,6 +49,27 @@ vi.mock('../../../api/client', async (importOriginal) => { } }) +/** Set by the one test that needs a render slower than React's time slice. */ +let slowCard = false + +vi.mock('./DocSyncBinding', async (importOriginal) => { + const actual = await importOriginal() + const Card = actual.default + return { + ...actual, + default: function SlowableBinding(props: Parameters[0]) { + // Busy for longer than the scheduler's 5 ms slice, so the commit that + // shows the card leaves its effects for a later task, as a loaded CI + // runner does. + if (slowCard) { + const end = performance.now() + 20 + while (performance.now() < end) { /* busy */ } + } + return + }, + } +}) + import DocSyncPanel from './DocSyncPanel' import type { DocSyncLink, DocSyncProvider } from './useDocSync' @@ -142,6 +163,7 @@ const confirmUnlink = () => { beforeEach(() => { vi.clearAllMocks() + slowCard = false serverHas({}) deleteLink.mockResolvedValue({}) updateLink.mockResolvedValue({}) @@ -301,6 +323,39 @@ describe('DocSyncPanel: the store list', () => { expect(second).toHaveAttribute('aria-current', 'true') expect(within(sidebar()).getByRole('button', { name: /Paperless-ngx/ })).not.toHaveAttribute('aria-current') }) + + it('FE-DOCSYNC-PANEL-023: a store picked before the list-following effect has run stays picked', async () => { + serverHas({ + providers: [provider('paperless', 'Paperless-ngx'), provider('nextcloud', 'Nextcloud')], + links: [link(1, 'paperless'), link(2, 'nextcloud')], + }) + slowCard = true + + render() + + // Outside act, as during the wait inside findBy*: React commits the card on + // its own scheduler, and the click lands the moment the card is in the DOM, + // before the effect that follows the list has had its turn. + const env = globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } + const previous = env.IS_REACT_ACT_ENVIRONMENT + env.IS_REACT_ACT_ENVIRONMENT = false + try { + await new Promise(resolve => { + const observer = new MutationObserver(() => { + if (!document.querySelector('article')) return + observer.disconnect() + fireEvent.click(within(sidebar()).getByRole('button', { name: /Nextcloud/ })) + resolve() + }) + observer.observe(document.body, { childList: true, subtree: true }) + }) + } finally { + env.IS_REACT_ACT_ENVIRONMENT = previous + } + + expect(within(binding()).getByText('Folder 2')).toBeInTheDocument() + expect(within(binding()).queryByText('Folder 1')).not.toBeInTheDocument() + }) }) describe('DocSyncPanel: a member rather than the owner', () => { diff --git a/client/src/components/Files/docsync/DocSyncPanel.tsx b/client/src/components/Files/docsync/DocSyncPanel.tsx index 0eb8afca6..9507f6b18 100644 --- a/client/src/components/Files/docsync/DocSyncPanel.tsx +++ b/client/src/components/Files/docsync/DocSyncPanel.tsx @@ -54,11 +54,12 @@ export default function DocSyncPanel({ const activeProvider = active ? sync.providers.find(p => p.id === active.providerId) : undefined // Follow the list: a freshly bound store should be the one on screen, and a - // removed one must not leave the detail column pointing at nothing. + // removed one must not leave the detail column pointing at nothing. The pick + // is read when the update runs, not from this render: a store clicked before + // the effect got its turn was otherwise switched straight back. useEffect(() => { - if (sync.links.length === 0) setSelected(null) - else if (!sync.links.some(l => l.id === selected)) setSelected(sync.links[0].id) - }, [sync.links, selected]) + setSelected(prev => (sync.links.some(l => l.id === prev) ? prev : (sync.links[0]?.id ?? null))) + }, [sync.links]) const attention = ATTENTION_STATES.reduce((n, k) => n + (sync.itemCounts[k] ?? 0), 0)