fix(host): review round 13 — probe never gates a landing; relist supersession covered, false ignores removed
This commit is contained in:
@@ -128,8 +128,8 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
/* Right inset is slimmer than the left: the trailing column's own 8px
|
/* Right inset is slimmer than the left: the trailing column's own
|
||||||
* scrollbar clearance makes up the optical difference. */
|
* scrollbar clearance (see .column) makes up the optical difference. */
|
||||||
padding: 16px 16px 16px 24px;
|
padding: 16px 16px 16px 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,8 +144,8 @@
|
|||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
/* 12px of row gap on each side of the divider; the left side reads wider
|
/* 12px of row gap on each side of the divider; the left side reads wider
|
||||||
* by the column's trailing 8px scrollbar clearance, which is deliberate —
|
* by the column's trailing scrollbar clearance (see .column) — the thumb
|
||||||
* the thumb needs that room, the right pane's rows do not. */
|
* needs that room, the right pane's rows do not. */
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
|
|||||||
@@ -272,6 +272,20 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
|||||||
const refocusEditZone = useRef(false)
|
const refocusEditZone = useRef(false)
|
||||||
const editZoneRef = useRef<HTMLButtonElement | null>(null)
|
const editZoneRef = useRef<HTMLButtonElement | null>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the focused element sits among the miller rows — probed before
|
||||||
|
* a landing replaces the row nodes, to decide focus parking. A probe
|
||||||
|
* only: it never gates the landing itself (a torn-down ref in a close
|
||||||
|
* race merely skips the parking).
|
||||||
|
* @returns true when `document.activeElement` is inside the miller row.
|
||||||
|
*/
|
||||||
|
const focusInMillerRows = useCallback((): boolean => {
|
||||||
|
const rowHost = millerRowRef.current
|
||||||
|
/* v8 ignore next -- close-race guard: the commit-to-effect window is not deterministically reproducible. */
|
||||||
|
if (rowHost === null) return false
|
||||||
|
return rowHost.contains(document.activeElement)
|
||||||
|
}, [])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Launch a follow-up listing under the CURRENT supersession seq: a newer
|
* Launch a follow-up listing under the CURRENT supersession seq: a newer
|
||||||
* intent aborts it like the leg it continues, and it supersedes nothing.
|
* intent aborts it like the leg it continues, and it supersedes nothing.
|
||||||
@@ -312,11 +326,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
|||||||
// The landing replaces every row key; a slow jump leaves the OLD
|
// The landing replaces every row key; a slow jump leaves the OLD
|
||||||
// rows tabbable meanwhile (parentInert excludes loading), so focus
|
// rows tabbable meanwhile (parentInert excludes loading), so focus
|
||||||
// may live among them. With no selection yet the edit zone is the
|
// may live among them. With no selection yet the edit zone is the
|
||||||
// park target (body-guarded, like every other exit).
|
// park target (body-guarded, like every other exit). The probe never
|
||||||
const rowHost = millerRowRef.current
|
// gates the commit below — stranding the dialog in loading over a
|
||||||
/* v8 ignore next -- close-race guard: the commit-to-effect window is not deterministically reproducible. */
|
// focus check would be far worse than a skipped parking.
|
||||||
if (rowHost === null) return
|
if (focusInMillerRows()) refocusEditZone.current = true
|
||||||
if (rowHost.contains(document.activeElement)) refocusEditZone.current = true
|
|
||||||
setParent(target)
|
setParent(target)
|
||||||
setSelected(null)
|
setSelected(null)
|
||||||
setChild(null)
|
setChild(null)
|
||||||
@@ -338,13 +351,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
|||||||
// The upgrade replaces every committed row node; if focus lives
|
// The upgrade replaces every committed row node; if focus lives
|
||||||
// among them (Tab reached the rows during the parent leg), arm the
|
// among them (Tab reached the rows during the parent leg), arm the
|
||||||
// refocus effect so it re-parks on the re-selected row.
|
// refocus effect so it re-parks on the re-selected row.
|
||||||
const rowHost = millerRowRef.current
|
if (focusInMillerRows()) refocusPick.current = true
|
||||||
// A close race can clear the ref before the close effect's
|
|
||||||
// supersede runs (commit precedes passive effects): drop the
|
|
||||||
// upgrade, the dialog is going away.
|
|
||||||
/* v8 ignore next -- close-race guard: the commit-to-effect window is not deterministically reproducible. */
|
|
||||||
if (rowHost === null) return
|
|
||||||
if (rowHost.contains(document.activeElement)) refocusPick.current = true
|
|
||||||
setParent(parentLevel)
|
setParent(parentLevel)
|
||||||
setSelected(match)
|
setSelected(match)
|
||||||
setChild(target)
|
setChild(target)
|
||||||
@@ -358,7 +365,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
|||||||
setLoading(false)
|
setLoading(false)
|
||||||
setError(failureText(reason))
|
setError(failureText(reason))
|
||||||
})
|
})
|
||||||
}, [launchListing, continueScan])
|
}, [launchListing, continueScan, focusInMillerRows])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Close the nested create dialog. Its unmount drops focus to body (the
|
* Close the nested create dialog. Its unmount drops focus to body (the
|
||||||
@@ -486,13 +493,14 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
|||||||
const { seq, scan } = launchListing(targetPath)
|
const { seq, scan } = launchListing(targetPath)
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
scan.then((level) => {
|
scan.then((level) => {
|
||||||
/* v8 ignore next -- same fence as navigate/select; the modal blocks superseding input */
|
// The nested dialog closed before this relist launched, so the card
|
||||||
|
// is interactive meanwhile: a pick or crumb jump supersedes it.
|
||||||
if (seq !== requestSeq.current) return
|
if (seq !== requestSeq.current) return
|
||||||
setParent(level)
|
setParent(level)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
select({ name, path: createdPath, hidden: false })
|
select({ name, path: createdPath, hidden: false })
|
||||||
}, (reason: unknown) => {
|
}, (reason: unknown) => {
|
||||||
/* v8 ignore next -- same fence as navigate/select; the modal blocks superseding input */
|
// Same interactive-window fence as the success branch above.
|
||||||
if (seq !== requestSeq.current) return
|
if (seq !== requestSeq.current) return
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
setError(failureText(reason))
|
setError(failureText(reason))
|
||||||
|
|||||||
@@ -269,6 +269,53 @@ describe('DirectoryBrowser', () => {
|
|||||||
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'browser.editPath' }))
|
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'browser.editPath' }))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('a pick during the post-create relist supersedes it: late settlements drop', async () => {
|
||||||
|
const settlers: { resolve: (value: DirectoryListing) => void; reject: (reason: unknown) => void }[] = []
|
||||||
|
const listDirectory = vi.fn(async (path?: string) => {
|
||||||
|
// Explicit HOME requests are the relists; the initial open lists home
|
||||||
|
// through the absent-path form.
|
||||||
|
if (path === HOME) {
|
||||||
|
return new Promise<DirectoryListing>((resolve, reject) => { settlers.push({ resolve, reject }) })
|
||||||
|
}
|
||||||
|
return listingFor(path)
|
||||||
|
})
|
||||||
|
mount({ listDirectory })
|
||||||
|
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
|
||||||
|
fireEvent.change(screen.getByLabelText('browser.folderName'), { target: { value: 'ghost' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'browser.create' }))
|
||||||
|
// The nested dialog is gone while the relist hangs; the card is
|
||||||
|
// interactive, and picking a row supersedes the relist.
|
||||||
|
await waitFor(() => { expect(settlers).toHaveLength(1) })
|
||||||
|
fireEvent.click(rowButton(screen.getByRole('listitem')))
|
||||||
|
await waitFor(() => { expect(columns()).toHaveLength(2) })
|
||||||
|
// The stale relist settles late and must not land or select ghost.
|
||||||
|
await act(async () => { settlers[0]!.resolve({ ...listingFor(HOME), entries: [] }) })
|
||||||
|
expect(within(columns()[0]!).getByText('Documents')).toBeTruthy()
|
||||||
|
expect(rowButton(within(columns()[0]!).getByRole('listitem')).getAttribute('aria-current')).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a rejection of a superseded post-create relist is equally silent', async () => {
|
||||||
|
const settlers: { resolve: (value: DirectoryListing) => void; reject: (reason: unknown) => void }[] = []
|
||||||
|
const listDirectory = vi.fn(async (path?: string) => {
|
||||||
|
if (path === HOME) {
|
||||||
|
return new Promise<DirectoryListing>((resolve, reject) => { settlers.push({ resolve, reject }) })
|
||||||
|
}
|
||||||
|
return listingFor(path)
|
||||||
|
})
|
||||||
|
mount({ listDirectory })
|
||||||
|
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
|
||||||
|
fireEvent.change(screen.getByLabelText('browser.folderName'), { target: { value: 'ghost' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'browser.create' }))
|
||||||
|
await waitFor(() => { expect(settlers).toHaveLength(1) })
|
||||||
|
fireEvent.click(rowButton(screen.getByRole('listitem')))
|
||||||
|
await waitFor(() => { expect(columns()).toHaveLength(2) })
|
||||||
|
await act(async () => { settlers[0]!.reject(new Error('late')) })
|
||||||
|
expect(screen.queryByRole('alert')).toBeNull()
|
||||||
|
expect(columns()).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
it('a failed create relist parks focus on the edit zone with the error shown', async () => {
|
it('a failed create relist parks focus on the edit zone with the error shown', async () => {
|
||||||
let relists = 0
|
let relists = 0
|
||||||
const listDirectory = vi.fn(async (path?: string) => {
|
const listDirectory = vi.fn(async (path?: string) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user