From 3d62e6e9be87bc6e85503a46dd1c77cf97a6766e Mon Sep 17 00:00:00 2001 From: IngridMorstrad Date: Mon, 26 Oct 2026 03:22:15 +0000 Subject: [PATCH] fix(guide): render a probe's open before its settle wait starts A guide's ui.find probe opens a registered popover, menu or local tab through that primitive's own state, waits settleMs on a timer, searches what the open revealed, then restores it. The open was a plain state update from outside React, so it rendered on React's next scheduler task. On a busy machine the settle timer can come due first. The probe then saw the container still closed and concluded "none", and its restore read the not-yet-updated open ref and did nothing, so the open landed afterwards and stayed on the person's page. This is the one-off CI failure of "opens a popover without its autofocus taking focus from the person" (expected 'none' to be 'found'). Run each open and each restore inside flushSync, so the container has rendered before the settle wait starts. A new test per primitive ends the settle wait before React's task can run; all three fail on main with the CI signature and pass with the fix. Closes #24248 --- website/src/guide/findByName.test.tsx | 25 ++++++++++++++++++++++++- website/src/guide/findByName.ts | 10 ++++++++-- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/website/src/guide/findByName.test.tsx b/website/src/guide/findByName.test.tsx index 99e4ac2d7ca4..c4688734c5ea 100644 --- a/website/src/guide/findByName.test.tsx +++ b/website/src/guide/findByName.test.tsx @@ -7,7 +7,7 @@ * happy-dom lays nothing out, so every element is given a box here; what is * hidden is hidden the way the page hides it (not rendered, or `hidden`). */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react' import { useState } from 'react' import { @@ -566,6 +566,29 @@ describe('probeForName', () => { expect(document.querySelector('[role="dialog"]')).toBeNull() }) + it.each([ + ['a popover', , 'Apply filter'], + ['a menu', Export, 'Export'], + ['a local tab', Save, Advanced: }} />, 'Purge cache'], + ])('finds inside %s and puts it back when its settle wait ends before the page renders', async (_, page, label) => { + render(page) + // A busy machine: the settle timer comes due before React runs the task + // that renders the open (or the restore). Modelled by ending each settle + // wait at the next microtask instead of on the clock. + const SETTLE = 7 + const realSetTimeout = globalThis.setTimeout + const timers = vi.spyOn(globalThis, 'setTimeout').mockImplementation(((fn: () => void, ms?: number, ...rest: unknown[]) => { + if (ms !== SETTLE) return realSetTimeout(fn, ms, ...rest) + queueMicrotask(fn) + return 0 + }) as typeof setTimeout) + // Restored even when the test times out, which a finally would not reach. + onTestFinished(() => timers.mockRestore()) + expect((await probe({ label }, { settleMs: SETTLE })).result).toBe('found') + expect(document.querySelector('[role="dialog"], [role="menu"]')).toBeNull() + if (label === 'Purge cache') expect(selectedTab()).toBe('General') + }) + it('keeps what it opened from being dismissed by a focus change outside it, until the person acts', async () => { render( <> diff --git a/website/src/guide/findByName.ts b/website/src/guide/findByName.ts index 492165509b5c..1c40d495c8ef 100644 --- a/website/src/guide/findByName.ts +++ b/website/src/guide/findByName.ts @@ -45,6 +45,7 @@ * acted since the step began. A container no primitive registered is never * opened: the guide asks the person to open it. */ +import { flushSync } from 'react-dom' import { i18nT } from '../i18n/t' import { GUIDE_PLANS } from '../uiLocations/guidePlans.gen' import { closestRegistered, registeredId } from '../uiLocations/targetRegistry' @@ -598,7 +599,12 @@ async function probeOnce(q: FindQuery, opts: ProbeOptions): Promise const before = new Set(probeTargets().map(e => e.id)) const saved = save(el) opened += 1 - const restore = entry.open() + // Rendered before the settle wait starts, not when React next gets a + // task: on a busy machine the wait can end first, and the probe would + // then search a closed container and skip a restore that the open + // reaches the page after. + let restore: () => void = () => {} + flushSync(() => { restore = entry.open() }) // Waited out even when aborted: the restore reads the primitive's // state, so what the open did must have rendered first. await sleep(settleMs) @@ -614,7 +620,7 @@ async function probeOnce(q: FindQuery, opts: ProbeOptions): Promise } } if (interrupted) return { result: 'none' } - restore() + flushSync(restore) await sleep(settleMs) if (interrupted) return { result: 'none' } restoreView(saved)