From d345520d72df041ac70546e4ce8b0f3fd536a563 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sat, 5 Sep 2026 03:15:03 -0300 Subject: [PATCH] fix(dashboard): read the combos usage-guide dismissal from an external store (base-red #12581) (#12671) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged. This removes the cause that #12607 had to freeze. `react-hooks/set-state-in-effect` on this file was living in `config/quality/eslint-suppressions.json` as a frozen count of 1 — the lint was green because the violation was suppressed, not because it was gone. `useSyncExternalStore` is the sanctioned shape for exactly this problem: `getServerSnapshot` supplies the SSR-safe default, `getSnapshot` reads localStorage after hydration, and the tree commits once instead of twice. The `storage` listener keeping other tabs in sync is a real bonus. The detail that makes this correct rather than merely lint-clean: you kept "hide for now" and "hide forever" as separate concepts — `usageGuideHiddenForNow` stays per-mount local state while only the persisted dismissal goes through the store. A naive conversion would have collapsed them and made the temporary hide survive a reload. Three things I added before merging: 1. **Dropped the `react-hooks/set-state-in-effect` entry from the suppressions file.** With the cause gone it becomes a stale allowlist entry, which is what the Fase 6A.3 stale-enforcement is built to flag. Verified: `eslint` on the file now reports only the 6 pre-existing `no-unused-vars`, which stay frozen. 2. **Updated the rationale comment above the hook** — it still described "correct it client-only, after hydration, in an effect", which is the shape you just removed. 3. **Rebaselined `combos/page.tsx` 5018 → 5066** in `file-size-baseline.json` with a dated annotation. The +48 lines are the module-scope store helpers; the cap is pre-authorized for legitimate growth and this is as legitimate as it gets. Validated on `release/v3.8.51`: `check-file-size` OK, `lint` clean, `typecheck:core` and `check:dashboard-typecheck` clean (207 pre-existing, all within baseline). --- ...v3851-combos-usage-guide-external-store.md | 1 + config/quality/eslint-suppressions.json | 3 - config/quality/file-size-baseline.json | 5 +- src/app/(dashboard)/dashboard/combos/page.tsx | 76 +++++++++++++++---- 4 files changed, 66 insertions(+), 19 deletions(-) create mode 100644 changelog.d/fixes/v3851-combos-usage-guide-external-store.md diff --git a/changelog.d/fixes/v3851-combos-usage-guide-external-store.md b/changelog.d/fixes/v3851-combos-usage-guide-external-store.md new file mode 100644 index 0000000000..ac4c882899 --- /dev/null +++ b/changelog.d/fixes/v3851-combos-usage-guide-external-store.md @@ -0,0 +1 @@ +- **fix(dashboard):** The Combos page usage guide now reads its dismissal through `useSyncExternalStore` instead of correcting SSR state inside an effect, removing an extra commit of the page tree on every load (and the `react-hooks/set-state-in-effect` error it raised). diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index fe963b547c..e10d1f6551 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -854,9 +854,6 @@ "src/app/(dashboard)/dashboard/combos/page.tsx": { "@typescript-eslint/no-unused-vars": { "count": 6 - }, - "react-hooks/set-state-in-effect": { - "count": 1 } }, "src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx": { diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 56752d58b9..4bed8fd2e2 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -434,7 +434,7 @@ "open-sse/vendor/codex-chatgpt-web/bridge.ts": 1335, "src/app/(dashboard)/dashboard/HomePageClient.tsx": 1344, "src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": 3186, - "src/app/(dashboard)/dashboard/combos/page.tsx": 5018, + "src/app/(dashboard)/dashboard/combos/page.tsx": 5066, "src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx": 1319, "src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx": 2491, "src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": 1631, @@ -643,5 +643,6 @@ "_rebaseline_2026_09_03_error_boundary_campaign": "Campanha de error-boundary (#12431 #12438 #12444 #12454 #12455 #12456 #12457 #12458 #12459 #12465 #12466 #12467 #12469 #12435), medido no tip com os 14 mergeados. open-sse/executors/codex.ts 1499->1505: os primeiros 4 (1499->1503) sao DRIFT ANTERIOR a esta campanha, ja presente no tip antes dela; os 2 ultimos (1503->1505) sao do #12444, que fecha o boundary de falha da resposta do Codex. Absorver o drift junto foi inevitavel porque o cap e um numero so, mas fica registrado aqui que 4 das 6 linhas nao sao desta leva. open-sse/vendor/codex-chatgpt-web/bridge.ts 1322->1335 (+13): tambem do #12444, no mesmo caminho de falha. NAO cobre open-sse/utils/stream.ts, que segue violando por drift anterior e independente.", "_rebaseline_2026_09_03_12352_apikey_acl": "PR #12352 (fix/api-key-create-acl-12275) crescimento proprio: src/lib/db/apiKeys.ts 1610->1625 (+15). A criacao de API key descartava a ACL enviada no payload; preservar essa ACL exige carregar e persistir o conjunto no mesmo chokepoint de INSERT do modulo de dominio, sem extracao possivel sem partir a funcao de criacao ao meio. Coberto pelos testes do proprio PR (54/54 focados na leva).", "_rebaseline_2026_09_03_houminxi_combo_stacked": "Leva HouMinXi (#12624 #12626 #12632 #12637): open-sse/services/combo.ts 4075->4080 (+5), medido no tip com os quatro mergeados. Cada PR registrou o proprio crescimento contra o tip de onde forkou (o #12637 ja subira o cap para 4075); as 5 linhas restantes so aparecem quando eles empilham, porque mais de um toca o mesmo chokepoint de scoring reset-aware em combo.ts. Fiacao em ponto existente, sem extracao possivel sem partir a funcao de selecao de alvos. Coberto por combo-strategies e reset-aware-request-scope-12600 (119/119 focados na leva).", - "_rebaseline_2026_09_04_12641_continuation_effective_input": "PR #12641 crescimento proprio: src/sse/handlers/chat.ts 2450->2454 (+4). A continuacao por previous_response_id encadeava a partir de clientRawRequest.body.input, que e capturado ANTES da reconstrucao do proprio chat.ts; quando o turno anterior ja era uma continuacao, esse campo guarda so o delta do cliente, e o erro se acumulava a cada salto ate a reconstrucao virar itens de tool sem prefixo. Persistir o input EFETIVO exige as linhas no ponto onde a reconstrucao termina, dentro do fluxo de despacho. Coberto por tests/unit/responses-continuation-store.test.ts (22/22 focados na leva)." + "_rebaseline_2026_09_04_12641_continuation_effective_input": "PR #12641 crescimento proprio: src/sse/handlers/chat.ts 2450->2454 (+4). A continuacao por previous_response_id encadeava a partir de clientRawRequest.body.input, que e capturado ANTES da reconstrucao do proprio chat.ts; quando o turno anterior ja era uma continuacao, esse campo guarda so o delta do cliente, e o erro se acumulava a cada salto ate a reconstrucao virar itens de tool sem prefixo. Persistir o input EFETIVO exige as linhas no ponto onde a reconstrucao termina, dentro do fluxo de despacho. Coberto por tests/unit/responses-continuation-store.test.ts (22/22 focados na leva).", + "_rebaseline_2026_09_05_12671_combos_usage_guide_external_store": "combos/page.tsx 5018 -> 5066: #12671 replaces the effect-based localStorage read with useSyncExternalStore; the +48 lines are the store helpers (subscribe/getSnapshot/getServerSnapshot/emit) hoisted to module scope, which is the sanctioned shape and what let the react-hooks/set-state-in-effect suppression be dropped." } diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx index 1a666b3634..21ef2e2134 100644 --- a/src/app/(dashboard)/dashboard/combos/page.tsx +++ b/src/app/(dashboard)/dashboard/combos/page.tsx @@ -1,6 +1,15 @@ "use client"; -import { useState, useEffect, useCallback, useMemo, useRef, memo, Suspense } from "react"; +import { + useState, + useEffect, + useCallback, + useMemo, + useRef, + useSyncExternalStore, + memo, + Suspense, +} from "react"; import dynamic from "next/dynamic"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; @@ -388,6 +397,42 @@ const STRATEGY_RECOMMENDATIONS_FALLBACK = { const COMBO_USAGE_GUIDE_STORAGE_KEY = "omniroute:combos:hide-usage-guide"; +// The dismissal lives in localStorage, which SSR cannot read: a lazy useState +// initializer would render "not dismissed" on the server and the real value on +// the client, and correcting that in an effect is a synchronous setState inside +// an effect (react-hooks/set-state-in-effect) that costs an extra commit of this +// whole tree. useSyncExternalStore is the sanctioned shape for exactly this — +// getServerSnapshot supplies the SSR-safe default, getSnapshot reads the store +// after hydration, and the two handlers below notify subscribers instead of +// setting state. The `storage` listener keeps other tabs in sync for free. +const usageGuideListeners = new Set<() => void>(); + +function subscribeUsageGuide(onStoreChange: () => void): () => void { + usageGuideListeners.add(onStoreChange); + globalThis.addEventListener?.("storage", onStoreChange); + return () => { + usageGuideListeners.delete(onStoreChange); + globalThis.removeEventListener?.("storage", onStoreChange); + }; +} + +function emitUsageGuideChange(): void { + for (const listener of usageGuideListeners) listener(); +} + +function getUsageGuideSnapshot(): boolean { + try { + return globalThis.localStorage?.getItem(COMBO_USAGE_GUIDE_STORAGE_KEY) !== "1"; + } catch { + // Storage access errors (privacy mode / restricted environments) show the guide. + return true; + } +} + +function getUsageGuideServerSnapshot(): boolean { + return true; +} + // Pure predicate hoisted out of the page component to keep its cyclomatic budget flat // (check:complexity new-code mode). function isStaleIntelligentSelection( @@ -766,16 +811,18 @@ function CombosPageContent() { // real stored value -- exactly the kind of source React's hydration // mismatch check is built to catch, and in dev mode a mismatch forces a // full client-only re-render of this tree, discarding whatever the fetch - // effects below had already populated. Start with the SSR-safe default on - // both passes and correct it client-only, after hydration, in an effect. - const [showUsageGuide, setShowUsageGuide] = useState(true); - useEffect(() => { - try { - setShowUsageGuide(globalThis.localStorage?.getItem(COMBO_USAGE_GUIDE_STORAGE_KEY) !== "1"); - } catch { - // Ignore storage access errors (privacy mode / restricted environments) - } - }, []); + // effects below had already populated. useSyncExternalStore renders the + // SSR-safe default on both passes and switches to the stored value at + // hydration, without a second commit — see the store helpers above. + const usageGuideNotDismissed = useSyncExternalStore( + subscribeUsageGuide, + getUsageGuideSnapshot, + getUsageGuideServerSnapshot + ); + // "Hide" (as opposed to "hide forever") is intentionally per-mount: it is not + // persisted, and remounting the page brings the guide back — same as before. + const [usageGuideHiddenForNow, setUsageGuideHiddenForNow] = useState(false); + const showUsageGuide = usageGuideNotDismissed && !usageGuideHiddenForNow; const [recentlyCreatedCombo, setRecentlyCreatedCombo] = useState(""); const [creatingKimiPreset, setCreatingKimiPreset] = useState(false); const [comboDragIndex, setComboDragIndex] = useState(null); @@ -1006,17 +1053,18 @@ function CombosPageContent() { }; const handleHideUsageGuideForever = () => { - setShowUsageGuide(false); try { globalThis.localStorage?.setItem(COMBO_USAGE_GUIDE_STORAGE_KEY, "1"); } catch {} + emitUsageGuideChange(); }; const handleShowUsageGuide = () => { - setShowUsageGuide(true); try { globalThis.localStorage?.removeItem(COMBO_USAGE_GUIDE_STORAGE_KEY); } catch {} + setUsageGuideHiddenForNow(false); + emitUsageGuideChange(); }; const handleFilterChange = (nextFilter) => { @@ -1149,7 +1197,7 @@ function CombosPageContent() { {showUsageGuide && ( setShowUsageGuide(false)} + onHide={() => setUsageGuideHiddenForNow(true)} onHideForever={handleHideUsageGuideForever} onCreateCombo={() => setShowCreateModal(true)} />