fix: popup exit and capture

This commit is contained in:
zhom committed 2026-10-19 05:35:28 +04:00
1 parent d93afc2bd5
commit f493c9ef7c
5 files changed
+78 -38

No files matched your search

+52 -17
View File
@@ -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);`,
+3 -1
View File
@@ -797,6 +797,8 @@ async fn pump(
ready: tokio::sync::oneshot::Sender<Result<(), String>>,
) {
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" }],
}),
+18 -17
View File
@@ -16,9 +16,13 @@ function assignRef<T>(ref: React.Ref<T> | 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<T extends HTMLElement>(ref?: React.Ref<T>) {
const motionAllowed = useMotionAllowed();
@@ -27,26 +31,23 @@ export function usePopupMotion<T extends HTMLElement>(ref?: React.Ref<T>) {
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();
+4 -2
View File
@@ -65,7 +65,9 @@ function SelectContent({
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
// 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 (
<SelectPrimitive.Portal>
@@ -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,
+1 -1
View File
@@ -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;