fix(docsync): a store picked before the panel settles stays picked

The effect that keeps the selection on a bound store read the pick from
its own render. When a click landed after the card was committed but
before that effect ran, the effect then wrote the first store back over
the click. On a loaded CI runner this is what made FE-DOCSYNC-PANEL-008
fail now and then. The effect now reads the current pick when the update
runs and only follows the list.

FE-DOCSYNC-PANEL-023 forces that order and fails on the old effect.
This commit is contained in:
Maurice
2026-09-24 08:14:29 +02:00
parent c5ac479915
commit 233076030b
2 changed files with 61 additions and 5 deletions
@@ -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<typeof import('./DocSyncBinding')>()
const Card = actual.default
return {
...actual,
default: function SlowableBinding(props: Parameters<typeof Card>[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 <Card {...props} />
},
}
})
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(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
// 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<void>(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', () => {
@@ -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)