From f493c9ef7ce51ab5c4cc651f174aa3bb21c3d86f Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Mon, 19 Oct 2026 05:35:28 +0400 Subject: [PATCH] fix: popup exit and capture --- e2e/tests/ui.test.mjs | 69 +++++++++++++++++++++++-------- src-tauri/src/network_capture.rs | 4 +- src/components/ui/popup-motion.ts | 35 ++++++++-------- src/components/ui/select.tsx | 6 ++- src/styles/globals.css | 2 +- 5 files changed, 78 insertions(+), 38 deletions(-) diff --git a/e2e/tests/ui.test.mjs b/e2e/tests/ui.test.mjs index f276215d..6db534ed 100644 --- a/e2e/tests/ui.test.mjs +++ b/e2e/tests/ui.test.mjs @@ -611,6 +611,21 @@ test("profile table filters, range selection, view settings, and keyboard edits [], ); const row = (index) => `tr[data-table-row="${profiles[index].id}"]`; + // A new profile is scrolled into view, so the last of the 32 leaves the + // table at its end. Scroll back to the first row. + await app.waitFor( + () => + app.execute( + ` + const root = document.querySelector('[data-table-id="profiles"]'); + if (root?.querySelector('table')?.getAttribute('aria-rowcount') !== "33") return false; + root.querySelector('.scroll-fade').scrollTop = 0; + return Boolean(root.querySelector(arguments[0])); + `, + [row(0)], + ), + { description: "the first profile row in view" }, + ); await app.clickSelector(`${row(0)} [role="checkbox"]`); await tableShiftClick(app, `${row(3)} [role="checkbox"]`); await app.waitFor( @@ -1126,11 +1141,28 @@ test("management tables share search, filters, selection, and saved views", asyn async () => (await tableRows(app, "proxies")).length === 1, { description: "proxy search" }, ); - assert.equal( - await app.execute( - `return Boolean(document.querySelector('[data-slot="table-action-bar"]'));`, - ), - false, + // A search keeps the selection, and the bar counts the rows it hides. + await app.waitFor( + () => + app.execute( + `return document.querySelector('[data-slot="table-action-bar"] [data-slot="table-selection-count"] .sr-only')?.textContent === arguments[0];`, + [ + en.dataTableActionBar.selectedHidden + .replace("{{selected}}", "3") + .replace("{{hidden}}", "2"), + ], + ), + { description: "selection kept through the search" }, + ); + await app.clickSelector( + `[data-slot="table-action-bar"] [aria-label="${en.dataTableActionBar.clearSelection}"]`, + ); + await app.waitFor( + () => + app.execute( + `return !document.querySelector('[data-slot="table-action-bar"]');`, + ), + { timeoutMs: 2000, description: "selection cleared" }, ); await app.fillSelector( `[data-table-id="proxies"] input[aria-label="${en.tables.search}"]`, @@ -1847,6 +1879,7 @@ test("MCP clients on this computer come first, and toolsets go to the connected assert.deepEqual((await calls("set_mcp_toolsets"))[0].toolsets, [ "organize", "network", + "traffic", "fingerprint", "extensions", "sync", @@ -5602,7 +5635,7 @@ test("tips open from the rail, walk the catalog, and deep-link into the feature" ); // The action lands inside the feature: the DNS tip opens settings on - // its DNS section, and the dialog is gone by then. + // its DNS section, and the dialog leaves after its short exit. await app.clickSelector( '[data-slot="tips-list-item"][data-tip-id="dnsBlocklist"]', ); @@ -5614,12 +5647,12 @@ test("tips open from the rail, walk the catalog, and deep-link into the feature" ), { description: "the DNS settings section focused" }, ); - assert.equal( - await app.execute( - `return Boolean(document.querySelector(arguments[0]));`, - [TIPS_DIALOG], - ), - false, + await app.waitFor( + () => + app.execute(`return !document.querySelector(arguments[0]);`, [ + TIPS_DIALOG, + ]), + { timeoutMs: 2000, description: "the tips dialog to leave" }, ); // The chord opens the catalog too, and the switch turns the automatic @@ -6041,11 +6074,13 @@ test("a freshly paid account is welcomed once and walked to its plan tips", asyn ), { description: "the Cookie Bot tip" }, ); - assert.equal( - await app.execute( - `return Boolean(document.querySelector('[data-slot="paid-welcome"]'));`, - ), - false, + // The welcome leaves after its short exit, under the catalog. + await app.waitFor( + () => + app.execute( + `return !document.querySelector('[data-slot="paid-welcome"]');`, + ), + { timeoutMs: 2000, description: "the paid welcome to leave" }, ); const listed = await app.execute( `return [...document.querySelectorAll('[data-slot="tips-list-item"]')].map((node) => node.dataset.tipId);`, diff --git a/src-tauri/src/network_capture.rs b/src-tauri/src/network_capture.rs index cc58e76f..091a2028 100644 --- a/src-tauri/src/network_capture.rs +++ b/src-tauri/src/network_capture.rs @@ -797,6 +797,8 @@ async fn pump( ready: tokio::sync::oneshot::Sender>, ) { let mut ready = Some(ready); + // A tab opened while the capture runs waits until its Network domain is + // on, so its first request is not missed; `attached` lets it go. let attach = connection .send_to( None, @@ -804,7 +806,7 @@ async fn pump( "Target.setAutoAttach", json!({ "autoAttach": true, - "waitForDebuggerOnStart": false, + "waitForDebuggerOnStart": true, "flatten": true, "filter": [{ "type": "page" }], }), diff --git a/src/components/ui/popup-motion.ts b/src/components/ui/popup-motion.ts index f3523e83..90874381 100644 --- a/src/components/ui/popup-motion.ts +++ b/src/components/ui/popup-motion.ts @@ -16,9 +16,13 @@ function assignRef(ref: React.Ref | undefined, value: T | null) { * Motion for a Radix popup's content element (`.popup-motion` in * globals.css): the `data-motion` flag, and a ref that ends a stalled exit. * - * Radix keeps a closed popup mounted until its exit animation sends - * `animationend`. A paused animation clock (an occluded webview) never sends - * it, so a timer sends it instead, and the popup always leaves. + * Radix keeps a closed popup mounted until an `animationend` names the + * animation it runs now. A paused animation clock (an occluded webview) never + * sends it, and a popup whose motion turns off mid-exit (a key press makes + * the user a keyboard user) has no animation left to end. A popup left + * mounted comes back with its old state (a search typed into it) when it + * opens again. So the ref sends that event itself: at once when no exit can + * play, else after the exit's length. */ export function usePopupMotion(ref?: React.Ref) { const motionAllowed = useMotionAllowed(); @@ -27,26 +31,23 @@ export function usePopupMotion(ref?: React.Ref) { assignRef(ref, node); if (!node) return; let timer = 0; + const endExit = () => { + if (!node.isConnected || node.dataset.state !== "closed") return; + node.dispatchEvent( + new AnimationEvent("animationend", { + animationName: getComputedStyle(node).animationName, + }), + ); + }; const observer = new MutationObserver(() => { window.clearTimeout(timer); if (node.dataset.state !== "closed") return; - timer = window.setTimeout(() => { - if ( - node.isConnected && - node.dataset.state === "closed" && - getComputedStyle(node).animationName === "popup-exit" - ) { - node.dispatchEvent( - new AnimationEvent("animationend", { - animationName: "popup-exit", - }), - ); - } - }, POPUP_EXIT_FALLBACK_MS); + if (document.hidden || node.dataset.motion !== "on") endExit(); + else timer = window.setTimeout(endExit, POPUP_EXIT_FALLBACK_MS); }); observer.observe(node, { attributes: true, - attributeFilter: ["data-state"], + attributeFilter: ["data-state", "data-motion"], }); return () => { observer.disconnect(); diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx index 2fa07d85..fcc40108 100644 --- a/src/components/ui/select.tsx +++ b/src/components/ui/select.tsx @@ -65,7 +65,9 @@ function SelectContent({ position = "popper", ...props }: React.ComponentProps) { - // Grows in; never animates out. A Select unmounts the moment it closes. + // Grows in; never animates out. `animate-none` on close cancels the + // `popup-exit` of `.popup-motion`, so Radix unmounts the content at once + // (`hidden` cannot: the popper sets an inline `display: flex`). const motionAllowed = useMotionAllowed(); return ( @@ -73,7 +75,7 @@ function SelectContent({ data-slot="select-content" data-motion={motionAllowed ? "on" : undefined} className={cn( - "popup-motion relative z-50000 max-h-(--radix-select-content-available-height) min-w-32 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-sm data-[state=closed]:pointer-events-none data-[state=closed]:hidden", + "popup-motion relative z-50000 max-h-(--radix-select-content-available-height) min-w-32 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-sm data-[state=closed]:pointer-events-none data-[state=closed]:hidden data-[state=closed]:animate-none", position === "popper" && "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className, diff --git a/src/styles/globals.css b/src/styles/globals.css index 1461f4d6..ca3ee8d6 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -390,7 +390,7 @@ Menus, selects and popovers only scale: they are fully opaque from the first frame, so a paused animation clock still shows them readable. A tooltip also fades from half opacity. The exit holds a closed popup for 100ms; - `usePopupExitFallback` ends it on a timer if the clock is paused. */ + `usePopupMotion` ends it if the clock is paused. A Select has no exit. */ @keyframes popup-enter { from { scale: 0.97;