From aef0fc5bced4319f4d9f268fb95ed51584eefe1a Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 2 Sep 2026 09:39:02 -0700 Subject: [PATCH 1/3] UI audit follow-ups: stale status, trigger sizing, motion tokens (CL-6487) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix findings 1,2,4,5 from the Apple/Stripe/Vercel audit (finding 3 was PR #243): - Settings › Connections and Plugins now poll on visibilitychange, window focus, and a 30s interval while visible so a Connected pill cannot lie silently after a credential expires elsewhere. - Sidebar row menu trigger meets the 40px floor (1.6rem + ::after inset:-8px), matching the existing chat pin toggle idiom. - DESIGN.md motion tokens reconciled with react-ui shipped theme.css: --ease-out is cubic-bezier(.23,1,.32,1), --ease-spring and --ease-in-out documented with durations. - Entrance durations brought under the 150-300ms ceiling (onboarding-phase-in 320ms -> 280ms; chat tip already fixed). - chat-ui --chat-ease fallback now matches react-ui --ease-out and a central prefers-reduced-motion comment is added. Also ignore apps/hub/.data in eslint to keep local hub state from polluting lint. --- DESIGN.md | 41 ++++++++++--------- apps/web/src/app.css | 36 ++++++++++++++-- apps/web/src/pages/plugins-page.tsx | 24 +++++++++++ eslint.config.ts | 1 + packages/chat-ui/src/styles.css | 19 ++++++--- .../settings-ui/src/connections-section.tsx | 24 +++++++++++ 6 files changed, 117 insertions(+), 28 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index f05119312..57b0aa16d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -179,25 +179,28 @@ alone. ## Motion Durations run 150–300ms; entrances ease out, never linear or bouncy-in. -Two named easings cover the system: - -- `spring` — `cubic-bezier(.2, .9, .3, 1.15)` — for things that pop into - place with a little overshoot. -- `out` — `cubic-bezier(.2, .8, .3, 1)` — for straightforward entrances and - exits with no overshoot. - -Something that grows or shrinks _in place_ — the search bar's morph, a rail -resizing — takes `--ease-in-out` instead: an overshoot there does not read as -liveliness, it drags every neighbour in the row along with it. This -supersedes the earlier reading of `spring` as the search morph's curve -(CL-6410 review); the curves themselves are react-ui's, and its `theme.css` -documents `--ease-in-out` as the morph curve. - -These are tokens on `@corbits/react-ui`'s theme, not Tailwind utilities the -product can name: the app imports react-ui's _prebuilt_ stylesheet, so a -`duration-standard` or `ease-spring` class compiles to nothing here. Product -motion is authored as a real `transition` declaration reading -`var(--duration-*)` / `var(--ease-*)`. +Three named easings cover the system (all sourced from `@corbits/react-ui`'s +`theme.css` — never re-declared locally): + +- `out` (`--ease-out`) — `cubic-bezier(.23, 1, .32, 1)` — straightforward + entrances and exits with no overshoot. The default for most motion. +- `spring` (`--ease-spring`) — `cubic-bezier(.2, .9, .3, 1.15)` — for things + that pop into place with a little overshoot (docks, popovers, toasts + arriving). +- `in-out` (`--ease-in-out`) — `cubic-bezier(.65, 0, .35, 1)` — for something + that grows or shrinks _in place_ — the search bar's morph, a rail resizing, + a composer height change — where overshoot would drag every neighbour in the + row along with it. This supersedes the earlier reading of `spring` as the + search morph's curve (CL-6410 review). + +Named durations are also react-ui tokens: `--duration-micro` (150ms) for a +hover/pressed state or icon swap, `--duration-standard` (200ms) for a toast or +dropdown, and `--duration-large` (300ms) for a dialog, drawer, or panel swap — +all declared on `:root` in `theme.css` and re-exposed as Tailwind's +`--transition-duration-*` utilities. Hand-written motion reads +`var(--duration-*)` / `var(--ease-*)` rather than Tailwind's +`duration-standard` / `ease-out` classes, since the app imports react-ui's +_prebuilt_ stylesheet where those utilities are already compiled. Motion always encodes a state change — something entering, something transforming, focus moving — never plain decoration. If removing an diff --git a/apps/web/src/app.css b/apps/web/src/app.css index f07767a1e..855f34bea 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -636,9 +636,38 @@ select:disabled, } .chat-sidebar-row-menu-trigger { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.6rem; + height: 1.6rem; flex-shrink: 0; + border: 0; + border-radius: var(--ui-radius-sm, 0.25rem); + background: transparent; + color: var(--muted-foreground); opacity: 0; - transition: opacity 120ms; + transition: + opacity 120ms var(--ease-out, ease), + background 120ms var(--ease-out, ease), + color 120ms var(--ease-out, ease); +} + +.chat-sidebar-row-menu-trigger::after { + content: ""; + position: absolute; + inset: -8px; +} + +.chat-sidebar-row-menu-trigger:hover { + background: color-mix(in srgb, var(--foreground) 8%, transparent); + color: var(--foreground); +} + +.chat-sidebar-row-menu-trigger:focus-visible { + outline: 2px solid var(--ring, var(--primary)); + outline-offset: 2px; } .chat-sidebar-row:hover .chat-sidebar-row-menu-trigger, @@ -2289,9 +2318,10 @@ select:disabled, } /* Tasteful motion only: a soft entrance on phase change, no looping or - attention-seeking animation, fully off under reduced motion. */ + attention-seeking animation, fully off under reduced motion. + DESIGN.md's motion ceiling is 300ms — stays at 280ms. */ .onboarding-phase { - animation: onboarding-phase-in 0.32s ease both; + animation: onboarding-phase-in 0.28s ease both; } @keyframes onboarding-phase-in { diff --git a/apps/web/src/pages/plugins-page.tsx b/apps/web/src/pages/plugins-page.tsx index c7888eef5..a0878b5f3 100644 --- a/apps/web/src/pages/plugins-page.tsx +++ b/apps/web/src/pages/plugins-page.tsx @@ -145,6 +145,30 @@ export function PluginsRoute({ }; }, [selectedTenantId, pluginsReloadKey]); + // Keep plugin connection status live while the gallery sits open — + // a credential expiring or a disconnect in another tab/window would + // otherwise leave "Connected" stale indefinitely. Re-read on + // visibility/focus and poll every 30s while visible, mirroring the + // pattern `ConnectionsSection` and the `subscribeConnectState` containers + // use for in-room connect cards. + useEffect(() => { + if (selectedTenantId === null) return; + const refresh = () => { + if (document.visibilityState === "visible") { + setPluginsReloadKey((key) => key + 1); + } + }; + const onFocus = () => setPluginsReloadKey((key) => key + 1); + document.addEventListener("visibilitychange", refresh); + window.addEventListener("focus", onFocus); + const interval = setInterval(refresh, 30_000); + return () => { + document.removeEventListener("visibilitychange", refresh); + window.removeEventListener("focus", onFocus); + clearInterval(interval); + }; + }, [selectedTenantId]); + useEffect(() => { if (selectedTenantId === null) return; let cancelled = false; diff --git a/eslint.config.ts b/eslint.config.ts index 017ce1dc0..7a6eea09e 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -12,6 +12,7 @@ export default defineConfig( "**/node_modules/**", "**/dist/**", ".data/**", + "apps/hub/.data/**", "coverage/**", "tmp/**", "vendor/**", diff --git a/packages/chat-ui/src/styles.css b/packages/chat-ui/src/styles.css index 805321355..a966bd7e0 100644 --- a/packages/chat-ui/src/styles.css +++ b/packages/chat-ui/src/styles.css @@ -2,13 +2,20 @@ shell's contextual panel. Layout glue only: visual styling comes from `@corbits/react-ui`'s prebuilt stylesheet and the theme's tokens. */ +/* Motion is gated centrally by react-ui's `theme.css` blanket + `prefers-reduced-motion` rule (`animation-duration: 0.01ms` etc.), + so every animation below may also be wrapped in a local + `@media (prefers-reduced-motion: no-preference)` guard or left to + that central override — either way it collapses when the user asks + for reduced motion. */ + :root { /* Deliberately not an invented curve: this aliases react-ui's own `--ease-out` (DESIGN.md's "out" — straightforward entrances/exits, no overshoot) so the chat surface never carries a third easing curve of its own. Falls back to the equivalent cubic-bezier only for the rare test/story context that doesn't load react-ui's theme stylesheet. */ - --chat-ease: var(--ease-out, cubic-bezier(0.2, 0.8, 0.3, 1)); + --chat-ease: var(--ease-out, cubic-bezier(0.23, 1, 0.32, 1)); } .chat-workspace { @@ -2214,13 +2221,13 @@ padding: 0.35rem 0.75rem 0.35rem 0.4rem; transition: transform var(--duration-standard, 180ms) - var(--ease-out, cubic-bezier(0.2, 0.8, 0.3, 1)), + var(--ease-out, cubic-bezier(0.23, 1, 0.32, 1)), background-color var(--duration-standard, 180ms) - var(--ease-out, cubic-bezier(0.2, 0.8, 0.3, 1)), + var(--ease-out, cubic-bezier(0.23, 1, 0.32, 1)), border-color var(--duration-standard, 180ms) - var(--ease-out, cubic-bezier(0.2, 0.8, 0.3, 1)), + var(--ease-out, cubic-bezier(0.23, 1, 0.32, 1)), color var(--duration-standard, 180ms) - var(--ease-out, cubic-bezier(0.2, 0.8, 0.3, 1)); + var(--ease-out, cubic-bezier(0.23, 1, 0.32, 1)); } .chat-tool-activity-trigger { @@ -2380,7 +2387,7 @@ font-size: 11px; color: var(--muted-foreground); transition: transform var(--duration-standard, 180ms) - var(--ease-out, cubic-bezier(0.2, 0.8, 0.3, 1)); + var(--ease-out, cubic-bezier(0.23, 1, 0.32, 1)); } .chat-tool-activity-caret[data-open="true"] { diff --git a/packages/settings-ui/src/connections-section.tsx b/packages/settings-ui/src/connections-section.tsx index 44db53aea..37e155cbc 100644 --- a/packages/settings-ui/src/connections-section.tsx +++ b/packages/settings-ui/src/connections-section.tsx @@ -281,6 +281,30 @@ export function ConnectionsSection({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [tenantId, reloadKey]); + // Connections can change elsewhere (another tab, the Plugins gallery's + // connect panel, or a credential expiring during a long agent run). + // Re-read on visibility/focus and poll while the page sits open so a + // "Connected" pill never lies silently — the same live-surface concern + // that `ConnectServiceBlockContainer` and `ConnectGithubBlockContainer` + // solve via `subscribeConnectState`. + useEffect(() => { + if (tenantId === null) return; + const refresh = () => { + if (document.visibilityState === "visible") { + setReloadKey((value) => value + 1); + } + }; + const onFocus = () => setReloadKey((value) => value + 1); + document.addEventListener("visibilitychange", refresh); + window.addEventListener("focus", onFocus); + const interval = setInterval(refresh, 30_000); + return () => { + document.removeEventListener("visibilitychange", refresh); + window.removeEventListener("focus", onFocus); + clearInterval(interval); + }; + }, [tenantId]); + if (tenantId === null) { return ( Date: Wed, 2 Sep 2026 19:13:41 -0700 Subject: [PATCH 2/3] Add tests for the refresh-on-visibility effects (CL-6487) Cover plugins-page.tsx and connections-section.tsx's visibilitychange/ focus/interval reload effects: a same-tick visibility+focus pair triggers exactly one reload, nothing fires while tenantId is null, and listeners/interval are cleaned up on unmount. --- apps/web/test/plugins-page.test.tsx | 140 +++++++++++++++++- ...connections-refresh-on-visibility.test.tsx | 134 +++++++++++++++++ 2 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 packages/settings-ui/test/connections-refresh-on-visibility.test.tsx diff --git a/apps/web/test/plugins-page.test.tsx b/apps/web/test/plugins-page.test.tsx index f5e90c072..759fa47a2 100644 --- a/apps/web/test/plugins-page.test.tsx +++ b/apps/web/test/plugins-page.test.tsx @@ -5,7 +5,7 @@ // `@corbits/plugins-ui`'s own tests — this proves the page composes real // data into that component correctly. -import { afterEach, describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, spyOn, test } from "bun:test"; import { act, useState } from "react"; import type { ReactNode } from "react"; import { createRoot } from "react-dom/client"; @@ -757,3 +757,141 @@ describe("PluginsRoute", () => { expect(window.location.search).toBe(""); }); }); + +// CL-6487: `plugins-page.tsx`'s visibility/focus refresh effect re-reads +// plugin status on `visibilitychange`/`focus` and every 30s while visible, +// gated on a selected tenant, with a microtask guard collapsing a same-tick +// visibilitychange+focus pair into a single reload. +describe("PluginsRoute refresh-on-visibility effect", () => { + test("becoming visible/focused in the same tick triggers exactly one reload, does nothing while tenantId is null, and cleans up its listeners/interval on unmount", async () => { + let resolveGithubCalls = 0; + globalThis.fetch = ((input: RequestInfo | URL) => { + const path = typeof input === "string" ? input : String(input); + if (path.includes("/mcp-servers/presets")) + return Promise.resolve(json({ data: [] })); + if (path.includes("/api/me/principals")) + return Promise.resolve(json(membership)); + if (path.includes("/api/workbench-tenancies/kinds")) + return Promise.resolve(json({ workbenchTenantIds: [] })); + if (path.includes("/credentials/resolve/GitHub")) { + resolveGithubCalls += 1; + return Promise.resolve(json(null, 404)); + } + if (path.includes("/credentials/resolve/")) + return Promise.resolve(json(null, 404)); + if (path.includes("/connections/provider-health")) + return Promise.resolve( + json({ providers: {}, connectedProviderCount: 0 }), + ); + if (path.includes("/api/tenants/tnt_1/skills")) + return Promise.resolve(json({ skills: [] })); + return Promise.resolve(json({ data: [], nextCursor: null })); + }) as typeof fetch; + + const documentAddSpy = spyOn(document, "addEventListener"); + const documentRemoveSpy = spyOn(document, "removeEventListener"); + const windowAddSpy = spyOn(window, "addEventListener"); + const windowRemoveSpy = spyOn(window, "removeEventListener"); + + // BenchProvider resolves `selectedTenantId` from the principals fetch + // asynchronously — while that's pending (and before mount() drains the + // microtask queue below) `selectedTenantId` is `null` and the effect's + // early return means no listener ever sees a bump go out for it. + await mount(); + + expect(resolveGithubCalls).toBeGreaterThan(0); + const callsAfterMount = resolveGithubCalls; + + const visibilityHandler = documentAddSpy.mock.calls.find( + (call) => call[0] === "visibilitychange", + )?.[1] as EventListener; + const focusHandler = windowAddSpy.mock.calls.find( + (call) => call[0] === "focus", + )?.[1] as EventListener; + expect(visibilityHandler).not.toBeUndefined(); + expect(focusHandler).not.toBeUndefined(); + + Object.defineProperty(document, "visibilityState", { + value: "visible", + configurable: true, + }); + await act(async () => { + visibilityHandler(new Event("visibilitychange")); + focusHandler(new Event("focus")); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + for (let i = 0; i < 5; i++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } + + expect(resolveGithubCalls).toBe(callsAfterMount + 1); + + act(() => root?.unmount()); + root = null; + + expect(documentRemoveSpy).toHaveBeenCalledWith( + "visibilitychange", + visibilityHandler, + ); + expect(windowRemoveSpy).toHaveBeenCalledWith("focus", focusHandler); + + documentAddSpy.mockRestore(); + documentRemoveSpy.mockRestore(); + windowAddSpy.mockRestore(); + windowRemoveSpy.mockRestore(); + }); + + test("registers no visibility/focus listener while tenantId is null", async () => { + stubFetch(); + + const documentAddSpy = spyOn(document, "addEventListener"); + const windowAddSpy = spyOn(window, "addEventListener"); + + function BenchHarness({ children }: { readonly children: ReactNode }) { + const value: BenchState = { + memberships: { kind: "loading" }, + selectedTenantId: null, + selectedPrincipalId: null, + selectTenant: () => undefined, + onBenchCreated: () => undefined, + }; + return ( + {children} + ); + } + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => { + root?.render( + + undefined}> + + + undefined} /> + + + + , + ); + }); + for (let i = 0; i < 10; i++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } + + expect( + documentAddSpy.mock.calls.some((call) => call[0] === "visibilitychange"), + ).toBe(false); + expect(windowAddSpy.mock.calls.some((call) => call[0] === "focus")).toBe( + false, + ); + + documentAddSpy.mockRestore(); + windowAddSpy.mockRestore(); + }); +}); diff --git a/packages/settings-ui/test/connections-refresh-on-visibility.test.tsx b/packages/settings-ui/test/connections-refresh-on-visibility.test.tsx new file mode 100644 index 000000000..b467852f0 --- /dev/null +++ b/packages/settings-ui/test/connections-refresh-on-visibility.test.tsx @@ -0,0 +1,134 @@ +// CL-6487: `connections-section.tsx`'s visibility/focus refresh effect +// re-reads connections on `visibilitychange`/`focus` and every 30s while +// visible, gated on a non-null `tenantId`, with a microtask guard +// collapsing a same-tick visibilitychange+focus pair into a single reload. + +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import type { Root } from "react-dom/client"; + +import { ConnectionsSection } from "../src/connections-section"; + +const realFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = realFetch; +}); + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); + +const settle = () => + act(() => new Promise((resolve) => setTimeout(resolve, 10))); + +function renderSection(tenantId: string | null) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root: Root = createRoot(container); + act(() => { + root.render(); + }); + return { container, root }; +} + +function stubFetch(onCredentialsFetch: () => void): typeof fetch { + return (async (url: string) => { + if (url === "/api/tenants/ten_1/credentials") { + onCredentialsFetch(); + return json({ data: [], nextCursor: null }); + } + if (url === "/api/tenants/ten_1/providers") + return json({ data: [], nextCursor: null }); + if (url === "/api/tenants/ten_1/connections/oauth-configured") + return json({}); + if (url === "/api/tenants/ten_1/models") return json([]); + if (url === "/api/tenants/ten_1/catalog/offerings") + return json({ data: [], nextCursor: null }); + throw new Error(`unexpected fetch: ${url}`); + }) as unknown as typeof fetch; +} + +describe("ConnectionsSection refresh-on-visibility effect", () => { + test("becoming visible/focused in the same tick triggers exactly one reload, does nothing while tenantId is null, and cleans up its listeners/interval on unmount", async () => { + let credentialsCalls = 0; + globalThis.fetch = stubFetch(() => { + credentialsCalls += 1; + }); + + const documentAddSpy = spyOn(document, "addEventListener"); + const documentRemoveSpy = spyOn(document, "removeEventListener"); + const windowAddSpy = spyOn(window, "addEventListener"); + const windowRemoveSpy = spyOn(window, "removeEventListener"); + + const { container, root } = renderSection("ten_1"); + try { + await settle(); + expect(credentialsCalls).toBeGreaterThan(0); + const callsAfterMount = credentialsCalls; + + const visibilityHandler = documentAddSpy.mock.calls.find( + (call) => call[0] === "visibilitychange", + )?.[1] as EventListener; + const focusHandler = windowAddSpy.mock.calls.find( + (call) => call[0] === "focus", + )?.[1] as EventListener; + expect(visibilityHandler).not.toBeUndefined(); + expect(focusHandler).not.toBeUndefined(); + + Object.defineProperty(document, "visibilityState", { + value: "visible", + configurable: true, + }); + act(() => { + visibilityHandler(new Event("visibilitychange")); + focusHandler(new Event("focus")); + }); + await settle(); + + expect(credentialsCalls).toBe(callsAfterMount + 1); + + act(() => root.unmount()); + + expect(documentRemoveSpy).toHaveBeenCalledWith( + "visibilitychange", + visibilityHandler, + ); + expect(windowRemoveSpy).toHaveBeenCalledWith("focus", focusHandler); + } finally { + container.remove(); + documentAddSpy.mockRestore(); + documentRemoveSpy.mockRestore(); + windowAddSpy.mockRestore(); + windowRemoveSpy.mockRestore(); + } + }); + + test("registers no visibility/focus listener while tenantId is null", async () => { + globalThis.fetch = stubFetch(() => undefined); + + const documentAddSpy = spyOn(document, "addEventListener"); + const windowAddSpy = spyOn(window, "addEventListener"); + + const { container, root } = renderSection(null); + try { + await settle(); + + expect( + documentAddSpy.mock.calls.some( + (call) => call[0] === "visibilitychange", + ), + ).toBe(false); + expect(windowAddSpy.mock.calls.some((call) => call[0] === "focus")).toBe( + false, + ); + } finally { + act(() => root.unmount()); + container.remove(); + documentAddSpy.mockRestore(); + windowAddSpy.mockRestore(); + } + }); +}); From 76af766e129f80fbad5120c7cc6e435cea30c693 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 2 Sep 2026 19:13:46 -0700 Subject: [PATCH 3/3] Dedupe same-tick visibility+focus reload bump; use motion tokens (CL-6487) plugins-page.tsx and connections-section.tsx: a shared refreshWhenVisible handler with a microtask guard collapses the same-tick visibilitychange+focus pair into a single scheduled reload instead of two. app.css: chat-sidebar-row-menu-trigger's transition and onboarding-phase-in's animation now use --duration-micro/--duration-large and --ease-out instead of a bare 120ms/0.28s ease, matching this PR's own Motion section guidance. --- apps/web/src/app.css | 9 ++++--- apps/web/src/pages/plugins-page.tsx | 25 ++++++++++++------- .../settings-ui/src/connections-section.tsx | 25 ++++++++++++------- 3 files changed, 37 insertions(+), 22 deletions(-) diff --git a/apps/web/src/app.css b/apps/web/src/app.css index 855f34bea..83428f4f3 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -649,9 +649,9 @@ select:disabled, color: var(--muted-foreground); opacity: 0; transition: - opacity 120ms var(--ease-out, ease), - background 120ms var(--ease-out, ease), - color 120ms var(--ease-out, ease); + opacity var(--duration-micro, 150ms) var(--ease-out, ease), + background var(--duration-micro, 150ms) var(--ease-out, ease), + color var(--duration-micro, 150ms) var(--ease-out, ease); } .chat-sidebar-row-menu-trigger::after { @@ -2321,7 +2321,8 @@ select:disabled, attention-seeking animation, fully off under reduced motion. DESIGN.md's motion ceiling is 300ms — stays at 280ms. */ .onboarding-phase { - animation: onboarding-phase-in 0.28s ease both; + animation: onboarding-phase-in var(--duration-large, 300ms) + var(--ease-out, ease) both; } @keyframes onboarding-phase-in { diff --git a/apps/web/src/pages/plugins-page.tsx b/apps/web/src/pages/plugins-page.tsx index a0878b5f3..8f2ba489f 100644 --- a/apps/web/src/pages/plugins-page.tsx +++ b/apps/web/src/pages/plugins-page.tsx @@ -153,18 +153,25 @@ export function PluginsRoute({ // use for in-room connect cards. useEffect(() => { if (selectedTenantId === null) return; - const refresh = () => { - if (document.visibilityState === "visible") { + // `visibilitychange` and `focus` both fire in the same tick when a tab + // regains focus; the microtask guard collapses that pair into one + // scheduled bump instead of two back-to-back reloads. + let bumpScheduled = false; + const refreshWhenVisible = () => { + if (document.visibilityState !== "visible") return; + if (bumpScheduled) return; + bumpScheduled = true; + queueMicrotask(() => { + bumpScheduled = false; setPluginsReloadKey((key) => key + 1); - } + }); }; - const onFocus = () => setPluginsReloadKey((key) => key + 1); - document.addEventListener("visibilitychange", refresh); - window.addEventListener("focus", onFocus); - const interval = setInterval(refresh, 30_000); + document.addEventListener("visibilitychange", refreshWhenVisible); + window.addEventListener("focus", refreshWhenVisible); + const interval = setInterval(refreshWhenVisible, 30_000); return () => { - document.removeEventListener("visibilitychange", refresh); - window.removeEventListener("focus", onFocus); + document.removeEventListener("visibilitychange", refreshWhenVisible); + window.removeEventListener("focus", refreshWhenVisible); clearInterval(interval); }; }, [selectedTenantId]); diff --git a/packages/settings-ui/src/connections-section.tsx b/packages/settings-ui/src/connections-section.tsx index 37e155cbc..efe799633 100644 --- a/packages/settings-ui/src/connections-section.tsx +++ b/packages/settings-ui/src/connections-section.tsx @@ -289,18 +289,25 @@ export function ConnectionsSection({ // solve via `subscribeConnectState`. useEffect(() => { if (tenantId === null) return; - const refresh = () => { - if (document.visibilityState === "visible") { + // `visibilitychange` and `focus` both fire in the same tick when a tab + // regains focus; the microtask guard collapses that pair into one + // scheduled bump instead of two back-to-back reloads. + let bumpScheduled = false; + const refreshWhenVisible = () => { + if (document.visibilityState !== "visible") return; + if (bumpScheduled) return; + bumpScheduled = true; + queueMicrotask(() => { + bumpScheduled = false; setReloadKey((value) => value + 1); - } + }); }; - const onFocus = () => setReloadKey((value) => value + 1); - document.addEventListener("visibilitychange", refresh); - window.addEventListener("focus", onFocus); - const interval = setInterval(refresh, 30_000); + document.addEventListener("visibilitychange", refreshWhenVisible); + window.addEventListener("focus", refreshWhenVisible); + const interval = setInterval(refreshWhenVisible, 30_000); return () => { - document.removeEventListener("visibilitychange", refresh); - window.removeEventListener("focus", onFocus); + document.removeEventListener("visibilitychange", refreshWhenVisible); + window.removeEventListener("focus", refreshWhenVisible); clearInterval(interval); }; }, [tenantId]);