From 5d1cb4e59fcb0a5bd94bac3bd7c742feac472ea8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ho=C3=A0i=20Nh=E1=BB=9B?= Date: Tue, 2 Jun 2026 01:57:31 +0000 Subject: [PATCH] =?UTF-8?q?feat(roadmap):=20M-B=20foundation=20=E2=80=94?= =?UTF-8?q?=20detector=20registry=20+=20hero=20detector=20+=20onIdle=20dri?= =?UTF-8?q?ver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T1: Detector registry with type-safe contract (src/types/registry.ts, src/inject/registry.ts) T2: Registry dispatch wired into inject pipeline T3: React adapters r17/r18/r19/r19.2 with version sniffing T4: Settings storage layer with zod validation (src/settings/storage.ts, types.ts) T5: v0→v1 settings migration with backup (src/settings/migrate.ts) T6: Settings UI tab with toggles + thresholds (src/panel/tabs/SettingsTab.tsx) T7: Reconciler-keys hero detector (src/inject/detectors/reconciler-keys.ts) T8: Closure-leak Strategy-A bridge (src/inject/detectors/closure-leak.ts) T9: Scan-overlay + onIdle driver + getBoundingClientRect moved off hot path T10: drainAll + smoke-test coverage (src/__tests__/*.test.ts) Test pass: 219 (baseline at M-A: 141) tsc --noEmit: 0 errors Recovery branch — replays content from orphaned feat/self-roadmap-m-b-registry onto origin/main now that M-A landed via #50. --- CHANGELOG.md | 20 +- M_B_SMOKE_TEST.md | 183 ++++++++++ package-lock.json | 13 +- package.json | 29 +- src/__tests__/SettingsTab.test.tsx | 224 ++++++++++++ src/__tests__/closure-leak.test.ts | 146 ++++++++ src/__tests__/react-adapter.test.ts | 103 ++++++ src/__tests__/reconciler-keys.test.ts | 221 ++++++++++++ src/__tests__/registry.test.ts | 433 ++++++++++++++++++++++++ src/__tests__/scan-overlay.test.ts | 187 ++++++++++ src/__tests__/settings-migrate.test.ts | 154 +++++++++ src/__tests__/settings-storage.test.ts | 140 ++++++++ src/content/index.ts | 19 +- src/inject/detectors/closure-leak.ts | 143 ++++++++ src/inject/detectors/index.ts | 21 ++ src/inject/detectors/reconciler-keys.ts | 223 ++++++++++++ src/inject/detectors/scan-overlay.ts | 151 +++++++++ src/inject/index.ts | 147 +++++++- src/inject/react-adapters/index.ts | 138 ++++++++ src/inject/react-adapters/r17.ts | 159 +++++++++ src/inject/react-adapters/r18.ts | 173 ++++++++++ src/inject/react-adapters/r19.ts | 182 ++++++++++ src/inject/react-adapters/r19_2.ts | 168 +++++++++ src/inject/react-adapters/types.ts | 227 +++++++++++++ src/inject/react-adapters/utils.ts | 81 +++++ src/inject/registry.ts | 338 ++++++++++++++++++ src/panel/Panel.tsx | 6 +- src/panel/styles/panel.css | 297 ++++++++++++++++ src/panel/tabs/SettingsTab.tsx | 255 ++++++++++++++ src/settings/migrate.ts | 187 ++++++++++ src/settings/storage.ts | 104 ++++++ src/settings/types.ts | 102 ++++++ src/types/index.ts | 3 +- src/types/registry.ts | 178 ++++++++++ 34 files changed, 5123 insertions(+), 32 deletions(-) create mode 100644 M_B_SMOKE_TEST.md create mode 100644 src/__tests__/SettingsTab.test.tsx create mode 100644 src/__tests__/closure-leak.test.ts create mode 100644 src/__tests__/react-adapter.test.ts create mode 100644 src/__tests__/reconciler-keys.test.ts create mode 100644 src/__tests__/registry.test.ts create mode 100644 src/__tests__/scan-overlay.test.ts create mode 100644 src/__tests__/settings-migrate.test.ts create mode 100644 src/__tests__/settings-storage.test.ts create mode 100644 src/inject/detectors/closure-leak.ts create mode 100644 src/inject/detectors/index.ts create mode 100644 src/inject/detectors/reconciler-keys.ts create mode 100644 src/inject/detectors/scan-overlay.ts create mode 100644 src/inject/react-adapters/index.ts create mode 100644 src/inject/react-adapters/r17.ts create mode 100644 src/inject/react-adapters/r18.ts create mode 100644 src/inject/react-adapters/r19.ts create mode 100644 src/inject/react-adapters/r19_2.ts create mode 100644 src/inject/react-adapters/types.ts create mode 100644 src/inject/react-adapters/utils.ts create mode 100644 src/inject/registry.ts create mode 100644 src/panel/tabs/SettingsTab.tsx create mode 100644 src/settings/migrate.ts create mode 100644 src/settings/storage.ts create mode 100644 src/settings/types.ts create mode 100644 src/types/registry.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 495a69a..79260ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,15 +10,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **`Star Check` CI workflow** (`.github/workflows/star-check.yml`) — runs on every PR and blocks merge if the author hasn't starred the repository. Auto-exempts maintainer (`@hoainho`), bots (Dependabot, gemini-code-assist, google-cla, github-actions, renovate), `tracked-plan`-labeled PRs (maintainer-driven milestone work), and `pre-star-rule`-labeled PRs (grandfathered pre-policy). Uses the public `GET /users/{login}/starred/{owner}/{repo}` API — no extra auth scope. +- **Detector registry foundation (M-B)** — pluggable `Detector` lifecycle interface (`id`, `category`, `budgetMs`, `confidence`, `prodCapable`, `init`, `onCommit`, `onIdle`, `drain`, `teardown`, `recover`). Per-detector try/catch isolation + staged-write transactionality + bounded LRU dedupe. +- **React version adapter** scaffolding for r17/r18/r19/r19.2 with version-aware fiber tag enums + `getDisplayName`. Handles `OffscreenComponent` / `LegacyHiddenComponent` / `IndeterminateComponent` landmines across React versions. +- **Settings storage** with zod schema validation, v0→v1 migration (from legacy `react_debugger_disabled_sites` array), and `chrome.storage.local` persistence under `react_debugger_settings_v1`. +- **Settings UI** — new panel tab with per-detector toggles + confidence badges + per-site override list. +- **Hero detector #1 — reconciler-keys**: detects `Math.random()` / `Date.now()` keys (emit on first commit) AND numeric-index keys with verified cross-commit reorder. Emits new `UNSTABLE_LIST_KEY` issue type. Confidence: high (defaults ON). Production-capable. +- **Registry `onIdle` driver** (`Registry.dispatchIdle`) — scheduled via `requestIdleCallback` after every commit, enables detectors to do deferred work off the hot path. ### Changed -- **Contributor claim policy hardened** — starring the repo is now a **hard precondition for merge**, enforced by CI (see Star Check workflow above). The previous "comment `I'll take this`" rule stays honor-system + reviewer-checked. See [CONTRIBUTING.md → How to claim](.github/CONTRIBUTING.md#-how-to-claim-an-issue-required-before-opening-a-pr). -- PR template "Claim confirmation" section updated to flag the star as CI-enforced. +- **Contributor claim policy hardened** — starring the repo is now a **hard precondition for merge**, enforced by CI. See [CONTRIBUTING.md → How to claim](.github/CONTRIBUTING.md#-how-to-claim-an-issue-required-before-opening-a-pr). The previous "comment `I'll take this`" rule stays honor-system + reviewer-checked. +- PR template adds a "Claim confirmation" section with checkboxes for the two required steps. Maintainer / tracked-plan PRs can delete this section. +- **closure-leak detector** migrated to registry pattern via Strategy A (thin adapter through `window.__REACT_DEBUGGER_CLOSURE_BRIDGE__`). Legacy `_installClosureTracking` body unchanged; existing behavior preserved end-to-end. Confidence: medium (defaults OFF). +- **scan-overlay detector** migrated to registry pattern. **`getBoundingClientRect()` moved from synchronous `onCommit` to deferred `onIdle`** — the biggest live perf-budget violation flagged by the M-A audit is now closed. Confidence: high (defaults ON). Behavior preserved with a one-idle-callback-tick delay. + +### Fixed + +- Periodic cleanup now drains the detector registry's per-detector buffers every 60s, preventing unbounded buffer growth if users opt in to medium/low-confidence detectors. ### Migration -- 4 PRs that were already open when this policy landed (#17, #36, #37, #38) labeled `pre-star-rule` and grandfathered through the check. +- First run after upgrade: existing per-site disabled list (`react_debugger_disabled_sites`) is migrated to the new `Settings.perSite` shape. Migration is idempotent; legacy key is removed after successful migration. +- Default-policy applied on first install: high-confidence detectors (reconciler-keys, scan-overlay) default ON; medium/low (closure-leak) default OFF. +- 4 PRs that were already open when the Star Check policy landed (#17, #36, #37, #38) labeled `pre-star-rule` and grandfathered through the check. ## [2.0.3] - 2026-02-28 diff --git a/M_B_SMOKE_TEST.md b/M_B_SMOKE_TEST.md new file mode 100644 index 0000000..e369a8b --- /dev/null +++ b/M_B_SMOKE_TEST.md @@ -0,0 +1,183 @@ +# M-B Manual Smoke-Test Plan + +Tester runbook for someone with real Chrome + devtools extension loaded. +Extension must be built (`npm run build`) and loaded as an unpacked extension. + +--- + +## Prerequisites + +1. `npm run build` — exits 0. +2. Open `chrome://extensions`, enable **Developer mode**, load unpacked from `dist/`. +3. Open a test page running React (e.g. `http://localhost:3000` with any CRA/Vite app, or [react.dev](https://react.dev)). +4. Open Chrome DevTools → find the **React Debugger** panel tab. + +--- + +## TC-01: Extension loads without errors + +**Steps:** +1. Open a React page. +2. Open DevTools Console. +3. Check for errors from `react-debugger` or `chrome-extension://`. + +**Expected:** No errors. Panel shows "React Debugger" tab. + +--- + +## TC-02: Settings panel — detector toggles visible + +**Steps:** +1. Click **Settings** tab in the React Debugger panel. +2. Observe detector list. + +**Expected:** +- `reconciler-keys` listed — confidence badge `high` — toggle ON by default. +- `scan-overlay` listed — confidence badge `high` — toggle ON by default. +- `closure-leak` listed — confidence badge `medium` — toggle OFF by default. + +--- + +## TC-03: Settings panel — toggle closure-leak ON + +**Steps:** +1. In Settings, toggle `closure-leak` to ON. +2. Interact with the page (click buttons, navigate between views). +3. Wait 60 seconds. + +**Expected:** +- No browser tab memory growth visible in Task Manager (drain prevents buffer from growing). +- No JS errors in Console. + +--- + +## TC-04: Reconciler-keys detector — Math.random() key + +**Steps:** +1. Load or create a React component that renders a list with `key={Math.random()}`. + Example: `items.map(item =>
{item}
)` +2. Trigger a re-render. +3. Check the React Debugger **Issues** tab. + +**Expected:** +- Issue of type `UNSTABLE_LIST_KEY` appears within ~1s of the commit. +- Issue description mentions "Math.random" or "unstable key". + +--- + +## TC-05: Reconciler-keys detector — Date.now() key + +**Steps:** +1. Render a list with `key={Date.now()}`. +2. Trigger a re-render. + +**Expected:** Same as TC-04 — `UNSTABLE_LIST_KEY` issue appears. + +--- + +## TC-06: Reconciler-keys detector — numeric index reorder + +**Steps:** +1. Render a list with index keys: `items.map((item, i) =>
{item}
)`. +2. Trigger a reorder of items (reverse the array, for example). +3. Check Issues tab. + +**Expected:** `UNSTABLE_LIST_KEY` issue appears flagging numeric-index reorder. + +--- + +## TC-07: Scan-overlay detector — bounding rect off commit path + +**Steps:** +1. Enable Performance profiling in DevTools → record a few seconds of React activity. +2. Stop recording. +3. Inspect the flame chart for `onCommitFiberRoot`. + +**Expected:** +- `getBoundingClientRect` calls do NOT appear inside `onCommitFiberRoot` stack frames. +- They appear inside idle callback frames (separate from the commit stack). +- No long tasks (>50ms) attributed to the extension commit handler. + +--- + +## TC-08: Settings migration — legacy disabled sites preserved + +**Steps:** +1. Before loading the extension, manually set chrome.storage.local: + ```js + chrome.storage.local.set({ react_debugger_disabled_sites: ['example.com', 'foo.com'] }) + ``` +2. Load the extension. +3. Go to Settings → per-site overrides. + +**Expected:** +- `example.com` and `foo.com` appear in the per-site disabled list. +- No `react_debugger_disabled_sites` key remains in storage after migration + (verify: `chrome.storage.local.get(null, console.log)` in background console). +- New key `react_debugger_settings_v1` is present. + +--- + +## TC-09: Settings migration is idempotent + +**Steps:** +1. Reload the extension (disable + re-enable in chrome://extensions). +2. Check storage again. + +**Expected:** Migration does not duplicate entries. Storage state identical to post-TC-08. + +--- + +## TC-10: Per-site override — disable on current site + +**Steps:** +1. Open React Debugger panel on `localhost:3000`. +2. In Settings, add `localhost` to per-site disabled list (or click the "Disable on this site" button if present). +3. Reload the page. + +**Expected:** +- Panel shows debugger is disabled for this site. +- No issues emitted. +- Other sites remain unaffected. + +--- + +## TC-11: Closure-leak detector — no false positives on clean closures + +**Steps:** +1. Enable `closure-leak` in Settings. +2. Visit a React page with normal event handlers (onClick, useEffect cleanup, etc.). +3. Interact normally for 30 seconds. +4. Check Issues tab. + +**Expected:** No closure-leak issues for normal, properly-cleaned-up closures. + +--- + +## TC-12: drainAll periodic cleanup — no memory growth + +**Steps:** +1. Enable ALL detectors in Settings. +2. Open Chrome Task Manager (Shift+Esc). +3. Note the extension's memory footprint. +4. Interact with the React app for 5 minutes (navigation, clicks, etc.). +5. Check memory every 60 seconds. + +**Expected:** Extension memory stays stable (±5MB). Does not grow linearly over time. + +--- + +## Sign-off checklist + +- [ ] TC-01 passed +- [ ] TC-02 passed +- [ ] TC-03 passed (no errors, no memory growth) +- [ ] TC-04 passed (UNSTABLE_LIST_KEY emitted for Math.random) +- [ ] TC-05 passed (UNSTABLE_LIST_KEY emitted for Date.now) +- [ ] TC-06 passed (UNSTABLE_LIST_KEY emitted for index reorder) +- [ ] TC-07 passed (getBoundingClientRect off commit path) +- [ ] TC-08 passed (migration from legacy format) +- [ ] TC-09 passed (migration idempotent) +- [ ] TC-10 passed (per-site disable works) +- [ ] TC-11 passed (no false positives) +- [ ] TC-12 passed (no memory growth) diff --git a/package-lock.json b/package-lock.json index 5534842..de098d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "dependencies": { "opencode-antigravity-auth": "^1.4.3", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "zod": "^4.4.3" }, "devDependencies": { "@resvg/resvg-js": "^2.6.2", @@ -28,6 +29,10 @@ "vite": "^5.0.12", "vitest": "^4.0.18", "wrangler": "^4.67.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/hoainho" } }, "node_modules/@acemir/cssom": { @@ -5435,9 +5440,9 @@ } }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 4797b4c..211ca8a 100644 --- a/package.json +++ b/package.json @@ -30,19 +30,19 @@ "url": "https://github.com/sponsors/hoainho" }, "scripts": { - "dev": "vite build --watch --mode development", - "build": "vite build --mode production", - "build:dev": "vite build --mode development", - "clean": "rm -rf dist", - "package": "npm run build && cd dist && zip -r ../react-debugger.zip .", - "test": "vitest", - "test:run": "vitest run", - "test:coverage": "vitest run --coverage", - "fixture:basic:dev": "npm --prefix test/fixtures/basic run dev", - "typecheck": "tsc --noEmit", - "typecheck:node": "tsc -p tsconfig.node.json --noEmit", - "bench": "vitest bench --run" -}, + "dev": "vite build --watch --mode development", + "build": "vite build --mode production", + "build:dev": "vite build --mode development", + "clean": "rm -rf dist", + "package": "npm run build && cd dist && zip -r ../react-debugger.zip .", + "test": "vitest", + "test:run": "vitest run", + "test:coverage": "vitest run --coverage", + "fixture:basic:dev": "npm --prefix test/fixtures/basic run dev", + "typecheck": "tsc --noEmit", + "typecheck:node": "tsc -p tsconfig.node.json --noEmit", + "bench": "vitest bench --run" + }, "devDependencies": { "@resvg/resvg-js": "^2.6.2", "@testing-library/jest-dom": "^6.9.1", @@ -62,6 +62,7 @@ "dependencies": { "opencode-antigravity-auth": "^1.4.3", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "zod": "^4.4.3" } } diff --git a/src/__tests__/SettingsTab.test.tsx b/src/__tests__/SettingsTab.test.tsx new file mode 100644 index 0000000..580bf7c --- /dev/null +++ b/src/__tests__/SettingsTab.test.tsx @@ -0,0 +1,224 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { KNOWN_DETECTORS_DEFAULTS } from '../settings/migrate'; +import type { Settings } from '../settings/types'; + +const { readMock, writeMock, subscribeMock, getSubscribeCallback } = vi.hoisted(() => { + let _cb: ((s: unknown) => void) | null = null; + + const readMock = vi.fn(); + const writeMock = vi.fn(); + const subscribeMock = vi.fn((cb: (s: unknown) => void) => { + _cb = cb; + return () => { _cb = null; }; + }); + + return { + readMock, + writeMock, + subscribeMock, + getSubscribeCallback: () => _cb as ((s: Settings) => void) | null, + }; +}); + +vi.mock('../settings/storage', () => ({ + read: readMock, + write: writeMock, + subscribe: subscribeMock, +})); + +import { SettingsTab } from '../panel/tabs/SettingsTab'; + +const makeFullSettings = (): Settings => ({ + version: 1, + detectors: Object.fromEntries( + KNOWN_DETECTORS_DEFAULTS.map(({ id, confidence }) => [ + id, + { enabled: confidence === 'high' }, + ]) + ), + perSite: {}, +}); + +describe('SettingsTab', () => { + beforeEach(() => { + readMock.mockClear(); + writeMock.mockClear(); + subscribeMock.mockClear(); + readMock.mockResolvedValue(makeFullSettings()); + writeMock.mockResolvedValue(undefined); + }); + + it('C1: renders all known detectors from KNOWN_DETECTORS_DEFAULTS', async () => { + render(); + + for (const { id } of KNOWN_DETECTORS_DEFAULTS) { + await waitFor(() => { + expect(screen.getByText(id)).toBeInTheDocument(); + }); + } + }); + + it('C2: confidence badge classes match detector confidence levels', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText(KNOWN_DETECTORS_DEFAULTS[0].id)).toBeInTheDocument(); + }); + + expect(document.querySelectorAll('.confidence-badge--high').length).toBeGreaterThan(0); + expect(document.querySelectorAll('.confidence-badge--medium').length).toBeGreaterThan(0); + + for (const { id, confidence } of KNOWN_DETECTORS_DEFAULTS) { + const idEl = screen.getByText(id); + const row = idEl.closest('.detector-row'); + expect(row?.querySelector(`.confidence-badge--${confidence}`)).toBeTruthy(); + } + }); + + it('C3: toggling a detector calls write() with the flipped enabled value', async () => { + const settings = makeFullSettings(); + readMock.mockResolvedValue(settings); + + render(); + + const targetDetector = KNOWN_DETECTORS_DEFAULTS[0]; + const initialEnabled = settings.detectors[targetDetector.id].enabled; + + await waitFor(() => { + expect(screen.getByText(targetDetector.id)).toBeInTheDocument(); + }); + + const toggle = document.getElementById( + `detector-toggle-${targetDetector.id}` + ) as HTMLInputElement; + expect(toggle).toBeTruthy(); + + fireEvent.click(toggle); + + await waitFor(() => { + expect(writeMock).toHaveBeenCalled(); + }); + + const calls = writeMock.mock.calls as [Settings][]; + const lastArg = calls[calls.length - 1][0]; + expect(lastArg.detectors[targetDetector.id].enabled).toBe(!initialEnabled); + }); + + it('C4: adding a site origin creates perSite entry with all detectors disabled', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText(KNOWN_DETECTORS_DEFAULTS[0].id)).toBeInTheDocument(); + }); + + const input = screen.getByPlaceholderText('https://app.example.com'); + fireEvent.change(input, { target: { value: 'https://myapp.io' } }); + + const addBtn = screen.getByRole('button', { name: 'Add' }); + fireEvent.click(addBtn); + + await waitFor(() => { + expect(writeMock).toHaveBeenCalled(); + }); + + const calls = writeMock.mock.calls as [Settings][]; + const lastArg = calls[calls.length - 1][0]; + expect(lastArg.perSite['https://myapp.io']).toBeDefined(); + + for (const { id } of KNOWN_DETECTORS_DEFAULTS) { + expect(lastArg.perSite['https://myapp.io'].detectors?.[id]?.enabled).toBe(false); + } + }); + + it('C5: closure-leak help icon shows exact tooltip text on hover/focus', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('closure-leak')).toBeInTheDocument(); + }); + + const helpIcon = screen.getByRole('img', { name: 'Info' }); + expect(helpIcon).toBeTruthy(); + + fireEvent.mouseEnter(helpIcon); + + await waitFor(() => { + expect( + screen.getByText( + 'Tracking starts when enabled. Timers created before enable are invisible to the leak detector. Reload the page to capture all timers.' + ) + ).toBeInTheDocument(); + }); + + fireEvent.mouseLeave(helpIcon); + + await waitFor(() => { + expect( + screen.queryByText( + 'Tracking starts when enabled. Timers created before enable are invisible to the leak detector. Reload the page to capture all timers.' + ) + ).not.toBeInTheDocument(); + }); + }); + + it('subscribes to external changes and re-renders with updated settings', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText(KNOWN_DETECTORS_DEFAULTS[0].id)).toBeInTheDocument(); + }); + + const updatedSettings: Settings = { + ...makeFullSettings(), + detectors: { + ...makeFullSettings().detectors, + [KNOWN_DETECTORS_DEFAULTS[0].id]: { enabled: false }, + }, + }; + + const cb = getSubscribeCallback(); + expect(cb).toBeTruthy(); + cb!(updatedSettings); + + await waitFor(() => { + const toggle = document.getElementById( + `detector-toggle-${KNOWN_DETECTORS_DEFAULTS[0].id}` + ) as HTMLInputElement; + expect(toggle.checked).toBe(false); + }); + }); + + it('remove site override button calls write() without that origin', async () => { + const settingsWithSite: Settings = { + ...makeFullSettings(), + perSite: { + 'https://to-remove.com': { + detectors: Object.fromEntries( + KNOWN_DETECTORS_DEFAULTS.map(({ id }) => [id, { enabled: false }]) + ), + }, + }, + }; + readMock.mockResolvedValue(settingsWithSite); + + render(); + + await waitFor(() => { + expect(screen.getByText('https://to-remove.com')).toBeInTheDocument(); + }); + + const removeBtn = screen.getByRole('button', { + name: 'Remove site override for https://to-remove.com', + }); + fireEvent.click(removeBtn); + + await waitFor(() => { + expect(writeMock).toHaveBeenCalled(); + }); + + const calls = writeMock.mock.calls as [Settings][]; + const lastArg = calls[calls.length - 1][0]; + expect(lastArg.perSite['https://to-remove.com']).toBeUndefined(); + }); +}); diff --git a/src/__tests__/closure-leak.test.ts b/src/__tests__/closure-leak.test.ts new file mode 100644 index 0000000..89f42ad --- /dev/null +++ b/src/__tests__/closure-leak.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { createClosureLeakDetector } from '../inject/detectors/closure-leak'; +import { createRegistry } from '../inject/registry'; +import type { Issue } from '../types'; + +interface BridgeMock { + install: () => void; + restoreOriginals: () => void; + clear: () => void; + setSink: (fn: ((issue: Issue) => void) | null) => void; + installCalls: number; + restoreCalls: number; + clearCalls: number; + sink: ((issue: Issue) => void) | null; +} + +function installMockBridge(): BridgeMock { + const bridge: BridgeMock = { + installCalls: 0, + restoreCalls: 0, + clearCalls: 0, + sink: null, + install: () => { + bridge.installCalls++; + }, + restoreOriginals: () => { + bridge.restoreCalls++; + }, + clear: () => { + bridge.clearCalls++; + }, + setSink: (fn) => { + bridge.sink = fn; + }, + }; + (window as unknown as { __REACT_DEBUGGER_CLOSURE_BRIDGE__: BridgeMock }).__REACT_DEBUGGER_CLOSURE_BRIDGE__ = bridge; + return bridge; +} + +function uninstallMockBridge(): void { + delete (window as unknown as { __REACT_DEBUGGER_CLOSURE_BRIDGE__?: unknown }).__REACT_DEBUGGER_CLOSURE_BRIDGE__; +} + +function makeRegistryHarness() { + const detector = createClosureLeakDetector(); + const registry = createRegistry({ + emit: () => {}, + log: () => {}, + sanitize: (v) => v, + performance: { now: () => 0 }, + }); + registry.register(detector); + return { + detector, + registry, + drain: (): Issue[] => { + const all = registry.drainAll(); + const us = all.find((e) => e.detectorId === 'closure-leak'); + return (us?.issues as Issue[]) ?? []; + }, + }; +} + +function makeFakeIssue(id: string): Issue { + return { + id, + type: 'STALE_CLOSURE', + severity: 'warning', + component: 'Comp', + message: 'fake stale closure', + suggestion: 'fake suggestion', + timestamp: 0, + }; +} + +describe('closure-leak detector', () => { + let bridge: BridgeMock; + beforeEach(() => { + bridge = installMockBridge(); + }); + afterEach(() => { + uninstallMockBridge(); + }); + + it('declares the expected detector metadata (id, category, budgetMs, confidence, prodCapable)', () => { + const d = createClosureLeakDetector(); + expect(d.id).toBe('closure-leak'); + expect(d.category).toBe('side-effects'); + expect(d.budgetMs).toBe(0.2); + expect(d.confidence).toBe('medium'); + expect(d.prodCapable).toBe(true); + }); + + it('init() invokes the bridge install hook and attaches a sink', () => { + const harness = makeRegistryHarness(); + expect(bridge.installCalls).toBe(1); + expect(typeof bridge.sink).toBe('function'); + harness.detector.teardown(); + }); + + it('teardown() restores originals via the bridge and detaches the sink', () => { + const harness = makeRegistryHarness(); + expect(bridge.installCalls).toBe(1); + expect(bridge.sink).not.toBeNull(); + harness.detector.teardown(); + expect(bridge.restoreCalls).toBe(1); + expect(bridge.clearCalls).toBe(1); + expect(bridge.sink).toBeNull(); + }); + + it('drain() returns buffered issues fed via the sink and then clears the buffer', () => { + const harness = makeRegistryHarness(); + const sink = bridge.sink; + expect(sink).not.toBeNull(); + sink!(makeFakeIssue('a')); + sink!(makeFakeIssue('b')); + const drained = harness.drain(); + expect(drained.map((i) => i.id)).toEqual(['a', 'b']); + expect(harness.drain()).toEqual([]); + harness.detector.teardown(); + }); + + it('teardown() is idempotent — calling it twice does not throw', () => { + const harness = makeRegistryHarness(); + expect(() => { + harness.detector.teardown(); + harness.detector.teardown(); + }).not.toThrow(); + expect(bridge.restoreCalls).toBe(1); + }); + + it('sink emissions after teardown are dropped (buffer not appended)', () => { + const harness = makeRegistryHarness(); + const sink = bridge.sink; + harness.detector.teardown(); + sink!(makeFakeIssue('late')); + expect(harness.drain()).toEqual([]); + }); + + it('init() is a safe no-op when the bridge is absent', () => { + uninstallMockBridge(); + const harness = makeRegistryHarness(); + expect(harness.drain()).toEqual([]); + expect(() => harness.detector.teardown()).not.toThrow(); + }); +}); diff --git a/src/__tests__/react-adapter.test.ts b/src/__tests__/react-adapter.test.ts new file mode 100644 index 0000000..972186e --- /dev/null +++ b/src/__tests__/react-adapter.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { + getAdapterForVersion, + detectReactVersion, + detectProfilingConflict, +} from '../inject/react-adapters'; + +type AnyWindow = Window & typeof globalThis & Record; +function win(): AnyWindow { + return window as AnyWindow; +} + +describe('getAdapterForVersion', () => { + it('r17: OffscreenComponent === 23 for 17.0.2', () => { + const adapter = getAdapterForVersion('17.0.2'); + expect(adapter).toBeDefined(); + expect(adapter.FIBER_TAGS.OffscreenComponent).toBe(23); + }); + + it('r18: OffscreenComponent === 22 and CacheComponent === 24 for 18.3.1', () => { + const adapter = getAdapterForVersion('18.3.1'); + expect(adapter).toBeDefined(); + expect(adapter.FIBER_TAGS.OffscreenComponent).toBe(22); + expect(adapter.FIBER_TAGS.CacheComponent).toBe(24); + }); + + it('r19: HostHoistable === 26 for 19.0.0', () => { + const adapter = getAdapterForVersion('19.0.0'); + expect(adapter).toBeDefined(); + expect(adapter.FIBER_TAGS.HostHoistable).toBe(26); + }); + + it('r19.2: ViewTransitionComponent === 30 and supportsPerformanceTracks === true for 19.2.0', () => { + const adapter = getAdapterForVersion('19.2.0'); + expect(adapter).toBeDefined(); + expect(adapter.FIBER_TAGS.ViewTransitionComponent).toBe(30); + expect(adapter.supportsPerformanceTracks).toBe(true); + }); + + it('unknown version (99.0.0): does not throw and returns a real adapter', () => { + let adapter: ReturnType | undefined; + expect(() => { adapter = getAdapterForVersion('99.0.0'); }).not.toThrow(); + expect(adapter).toBeDefined(); + expect(adapter).not.toBeNull(); + expect(adapter!.FIBER_TAGS.ViewTransitionComponent).toBe(30); + }); +}); + +describe('detectReactVersion', () => { + afterEach(() => { + delete (win() as any).__REACT_DEVTOOLS_GLOBAL_HOOK__; + delete (win() as any).React; + }); + + it('returns version from hook.renderers Map (priority 1)', () => { + const renderers = new Map(); + renderers.set(1, { version: '18.3.1' }); + (win() as any).__REACT_DEVTOOLS_GLOBAL_HOOK__ = { renderers }; + expect(detectReactVersion()).toBe('18.3.1'); + }); + + it('falls back to window.React.version when no hook is present', () => { + delete (win() as any).__REACT_DEVTOOLS_GLOBAL_HOOK__; + (win() as any).React = { version: '17.0.2' }; + expect(detectReactVersion()).toBe('17.0.2'); + }); + + it('returns null when neither hook nor window.React is present', () => { + delete (win() as any).__REACT_DEVTOOLS_GLOBAL_HOOK__; + delete (win() as any).React; + expect(detectReactVersion()).toBeNull(); + }); +}); + +describe('detectProfilingConflict', () => { + afterEach(() => { + delete (win() as any).__REACT_SCAN__; + delete (win() as any).__REACT_DEVTOOLS_GLOBAL_HOOK__; + }); + + it('returns reactScan: true when __REACT_SCAN__.ReactScanInternals is present', () => { + (win() as any).__REACT_SCAN__ = { ReactScanInternals: {} }; + const conflict = detectProfilingConflict(); + expect(conflict.reactScan).toBe(true); + }); + + it('returns reactDevTools: true when hook has getFiberRoots method', () => { + (win() as any).__REACT_DEVTOOLS_GLOBAL_HOOK__ = { + renderers: new Map(), + getFiberRoots: () => new Set(), + }; + const conflict = detectProfilingConflict(); + expect(conflict.reactDevTools).toBe(true); + }); + + it('returns all false when no profiling tool stubs are present', () => { + delete (win() as any).__REACT_SCAN__; + delete (win() as any).__REACT_DEVTOOLS_GLOBAL_HOOK__; + const conflict = detectProfilingConflict(); + expect(conflict.reactScan).toBe(false); + expect(conflict.reactDevTools).toBe(false); + }); +}); diff --git a/src/__tests__/reconciler-keys.test.ts b/src/__tests__/reconciler-keys.test.ts new file mode 100644 index 0000000..fe3bab5 --- /dev/null +++ b/src/__tests__/reconciler-keys.test.ts @@ -0,0 +1,221 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { createReconcilerKeysDetector } from '../inject/detectors/reconciler-keys'; +import { createRegistry } from '../inject/registry'; +import type { FiberNode, FiberRoot } from '../inject/react-adapters/types'; +import type { Issue } from '../types'; +import type { Detector } from '../types/registry'; + +const HOST_COMPONENT_TAG = 5; +const FUNCTION_COMPONENT_TAG = 0; + +function makeFiber(partial: Partial): FiberNode { + return { + tag: HOST_COMPONENT_TAG, + key: null, + type: 'div', + elementType: 'div', + stateNode: null, + return: null, + child: null, + sibling: null, + alternate: null, + memoizedState: null, + memoizedProps: null, + pendingProps: null, + flags: 0, + updateQueue: null, + ...partial, + } as FiberNode; +} + +function makeListFiber( + parentType: string, + childKeys: Array, + childTag = HOST_COMPONENT_TAG, + childType = 'li', +): FiberNode { + const parent = makeFiber({ tag: HOST_COMPONENT_TAG, type: parentType, elementType: parentType }); + let prev: FiberNode | null = null; + let first: FiberNode | null = null; + for (const key of childKeys) { + const child = makeFiber({ + tag: childTag, + type: childType, + elementType: childType, + key, + return: parent, + }); + if (first === null) { + first = child; + parent.child = child; + } else if (prev !== null) { + prev.sibling = child; + } + prev = child; + } + return parent; +} + +function makeRoot(parent: FiberNode): FiberRoot { + // The detector calls `walkFiberImpl` from `root.current`, which visits the + // root itself and then traverses .child/.sibling. We anchor at a host-root + // whose only child is our list parent. + const root = makeFiber({ tag: HOST_COMPONENT_TAG, type: 'root', elementType: 'root' }); + root.child = parent; + parent.return = root; + return { current: root }; +} + +function setupHarness(): { + detector: Detector; + dispatch: (root: FiberRoot) => void; + drain: () => Issue[]; +} { + const detector = createReconcilerKeysDetector(); + const registry = createRegistry({ + emit: () => {}, + log: () => {}, + sanitize: (v) => v, + performance: { now: () => 0 }, + }); + registry.register(detector); + return { + detector, + dispatch: (root) => registry.dispatch({ fiberRoot: root }), + drain: () => { + const all = registry.drainAll(); + const us = all.find((e) => e.detectorId === 'reconciler-keys'); + return (us?.issues as Issue[]) ?? []; + }, + }; +} + +describe('reconciler-keys detector', () => { + let harness: ReturnType; + beforeEach(() => { + harness = setupHarness(); + }); + + it('case 1: stable index keys with no reorder emits nothing', () => { + const parent = makeListFiber('ul', ['0', '1', '2']); + const root = makeRoot(parent); + + harness.dispatch(root); + harness.dispatch(root); + const issues = harness.drain(); + + expect(issues).toHaveLength(0); + }); + + it('case 2: index keys + reorder emits UNSTABLE_LIST_KEY', () => { + const root1 = makeRoot(makeListFiber('ul', ['0', '1', '2'])); + harness.dispatch(root1); + + const root2 = makeRoot(makeListFiber('ul', ['2', '0', '1'])); + harness.dispatch(root2); + + const issues = harness.drain(); + expect(issues).toHaveLength(1); + expect(issues[0].type).toBe('UNSTABLE_LIST_KEY'); + expect(issues[0].severity).toBe('warning'); + expect(issues[0].message).toMatch(/Index keys with detected reorder/); + expect(issues[0].component).toBe('ul'); + }); + + it('case 3: Math.random()-style keys emit on first commit', () => { + const parent = makeListFiber('ul', ['0.123456789', '0.987654321']); + harness.dispatch(makeRoot(parent)); + + const issues = harness.drain(); + expect(issues).toHaveLength(1); + expect(issues[0].type).toBe('UNSTABLE_LIST_KEY'); + expect(issues[0].message).toMatch(/unstable keys/); + }); + + it('case 4: Date.now()-style keys emit on first commit', () => { + const parent = makeListFiber('ul', ['1717180000000', '1717180000001']); + harness.dispatch(makeRoot(parent)); + + const issues = harness.drain(); + expect(issues).toHaveLength(1); + expect(issues[0].type).toBe('UNSTABLE_LIST_KEY'); + expect(issues[0].message).toMatch(/unstable keys/); + }); + + it('case 5: stable string id keys emit nothing', () => { + const parent = makeListFiber('ul', ['user-abc', 'user-def', 'user-ghi']); + harness.dispatch(makeRoot(parent)); + harness.dispatch(makeRoot(makeListFiber('ul', ['user-ghi', 'user-abc', 'user-def']))); + const issues = harness.drain(); + expect(issues).toHaveLength(0); + }); + + it('case 6: dedupe — repeated reorder on same parent emits only once', () => { + harness.dispatch(makeRoot(makeListFiber('ul', ['0', '1', '2']))); + harness.dispatch(makeRoot(makeListFiber('ul', ['2', '0', '1']))); + harness.dispatch(makeRoot(makeListFiber('ul', ['1', '2', '0']))); + harness.dispatch(makeRoot(makeListFiber('ul', ['0', '2', '1']))); + + const issues = harness.drain(); + expect(issues).toHaveLength(1); + expect(issues[0].message).toMatch(/reorder/); + }); + + it('detector metadata: confidence=high, category=ui-state, prodCapable=true, budgetMs=0.3', () => { + expect(harness.detector.id).toBe('reconciler-keys'); + expect(harness.detector.category).toBe('ui-state'); + expect(harness.detector.confidence).toBe('high'); + expect(harness.detector.prodCapable).toBe(true); + expect(harness.detector.budgetMs).toBe(0.3); + }); + + it('drained issues are cleared (drain is single-shot)', () => { + harness.dispatch(makeRoot(makeListFiber('ul', ['0.111111', '0.222222']))); + const first = harness.drain(); + expect(first).toHaveLength(1); + const second = harness.drain(); + expect(second).toHaveLength(0); + }); + + it('emitted Issue carries the expected location shape', () => { + const parent = makeListFiber('ol', ['0.111111', '0.222222']); + harness.dispatch(makeRoot(parent)); + const [issue] = harness.drain(); + expect(issue.location).toBeDefined(); + expect(issue.location?.componentName).toBe('ol'); + expect(Array.isArray(issue.location?.componentPath)).toBe(true); + expect(issue.suggestion).toMatch(/stable identifier/); + }); + + it('mixed unkeyed + keyed children are punted (no emission)', () => { + const parent = makeFiber({ type: 'ul', elementType: 'ul' }); + const a = makeFiber({ key: '0', type: 'li', elementType: 'li', return: parent }); + const b = makeFiber({ key: null, type: 'li', elementType: 'li', return: parent }); + const c = makeFiber({ key: '2', type: 'li', elementType: 'li', return: parent }); + parent.child = a; + a.sibling = b; + b.sibling = c; + + harness.dispatch(makeRoot(parent)); + const issues = harness.drain(); + expect(issues).toHaveLength(0); + }); + + it('function-component list parent uses displayName for dedupe + component field', () => { + function MyList(): null { return null; } + const parent = makeFiber({ + tag: FUNCTION_COMPONENT_TAG, + type: MyList, + elementType: MyList, + }); + const child1 = makeFiber({ tag: HOST_COMPONENT_TAG, type: 'li', elementType: 'li', key: '0.111111', return: parent }); + const child2 = makeFiber({ tag: HOST_COMPONENT_TAG, type: 'li', elementType: 'li', key: '0.222222', return: parent }); + parent.child = child1; + child1.sibling = child2; + + harness.dispatch(makeRoot(parent)); + const issues = harness.drain(); + expect(issues).toHaveLength(1); + expect(issues[0].component).toBe('MyList'); + }); +}); diff --git a/src/__tests__/registry.test.ts b/src/__tests__/registry.test.ts new file mode 100644 index 0000000..c2406a2 --- /dev/null +++ b/src/__tests__/registry.test.ts @@ -0,0 +1,433 @@ +import { describe, it, expect, vi } from 'vitest'; +import { createRegistry } from '../inject/registry'; +import type { Detector, DetectorContext } from '../types/registry'; + +function makeOptions(overrides: Partial[0]> = {}) { + return { + emit: vi.fn(), + log: vi.fn(), + sanitize: (v: unknown) => v, + performance: { now: () => 0 }, + ...overrides, + }; +} + +function makeDetector(partial: Partial & { id: string }): Detector { + return { + category: 'performance', + budgetMs: 1, + confidence: 'high', + prodCapable: true, + init: vi.fn(), + drain: vi.fn(() => []), + teardown: vi.fn(), + ...partial, + } as Detector; +} + +describe('createRegistry', () => { + describe('register / init', () => { + it('calls detector.init synchronously with a DetectorContext', () => { + const registry = createRegistry(makeOptions()); + const init = vi.fn(); + const detector = makeDetector({ id: 'd1', init }); + + registry.register(detector); + + expect(init).toHaveBeenCalledTimes(1); + const ctx = init.mock.calls[0][0] as DetectorContext; + expect(typeof ctx.emit).toBe('function'); + expect(typeof ctx.log).toBe('function'); + expect(typeof ctx.sanitize).toBe('function'); + expect(typeof ctx.dedupe).toBe('function'); + expect(typeof ctx.write).toBe('function'); + expect(typeof ctx.read).toBe('function'); + expect(typeof ctx.performance.now).toBe('function'); + }); + + it('throws when registering a duplicate id', () => { + const registry = createRegistry(makeOptions()); + registry.register(makeDetector({ id: 'dup' })); + expect(() => registry.register(makeDetector({ id: 'dup' }))).toThrow( + /already registered/, + ); + }); + + it('allows re-registering after unregister', () => { + const registry = createRegistry(makeOptions()); + const init1 = vi.fn(); + const init2 = vi.fn(); + registry.register(makeDetector({ id: 're', init: init1 })); + registry.unregister('re'); + registry.register(makeDetector({ id: 're', init: init2 })); + expect(init2).toHaveBeenCalledTimes(1); + }); + }); + + describe('unregister / teardown', () => { + it('calls detector.teardown on unregister', () => { + const registry = createRegistry(makeOptions()); + const teardown = vi.fn(); + registry.register(makeDetector({ id: 't1', teardown })); + registry.unregister('t1'); + expect(teardown).toHaveBeenCalledTimes(1); + }); + + it('silently no-ops when unregistering an unknown id', () => { + const registry = createRegistry(makeOptions()); + expect(() => registry.unregister('nope')).not.toThrow(); + }); + }); + + describe('dispatch / throw isolation', () => { + it('isolates a throwing detector from its peers in the same commit', () => { + const registry = createRegistry(makeOptions()); + + const drainB = vi.fn(() => [{ msg: 'B emitted' }]); + let bRan = false; + + const a = makeDetector({ + id: 'a', + onCommit: () => { + throw new Error('boom from a'); + }, + }); + const b = makeDetector({ + id: 'b', + onCommit: () => { + bRan = true; + }, + drain: drainB, + }); + + registry.register(a); + registry.register(b); + registry.dispatch({ fiberRoot: null }); + + expect(bRan).toBe(true); + const drained = registry.drainAll(); + const bEntry = drained.find((e) => e.detectorId === 'b'); + expect(bEntry?.issues).toEqual([{ msg: 'B emitted' }]); + + const listed = registry.list(); + const aEntry = listed.find((e) => e.id === 'a'); + expect(aEntry?.enabled).toBe(false); + expect(aEntry?.disabledReason).toContain('boom from a'); + }); + + it('emits DETECTOR_DISABLED when a detector throws', () => { + const emit = vi.fn(); + const registry = createRegistry(makeOptions({ emit })); + registry.register( + makeDetector({ + id: 'thrower', + onCommit: () => { + throw new Error('nope'); + }, + }), + ); + registry.dispatch({ fiberRoot: null }); + + const event = emit.mock.calls + .map((c) => c[0]) + .find( + (p): p is { type: string; detectorId: string; phase: string; error: string } => + typeof p === 'object' && + p !== null && + (p as { type?: unknown }).type === 'DETECTOR_DISABLED', + ); + expect(event).toBeDefined(); + expect(event?.detectorId).toBe('thrower'); + expect(event?.phase).toBe('onCommit'); + expect(event?.error).toBe('nope'); + }); + }); + + describe('recover', () => { + it('invokes recover before disabling on throw', () => { + const registry = createRegistry(makeOptions()); + const calls: string[] = []; + const recover = vi.fn(() => calls.push('recover')); + registry.register( + makeDetector({ + id: 'rec', + onCommit: () => { + calls.push('onCommit'); + throw new Error('die'); + }, + recover, + }), + ); + + registry.dispatch({ fiberRoot: null }); + + expect(recover).toHaveBeenCalledTimes(1); + expect(calls).toEqual(['onCommit', 'recover']); + const listed = registry.list(); + expect(listed[0].enabled).toBe(false); + }); + }); + + describe('staged write transactionality', () => { + it('discards writes on throw and commits writes on success', () => { + const registry = createRegistry(makeOptions()); + + let phase: 'throw' | 'ok' = 'throw'; + let readBack: unknown; + let ctxRef: DetectorContext | null = null; + + const detector = makeDetector({ + id: 'tx', + init: (ctx) => { + ctxRef = ctx; + }, + onCommit: () => { + if (phase === 'throw') { + ctxRef!.write('k', 'v1'); + throw new Error('rollback'); + } + readBack = ctxRef!.read('k'); + ctxRef!.write('k', 'v2'); + }, + }); + + registry.register(detector); + + registry.dispatch({ fiberRoot: null }); + expect(registry.list()[0].enabled).toBe(false); + + registry.enable('tx'); + phase = 'ok'; + registry.dispatch({ fiberRoot: null }); + expect(readBack).toBeUndefined(); + + phase = 'ok'; + registry.dispatch({ fiberRoot: null }); + expect(readBack).toBe('v2'); + }); + + it('does not expose intra-call writes to intra-call reads', () => { + const registry = createRegistry(makeOptions()); + let observed: unknown = 'sentinel'; + let ctxRef: DetectorContext | null = null; + + registry.register( + makeDetector({ + id: 'sameCall', + init: (ctx) => { + ctxRef = ctx; + }, + onCommit: () => { + ctxRef!.write('k', 'fresh'); + observed = ctxRef!.read('k'); + }, + }), + ); + + registry.dispatch({ fiberRoot: null }); + expect(observed).toBeUndefined(); + }); + }); + + describe('dedupe LRU', () => { + it('returns true on first sighting and false thereafter', () => { + const registry = createRegistry(makeOptions()); + let ctxRef: DetectorContext | null = null; + registry.register( + makeDetector({ + id: 'dd', + init: (ctx) => { + ctxRef = ctx; + }, + }), + ); + expect(ctxRef!.dedupe('a')).toBe(true); + expect(ctxRef!.dedupe('a')).toBe(false); + expect(ctxRef!.dedupe('b')).toBe(true); + }); + + it('evicts the least-recently-used key when capacity is exceeded', () => { + const registry = createRegistry(makeOptions({ dedupeCapDefault: 3 })); + let ctxRef: DetectorContext | null = null; + registry.register( + makeDetector({ + id: 'cap', + init: (ctx) => { + ctxRef = ctx; + }, + }), + ); + + expect(ctxRef!.dedupe('k1')).toBe(true); + expect(ctxRef!.dedupe('k2')).toBe(true); + expect(ctxRef!.dedupe('k3')).toBe(true); + + // k4 is unseen → returns true; capacity exceeded → evicts k1 (LRU). + expect(ctxRef!.dedupe('k4')).toBe(true); + + // k1 was evicted → first-sighting again, returns true; this in turn + // evicts the next-oldest, k2. + expect(ctxRef!.dedupe('k1')).toBe(true); + // k2 was evicted in the previous step → first-sighting again. + expect(ctxRef!.dedupe('k2')).toBe(true); + // k3 and k4 are still hot from the prior loop, plus k1 just refreshed. + expect(ctxRef!.dedupe('k4')).toBe(false); + expect(ctxRef!.dedupe('k1')).toBe(false); + }); + }); + + describe('list / enable / disable', () => { + it('lists detectors with metadata', () => { + const registry = createRegistry(makeOptions()); + registry.register( + makeDetector({ + id: 'one', + category: 'redux', + confidence: 'medium', + }), + ); + const out = registry.list(); + expect(out).toEqual([ + { id: 'one', category: 'redux', confidence: 'medium', enabled: true }, + ]); + }); + + it('disable then enable toggles the enabled flag', () => { + const registry = createRegistry(makeOptions()); + registry.register(makeDetector({ id: 'toggle' })); + registry.disable('toggle'); + expect(registry.list()[0].enabled).toBe(false); + registry.enable('toggle'); + expect(registry.list()[0].enabled).toBe(true); + expect(registry.list()[0].disabledReason).toBeUndefined(); + }); + }); + + describe('dispatch budget deadline', () => { + it('passes performance.now() + budgetMs as the deadline', () => { + const now = vi.fn(() => 100); + const onCommit = vi.fn(); + const registry = createRegistry( + makeOptions({ performance: { now } }), + ); + registry.register( + makeDetector({ + id: 'deadline', + budgetMs: 2.5, + onCommit, + }), + ); + registry.dispatch({ fiberRoot: { tag: 'root' } }); + expect(onCommit).toHaveBeenCalledWith({ tag: 'root' }, 102.5); + }); + }); + + describe('dispatchIdle', () => { + function makeDeadline(timeRemaining = 50, didTimeout = false): IdleDeadline { + return { + didTimeout, + timeRemaining: () => timeRemaining, + } as IdleDeadline; + } + + it('invokes onIdle for every active detector that defines it', () => { + const registry = createRegistry(makeOptions()); + const onIdleA = vi.fn(); + const onIdleB = vi.fn(); + + registry.register(makeDetector({ id: 'a', onIdle: onIdleA })); + registry.register(makeDetector({ id: 'b', onIdle: onIdleB })); + + const deadline = makeDeadline(42, false); + registry.dispatchIdle(deadline); + + expect(onIdleA).toHaveBeenCalledTimes(1); + expect(onIdleA).toHaveBeenCalledWith(deadline); + expect(onIdleB).toHaveBeenCalledTimes(1); + expect(onIdleB).toHaveBeenCalledWith(deadline); + }); + + it('skips detectors that do not define onIdle (does not crash)', () => { + const registry = createRegistry(makeOptions()); + const onIdleA = vi.fn(); + + registry.register(makeDetector({ id: 'withIdle', onIdle: onIdleA })); + registry.register(makeDetector({ id: 'noIdle' })); + + expect(() => registry.dispatchIdle(makeDeadline())).not.toThrow(); + expect(onIdleA).toHaveBeenCalledTimes(1); + }); + + it('skips disabled detectors', () => { + const registry = createRegistry(makeOptions()); + const onIdleA = vi.fn(); + registry.register(makeDetector({ id: 'd', onIdle: onIdleA })); + registry.disable('d'); + + registry.dispatchIdle(makeDeadline()); + + expect(onIdleA).not.toHaveBeenCalled(); + }); + + it('isolates a throwing onIdle: peer detectors still run, thrower is disabled', () => { + const registry = createRegistry(makeOptions()); + const onIdleB = vi.fn(); + + registry.register( + makeDetector({ + id: 'thrower', + onIdle: () => { + throw new Error('idle boom'); + }, + }), + ); + registry.register(makeDetector({ id: 'b', onIdle: onIdleB })); + + registry.dispatchIdle(makeDeadline()); + + expect(onIdleB).toHaveBeenCalledTimes(1); + const listed = registry.list(); + expect(listed.find((e) => e.id === 'thrower')?.enabled).toBe(false); + expect(listed.find((e) => e.id === 'thrower')?.disabledReason).toContain('idle boom'); + }); + }); + + describe('drainAll', () => { + it('returns {detectorId, issues[]} for all enabled detectors with non-empty drain results, and skips disabled detectors', () => { + const registry = createRegistry(makeOptions()); + + const enabledIssues = [{ type: 'UNSTABLE_LIST_KEY', component: 'List' }]; + const enabledDetector = makeDetector({ + id: 'enabled-with-issues', + drain: vi.fn(() => enabledIssues), + }); + const emptyDetector = makeDetector({ + id: 'enabled-no-issues', + drain: vi.fn(() => []), + }); + const disabledDetector = makeDetector({ + id: 'disabled-detector', + drain: vi.fn(() => [{ type: 'SHOULD_NOT_APPEAR' }]), + }); + + registry.register(enabledDetector); + registry.register(emptyDetector); + registry.register(disabledDetector); + registry.disable('disabled-detector'); + + const result = registry.drainAll(); + + const enabledEntry = result.find((e) => e.detectorId === 'enabled-with-issues'); + expect(enabledEntry).toBeDefined(); + expect(enabledEntry?.issues).toEqual(enabledIssues); + + const emptyEntry = result.find((e) => e.detectorId === 'enabled-no-issues'); + expect(emptyEntry).toBeDefined(); + expect(emptyEntry?.issues).toEqual([]); + + const disabledEntry = result.find((e) => e.detectorId === 'disabled-detector'); + expect(disabledEntry?.issues).toEqual([]); + expect((disabledDetector.drain as ReturnType)).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/__tests__/scan-overlay.test.ts b/src/__tests__/scan-overlay.test.ts new file mode 100644 index 0000000..5c78e59 --- /dev/null +++ b/src/__tests__/scan-overlay.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + createScanOverlayDetector, + type ScanOverlayPendingItem, +} from '../inject/detectors/scan-overlay'; +import { createRegistry } from '../inject/registry'; +import { KNOWN_DETECTORS_DEFAULTS } from '../settings/migrate'; + +type RecorderFn = (fiber: unknown, name: string, count: number) => void; + +interface BridgeMock { + enable: () => void; + disable: () => void; + isEnabled: () => boolean; + setRecorder: (fn: RecorderFn | null) => void; + paint: (items: ScanOverlayPendingItem[]) => void; + clear: () => void; + recorder: RecorderFn | null; + paintedBatches: ScanOverlayPendingItem[][]; + clearCalls: number; + enabled: boolean; +} + +function installMockBridge(): BridgeMock { + const bridge: BridgeMock = { + recorder: null, + paintedBatches: [], + clearCalls: 0, + enabled: false, + enable: () => { + bridge.enabled = true; + }, + disable: () => { + bridge.enabled = false; + }, + isEnabled: () => bridge.enabled, + setRecorder: (fn) => { + bridge.recorder = fn; + }, + paint: (items) => { + bridge.paintedBatches.push(items); + }, + clear: () => { + bridge.clearCalls++; + }, + }; + (window as unknown as { __REACT_DEBUGGER_SCAN_BRIDGE__: BridgeMock }).__REACT_DEBUGGER_SCAN_BRIDGE__ = bridge; + return bridge; +} + +function uninstallMockBridge(): void { + delete (window as unknown as { __REACT_DEBUGGER_SCAN_BRIDGE__?: unknown }).__REACT_DEBUGGER_SCAN_BRIDGE__; +} + +function makeIdleDeadline(timeRemaining = 50, didTimeout = false): IdleDeadline { + return { + didTimeout, + timeRemaining: () => timeRemaining, + } as IdleDeadline; +} + +function makeHarness() { + const detector = createScanOverlayDetector(); + const registry = createRegistry({ + emit: () => {}, + log: () => {}, + sanitize: (v) => v, + performance: { now: () => 0 }, + }); + registry.register(detector); + return { detector, registry }; +} + +describe('scan-overlay detector', () => { + let bridge: BridgeMock; + + beforeEach(() => { + bridge = installMockBridge(); + }); + + afterEach(() => { + uninstallMockBridge(); + }); + + it('declares the expected detector metadata (id, category, budgetMs, confidence, prodCapable)', () => { + const d = createScanOverlayDetector(); + expect(d.id).toBe('scan-overlay'); + expect(d.category).toBe('performance'); + expect(d.budgetMs).toBe(0.5); + expect(d.confidence).toBe('high'); + expect(d.prodCapable).toBe(true); + }); + + it('onCommit + recorder: buffers fibers WITHOUT touching the DOM (no getBoundingClientRect)', () => { + const harness = makeHarness(); + expect(typeof bridge.recorder).toBe('function'); + + const fakeFiber1 = { id: 'f1' }; + const fakeFiber2 = { id: 'f2' }; + + const elem = document.createElement('div'); + const getRectSpy = vi.spyOn(elem, 'getBoundingClientRect'); + + bridge.recorder!(fakeFiber1, 'Comp1', 3); + bridge.recorder!(fakeFiber2, 'Comp2', 7); + + harness.detector.onCommit?.(null, 0); + + expect(getRectSpy).not.toHaveBeenCalled(); + expect(bridge.paintedBatches).toEqual([]); + + harness.detector.teardown(); + }); + + it('onIdle drains the buffer and calls bridge.paint with the buffered items', () => { + const harness = makeHarness(); + const fakeFiber = { id: 'f1' }; + + bridge.recorder!(fakeFiber, 'CompA', 2); + bridge.recorder!({ id: 'f2' }, 'CompB', 5); + + expect(bridge.paintedBatches).toEqual([]); + + harness.detector.onIdle!(makeIdleDeadline(50, false)); + + expect(bridge.paintedBatches).toHaveLength(1); + expect(bridge.paintedBatches[0]).toEqual([ + { fiber: { id: 'f1' }, componentName: 'CompA', renderCount: 2 }, + { fiber: { id: 'f2' }, componentName: 'CompB', renderCount: 5 }, + ]); + + bridge.paintedBatches = []; + harness.detector.onIdle!(makeIdleDeadline(50, false)); + expect(bridge.paintedBatches).toEqual([]); + + harness.detector.teardown(); + }); + + it('teardown clears the bridge (overlay removal) and detaches the recorder', () => { + const harness = makeHarness(); + expect(bridge.recorder).not.toBeNull(); + expect(bridge.clearCalls).toBe(0); + + bridge.recorder!({ id: 'f' }, 'Comp', 1); + harness.detector.teardown(); + + expect(bridge.recorder).toBeNull(); + expect(bridge.clearCalls).toBe(1); + }); + + it('scan-overlay is configured as default-on in KNOWN_DETECTORS_DEFAULTS', () => { + const entry = KNOWN_DETECTORS_DEFAULTS.find((d) => d.id === 'scan-overlay'); + expect(entry).toBeDefined(); + expect(entry?.confidence).toBe('high'); + }); + + it('init is a safe no-op when the bridge is absent', () => { + uninstallMockBridge(); + const detector = createScanOverlayDetector(); + const registry = createRegistry({ + emit: () => {}, + log: () => {}, + sanitize: (v) => v, + performance: { now: () => 0 }, + }); + expect(() => registry.register(detector)).not.toThrow(); + expect(() => detector.onIdle!(makeIdleDeadline(50, false))).not.toThrow(); + expect(() => detector.teardown()).not.toThrow(); + }); + + it('drain always returns an empty array (scan emits no issues — visual only)', () => { + const harness = makeHarness(); + bridge.recorder!({ id: 'f' }, 'Comp', 1); + expect(harness.detector.drain()).toEqual([]); + harness.detector.teardown(); + }); + + it('onIdle with zero timeRemaining and not didTimeout drops the batch (yields to user)', () => { + const harness = makeHarness(); + bridge.recorder!({ id: 'f' }, 'Comp', 1); + + harness.detector.onIdle!(makeIdleDeadline(0, false)); + + expect(bridge.paintedBatches).toEqual([]); + harness.detector.teardown(); + }); +}); diff --git a/src/__tests__/settings-migrate.test.ts b/src/__tests__/settings-migrate.test.ts new file mode 100644 index 0000000..94fe712 --- /dev/null +++ b/src/__tests__/settings-migrate.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +import { DEFAULT_SETTINGS, SETTINGS_STORAGE_KEY } from '../settings/types'; +import { migrate, KNOWN_DETECTORS_DEFAULTS } from '../settings/migrate'; + +const LEGACY_KEY = 'react_debugger_disabled_sites'; + +const makeStorageMock = () => { + const store: Record = {}; + + const local = { + get: vi.fn((key: string, cb: (res: Record) => void) => { + cb({ [key]: store[key] }); + }), + set: vi.fn((items: Record, cb?: () => void) => { + Object.assign(store, items); + cb?.(); + }), + remove: vi.fn((key: string, cb?: () => void) => { + delete store[key]; + cb?.(); + }), + }; + + return { local, store }; +}; + +describe('settings-migrate', () => { + let storageMock: ReturnType; + + beforeEach(() => { + storageMock = makeStorageMock(); + vi.stubGlobal('chrome', { + storage: { local: storageMock.local }, + runtime: { lastError: null }, + }); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('TC1 – first-run migration: legacy sites → perSite + default policy applied + legacy key deleted', async () => { + storageMock.store[LEGACY_KEY] = ['app.example.com']; + + const result = await migrate(); + + expect(result.migrated).toBe(true); + expect(result.legacyKeysRemoved).toEqual([LEGACY_KEY]); + + const site = result.settings.perSite['app.example.com']; + expect(site).toBeDefined(); + expect(site?.detectors?.['reconciler-keys']).toEqual({ enabled: false }); + expect(site?.detectors?.['closure-leak']).toEqual({ enabled: false }); + expect(site?.detectors?.['scan-overlay']).toEqual({ enabled: false }); + + expect(result.settings.detectors['reconciler-keys']?.enabled).toBe(true); + expect(result.settings.detectors['closure-leak']?.enabled).toBe(false); + expect(result.settings.detectors['scan-overlay']?.enabled).toBe(true); + + expect(storageMock.local.remove).toHaveBeenCalledWith(LEGACY_KEY, expect.any(Function)); + expect(storageMock.store[LEGACY_KEY]).toBeUndefined(); + }); + + it('TC2 – idempotent: existing v1 in storage → returns migrated:false, no storage writes', async () => { + const existingSettings = { + version: 1, + detectors: { 'reconciler-keys': { enabled: true } }, + perSite: {}, + }; + storageMock.store[SETTINGS_STORAGE_KEY] = existingSettings; + + const result = await migrate(); + + expect(result.migrated).toBe(false); + expect(result.legacyKeysRemoved).toEqual([]); + expect(result.settings).toEqual(existingSettings); + expect(storageMock.local.set).not.toHaveBeenCalled(); + expect(storageMock.local.remove).not.toHaveBeenCalled(); + }); + + it('TC3 – fresh install: no legacy data, no v1 → writes DEFAULT with policy applied', async () => { + const result = await migrate(); + + expect(result.migrated).toBe(true); + expect(result.settings.version).toBe(1); + expect(Object.keys(result.settings.perSite)).toHaveLength(0); + + for (const { id, confidence } of KNOWN_DETECTORS_DEFAULTS) { + const expected = confidence === 'high'; + expect(result.settings.detectors[id]?.enabled).toBe(expected); + } + + expect(storageMock.local.set).toHaveBeenCalledOnce(); + }); + + it('TC4 – corrupt legacy data: legacy value is a string → logs warning, treats as fresh install', async () => { + storageMock.store[LEGACY_KEY] = 'not-an-array'; + + const result = await migrate(); + + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('SETTINGS_MIGRATION_BUILD_ERROR'), + expect.anything() + ); + + expect(result.migrated).toBe(true); + expect(Object.keys(result.settings.perSite)).toHaveLength(0); + }); + + it('TC5 – default policy correctness for fresh install', async () => { + const result = await migrate(); + + expect(result.settings.detectors['reconciler-keys']?.enabled).toBe(true); + expect(result.settings.detectors['closure-leak']?.enabled).toBe(false); + expect(result.settings.detectors['scan-overlay']?.enabled).toBe(true); + }); + + it('TC6 (bonus) – legacy key removed from storage after successful migration', async () => { + storageMock.store[LEGACY_KEY] = ['removed.example.com']; + + await migrate(); + + const getResult = await new Promise>((resolve) => { + chrome.storage.local.get(LEGACY_KEY, resolve); + }); + + expect(getResult[LEGACY_KEY]).toBeUndefined(); + }); + + it('TC7 – returns {migrated:false} when write fails (does not throw)', async () => { + storageMock.local.set.mockImplementationOnce( + (_items: Record, cb?: () => void) => { + cb?.(); + } + ); + vi.stubGlobal('chrome', { + storage: { local: storageMock.local }, + runtime: { lastError: { message: 'QuotaExceededError' } }, + }); + + const result = await migrate(); + + expect(result.migrated).toBe(false); + expect(result.settings).toEqual(DEFAULT_SETTINGS); + expect(result.legacyKeysRemoved).toEqual([]); + expect(console.warn).toHaveBeenCalledWith( + 'SETTINGS_MIGRATION_WRITE_ERROR', + expect.any(Error) + ); + }); +}); diff --git a/src/__tests__/settings-storage.test.ts b/src/__tests__/settings-storage.test.ts new file mode 100644 index 0000000..a60a155 --- /dev/null +++ b/src/__tests__/settings-storage.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +import { DEFAULT_SETTINGS, SETTINGS_STORAGE_KEY } from '../settings/types'; +import { read, write, subscribe } from '../settings/storage'; + +type StorageChangedListener = ( + changes: Record, + areaName: string +) => void; + +const makeStorageMock = () => { + const store: Record = {}; + const onChangedListeners: StorageChangedListener[] = []; + + const local = { + get: vi.fn((key: string, cb: (res: Record) => void) => { + cb({ [key]: store[key] }); + }), + set: vi.fn( + (items: Record, cb?: () => void) => { + Object.assign(store, items); + cb?.(); + } + ), + }; + + const onChanged = { + addListener: vi.fn((listener: StorageChangedListener) => { + onChangedListeners.push(listener); + }), + removeListener: vi.fn((listener: StorageChangedListener) => { + const idx = onChangedListeners.indexOf(listener); + if (idx !== -1) onChangedListeners.splice(idx, 1); + }), + fireChange(changes: Record, area = 'local') { + onChangedListeners.forEach((l) => l(changes, area)); + }, + }; + + return { local, onChanged, store }; +}; + +describe('settings-storage', () => { + let storageMock: ReturnType; + + beforeEach(() => { + storageMock = makeStorageMock(); + vi.stubGlobal('chrome', { + storage: { + local: storageMock.local, + onChanged: storageMock.onChanged, + }, + runtime: { lastError: null }, + }); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('write then read returns the same settings object', async () => { + const settings = { + version: 1 as const, + detectors: { unusedRenders: { enabled: true, budgetMs: 16 } }, + perSite: { 'example.com': { detectors: { unusedRenders: { enabled: false } } } }, + }; + + await write(settings); + const result = await read(); + + expect(result).toEqual(settings); + }); + + it('read with corrupt storage value returns DEFAULT_SETTINGS and logs SETTINGS_PARSE_ERROR', async () => { + storageMock.store[SETTINGS_STORAGE_KEY] = { version: 99, garbage: true }; + + const result = await read(); + + expect(result).toEqual(DEFAULT_SETTINGS); + expect(console.warn).toHaveBeenCalledWith( + 'SETTINGS_PARSE_ERROR', + expect.anything() + ); + }); + + it('read with missing key returns DEFAULT_SETTINGS without error log', async () => { + const result = await read(); + + expect(result).toEqual(DEFAULT_SETTINGS); + expect(console.warn).not.toHaveBeenCalled(); + }); + + it('write with invalid schema throws', async () => { + const invalid = { version: 2, detectors: 'not-an-object', perSite: {} }; + + await expect(write(invalid as never)).rejects.toThrow(); + }); + + it('subscribe fires callback when storage changes with valid value', async () => { + const callback = vi.fn(); + subscribe(callback); + + const newSettings = { ...DEFAULT_SETTINGS }; + storageMock.onChanged.fireChange({ + [SETTINGS_STORAGE_KEY]: { newValue: newSettings }, + }); + + expect(callback).toHaveBeenCalledWith(newSettings); + }); + + it('unsubscribe stops callback from firing', () => { + const callback = vi.fn(); + const unsubscribe = subscribe(callback); + + unsubscribe(); + + storageMock.onChanged.fireChange({ + [SETTINGS_STORAGE_KEY]: { newValue: DEFAULT_SETTINGS }, + }); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('subscribe with corrupt storage change logs warning and does NOT invoke callback', () => { + const callback = vi.fn(); + subscribe(callback); + + storageMock.onChanged.fireChange({ + [SETTINGS_STORAGE_KEY]: { newValue: { version: 'bad', detectors: null, perSite: null } }, + }); + + expect(callback).not.toHaveBeenCalled(); + expect(console.warn).toHaveBeenCalledWith( + 'SETTINGS_PARSE_ERROR', + expect.anything() + ); + }); +}); diff --git a/src/content/index.ts b/src/content/index.ts index fe01004..512e579 100644 --- a/src/content/index.ts +++ b/src/content/index.ts @@ -1,9 +1,12 @@ +import { migrate } from '../settings/migrate'; + const PAGE_SOURCE = 'REACT_DEBUGGER_PAGE'; const CONTENT_SOURCE = 'REACT_DEBUGGER_CONTENT'; const STORAGE_KEY = 'react_debugger_disabled_sites'; let isEnabled = true; let isInitialized = false; +let migrationDone = false; let extensionContextValid = true; let debuggerEnabled = false; let clsObserver: PerformanceObserver | null = null; @@ -64,8 +67,20 @@ function injectPageScript(): Promise { async function checkIfSiteEnabled(): Promise { try { const hostname = window.location.hostname; - const result = await chrome.storage.local.get(STORAGE_KEY); - const disabledSites: string[] = result[STORAGE_KEY] || []; + if (!migrationDone) { + const result = await migrate(); + migrationDone = true; + const siteOverride = result.settings.perSite[hostname]; + if (siteOverride?.detectors) { + const allDisabled = Object.values(siteOverride.detectors).every( + (d) => !d.enabled + ); + if (allDisabled) return false; + } + return true; + } + const legacyResult = await chrome.storage.local.get(STORAGE_KEY); + const disabledSites: string[] = legacyResult[STORAGE_KEY] || []; return !disabledSites.includes(hostname); } catch { return true; diff --git a/src/inject/detectors/closure-leak.ts b/src/inject/detectors/closure-leak.ts new file mode 100644 index 0000000..04c1c41 --- /dev/null +++ b/src/inject/detectors/closure-leak.ts @@ -0,0 +1,143 @@ +/** + * closure-leak detector (M-B T8 — canary extraction #1) + * + * Strategy: **thin adapter (Strategy A)**. + * + * The legacy closure-tracking implementation in `src/inject/index.ts` + * (`_installClosureTracking` + `trackClosure` + `checkStaleClosureOnExecution` + * + the `trackedClosures` / `staleClosureIssues` Maps) is **kept in place + * unchanged**. It has shipped for many versions and depends on inject-resident + * state (`getCurrentComponentContext`, `componentRenderIds`, the captured + * `originalSetTimeout` / `originalSetInterval` / `originalAddEventListener`). + * + * Inject exposes a small bridge on the host-page window: + * + * window.__REACT_DEBUGGER_CLOSURE_BRIDGE__ = { + * install(): // calls _installClosureTracking() (idempotent) + * restoreOriginals(): // best-effort restore of window.setTimeout etc. + * clear(): // drains tracked closures + stale issue dedupe map + * setSink(fn): // detector installs an issue-emit hook here so + * // inject can forward each STALE_CLOSURE issue into + * // the detector's buffer in addition to the legacy + * // sendFromPage('STALE_CLOSURE_DETECTED', issue) path + * } + * + * This file converts that bridge into a `Detector` conforming to T1's + * registry contract. The legacy `sendFromPage('STALE_CLOSURE_DETECTED', ...)` + * call site is preserved for backward compatibility with any consumer that + * still listens on the legacy channel; the detector buffer is filled + * **in addition**, so panel users see issues either way. + * + * Default-off via `KNOWN_DETECTORS_DEFAULTS` in `src/settings/migrate.ts` + * (confidence: medium → enabled: false). Users opt-in via Settings UI. + */ +import type { Detector, DetectorContext } from '../../types/registry'; +import type { Issue } from '../../types'; + +/** + * Bridge contract exposed by inject. Detector is tolerant of missing methods + * (tests / future inject builds that haven't wired everything yet). + */ +interface ClosureBridge { + install?: () => void; + restoreOriginals?: () => void; + clear?: () => void; + setSink?: (fn: ((issue: Issue) => void) | null) => void; +} + +/** + * Accessor with a tight cast at the boundary. The legacy code already uses + * `(window as any).__REACT_DEBUGGER_*` extensively — this file follows the + * same convention (which is allowed under the host-page-window namespace + * pattern per the M-B rules). + */ +function getBridge(): ClosureBridge | null { + // Test environments (jsdom) may not have the bridge installed; the detector + // must not throw at init() in that case — it just becomes a no-op. + if (typeof window === 'undefined') return null; + const bridge = (window as unknown as { + __REACT_DEBUGGER_CLOSURE_BRIDGE__?: ClosureBridge; + }).__REACT_DEBUGGER_CLOSURE_BRIDGE__; + return bridge ?? null; +} + +export function createClosureLeakDetector(): Detector { + let ctx: DetectorContext | null = null; + let buffer: Issue[] = []; + let installed = false; + + // Stable sink reference so we can detach the same function on teardown. + const sink = (issue: Issue): void => { + // Defensive: ignore once teardown has nulled ctx (sink may still be held + // by inject until restoreOriginals() runs). + if (ctx === null) return; + buffer.push(issue); + }; + + return { + id: 'closure-leak', + category: 'side-effects', + // Event-driven detector — onCommit is a near-no-op so the budget is tiny. + budgetMs: 0.2, + // Documented FP modes with async patterns → default OFF per T5. + confidence: 'medium', + prodCapable: true, + + init(injected: DetectorContext): void { + ctx = injected; + const bridge = getBridge(); + if (bridge === null) return; + try { + // Attach the sink BEFORE installing so any synchronous emission during + // install (none expected, but cheap insurance) is captured. + bridge.setSink?.(sink); + bridge.install?.(); + installed = true; + } catch (err) { + // Re-thrown errors here would be wrapped by the registry into a + // disabled-for-session marker. Route through ctx.log for diagnosis. + injected.log('[closure-leak] init failed', err); + } + }, + + /** + * Closure tracking is event-driven (setTimeout / setInterval / + * addEventListener callbacks). The per-commit hook is intentionally a + * no-op — issues land in the buffer when async callbacks fire, not when + * React commits. The legacy `periodicCleanup` (every 60s) already prunes + * tracked closures; we do not duplicate that here. + */ + onCommit(): void { + // Intentionally empty. See module comment. + }, + + drain(): Issue[] { + const out = buffer; + buffer = []; + return out; + }, + + teardown(): void { + // Detach sink and restore window globals. Idempotent: safe to call + // twice, or before init() (no bridge → silent return). + const bridge = getBridge(); + if (bridge !== null) { + try { + bridge.setSink?.(null); + if (installed) { + bridge.restoreOriginals?.(); + bridge.clear?.(); + } + } catch { + // Swallow — teardown must never throw. + } + } + installed = false; + buffer = []; + ctx = null; + }, + }; +} + +/** Singleton export for convenience — most callers use this directly. */ +export const closureLeakDetector = createClosureLeakDetector(); diff --git a/src/inject/detectors/index.ts b/src/inject/detectors/index.ts new file mode 100644 index 0000000..4836f94 --- /dev/null +++ b/src/inject/detectors/index.ts @@ -0,0 +1,21 @@ +/** + * Detector module aggregator. The inject script's bootstrap calls + * `registerAllDetectors(registry)` once, after `createRegistry(...)`. + * + * Add one `register()` line per new detector. T8 adds `closure-leak`, + * T9 adds `scan-overlay` here. + */ +import type { Registry } from '../registry'; +import { reconcilerKeysDetector } from './reconciler-keys'; +import { closureLeakDetector } from './closure-leak'; +import { scanOverlayDetector } from './scan-overlay'; + +export { reconcilerKeysDetector, createReconcilerKeysDetector } from './reconciler-keys'; +export { closureLeakDetector, createClosureLeakDetector } from './closure-leak'; +export { scanOverlayDetector, createScanOverlayDetector } from './scan-overlay'; + +export function registerAllDetectors(registry: Registry): void { + registry.register(reconcilerKeysDetector); + registry.register(closureLeakDetector); + registry.register(scanOverlayDetector); +} diff --git a/src/inject/detectors/reconciler-keys.ts b/src/inject/detectors/reconciler-keys.ts new file mode 100644 index 0000000..a81fb5b --- /dev/null +++ b/src/inject/detectors/reconciler-keys.ts @@ -0,0 +1,223 @@ +/** + * reconciler-keys detector (M-B T7, hero #1) + * + * Dynamic, cross-commit list-key analysis built on the T1 Detector contract + * and T3 React version adapter. Complementary to the legacy `checkListKeys` + * in `src/inject/index.ts` (which only does single-shot shape detection of + * MISSING_KEY / INDEX_AS_KEY). + * + * Two signals, both emitted as `UNSTABLE_LIST_KEY` issues: + * + * 1. Unstable key signature (Case A) — flagged on the FIRST commit. + * A child key matches either: + * - /^[0-9]{10,}/ → Date.now() / Date.now()+suffix style + * - /^0\.[0-9]{6,}/ → Math.random() style ("0.xxxxxx" decimals) + * + * 2. Index keys + verified reorder (Case B) — flagged on the SECOND+ commit. + * All children have purely numeric keys (`/^\d+$/`), AND the ordering + * of those keys changed from the previous commit recorded via + * `ctx.write('lastList:' + parentPath, [...])`. + * + * Per-parent dedupe via `ctx.dedupe(parentComponentPath)` so a single noisy + * list does not flood the panel. + * + * Cross-commit state lives in `ctx.write` / `ctx.read` — the registry owns + * the persistent store; this detector treats it purely as an opaque K/V. + * + * The walk uses `walkFiberImpl` from `react-adapters/utils.ts`. We do NOT + * re-implement fiber traversal. + */ +import type { Detector, DetectorContext } from '../../types/registry'; +import type { Issue } from '../../types'; +import type { FiberNode, FiberRoot, ReactVersionAdapter } from '../react-adapters/types'; +import { walkFiberImpl } from '../react-adapters/utils'; +import { getAdapter } from '../react-adapters'; + +const DATE_NOW_KEY_RE = /^[0-9]{10,}/; // 10+ digit prefix (ms since epoch) +const MATH_RANDOM_KEY_RE = /^0\.[0-9]{6,}/; // 0.xxxxxx decimal +const NUMERIC_INDEX_KEY_RE = /^\d+$/; + +const STORE_PREFIX = 'lastList:'; + +interface ListChild { + key: string; + index: number; +} + +function isUnstableKey(key: string): boolean { + return DATE_NOW_KEY_RE.test(key) || MATH_RANDOM_KEY_RE.test(key); +} + +function buildComponentPath(fiber: FiberNode | null, adapter: ReactVersionAdapter): string[] { + const path: string[] = []; + let limit = 50; // guard against pathological return-chains + let cur: FiberNode | null = fiber; + while (cur !== null && limit-- > 0) { + const name = adapter.getDisplayName(cur); + if (name) path.unshift(name); + cur = cur.return; + } + return path; +} + +function generateIssueId(seq: number): string { + // Avoid Math.random for determinism in tests; collision risk is acceptable + // because the registry tracks dedupe separately. + return `reconciler-keys-${Date.now()}-${seq}`; +} + +export function createReconcilerKeysDetector(): Detector { + let ctx: DetectorContext | null = null; + let adapter: ReactVersionAdapter | null = null; + let buffer: Issue[] = []; + let issueSeq = 0; + + function emitIssue(issue: Issue): void { + buffer.push(issue); + } + + function processListParent( + parent: FiberNode, + children: ListChild[], + context: DetectorContext, + ad: ReactVersionAdapter, + ): void { + const parentName = ad.getDisplayName(parent) ?? 'Anonymous'; + const componentPath = buildComponentPath(parent, ad); + const dedupeKey = componentPath.length > 0 ? componentPath.join('/') : parentName; + + const keys = children.map((c) => c.key); + + // Case A: any child has an unstable key signature — emit immediately. + const hasUnstable = keys.some(isUnstableKey); + if (hasUnstable) { + if (!context.dedupe(dedupeKey)) return; + emitIssue({ + id: generateIssueId(++issueSeq), + type: 'UNSTABLE_LIST_KEY', + severity: 'warning', + component: parentName, + message: + 'List children use unstable keys (auto-fix: use a stable id field)', + suggestion: + 'Replace key={index|Math.random()|Date.now()} with key={item.id} or another stable identifier', + timestamp: Date.now(), + fiberId: dedupeKey, + location: { componentName: parentName, componentPath }, + }); + return; + } + + // Case B: numeric-index keys + verified reorder across commits. + const allNumericIndex = keys.every((k) => NUMERIC_INDEX_KEY_RE.test(k)); + if (!allNumericIndex) { + // Nothing to track — record current state opportunistically so a later + // mutation to index keys can still be diff'd, but no emission. + return; + } + + const storeKey = STORE_PREFIX + dedupeKey; + const prev = context.read(storeKey); + // Always stage current keys for next commit (regardless of emission). + context.write(storeKey, keys.slice()); + + if (prev === undefined) { + // First sighting — cannot detect reorder yet. + return; + } + if (prev.length !== keys.length) { + // Length change is membership change, not a reorder signal. + return; + } + if (JSON.stringify(prev) === JSON.stringify(keys)) { + // Same order — quiet. + return; + } + // Order changed AND keys remain purely numeric → emit. + if (!context.dedupe(dedupeKey)) return; + emitIssue({ + id: generateIssueId(++issueSeq), + type: 'UNSTABLE_LIST_KEY', + severity: 'warning', + component: parentName, + message: + 'Index keys with detected reorder (auto-fix: use a stable id field)', + suggestion: + 'Replace key={index|Math.random()|Date.now()} with key={item.id} or another stable identifier', + timestamp: Date.now(), + fiberId: dedupeKey, + location: { componentName: parentName, componentPath }, + }); + } + + return { + id: 'reconciler-keys', + category: 'ui-state', + budgetMs: 0.3, + confidence: 'high', + prodCapable: true, + + init(injected: DetectorContext): void { + ctx = injected; + // Bind the adapter once. `getAdapter()` is safe in test environments + // (it falls back to r17Adapter when no React is on the page). + try { + adapter = getAdapter(); + } catch { + adapter = null; + } + }, + + onCommit(fiberRoot: unknown, deadline: number): void { + const context = ctx; + const ad = adapter; + if (context === null || ad === null) return; + const root = fiberRoot as FiberRoot | null; + if (!root || !root.current) return; + + walkFiberImpl(root.current, (fiber) => { + // Cheap deadline check — performance.now() is also cheap, but bail + // out without doing the per-list work when we're already over. + if (context.performance.now() > deadline) return; + + // Collect KEYED children of this fiber. A "list parent" is any fiber + // with >= 2 children that all carry explicit keys. + let child: FiberNode | null = fiber.child; + const keyed: ListChild[] = []; + let index = 0; + let hasUnkeyed = false; + while (child !== null) { + if (child.key !== null && child.key !== undefined) { + keyed.push({ key: String(child.key), index }); + } else if (keyed.length > 0) { + // Mixed keyed + unkeyed siblings — punt; this is the legacy + // checkListKeys territory (MISSING_KEY). + hasUnkeyed = true; + } + index++; + child = child.sibling; + } + if (hasUnkeyed) return; + if (keyed.length < 2) return; + + processListParent(fiber, keyed, context, ad); + }); + }, + + drain(): Issue[] { + const out = buffer; + buffer = []; + return out; + }, + + teardown(): void { + buffer = []; + issueSeq = 0; + ctx = null; + adapter = null; + }, + }; +} + +/** Singleton export for convenience — most callers use this directly. */ +export const reconcilerKeysDetector = createReconcilerKeysDetector(); diff --git a/src/inject/detectors/scan-overlay.ts b/src/inject/detectors/scan-overlay.ts new file mode 100644 index 0000000..97d6517 --- /dev/null +++ b/src/inject/detectors/scan-overlay.ts @@ -0,0 +1,151 @@ +/** + * scan-overlay detector (M-B T9 — canary extraction #2) + * + * Strategy: **thin adapter (Strategy A)**. + * + * The legacy scan implementation in `src/inject/index.ts` (`flashRenderOverlay` + * + `clearAllOverlays` + the `overlayElements` / `renderFlashTimers` / + * `lastOverlayFlashTime` maps + `toggleScan` + the `__REACT_DEBUGGER_SCAN__` + * window export) is **kept in place unchanged**. It owns the DOM-touching + * paint pipeline and the visible state. + * + * Inject exposes a small bridge on the host-page window: + * + * window.__REACT_DEBUGGER_SCAN_BRIDGE__ = { + * enable(): // calls toggleScan(true) + * disable(): // calls toggleScan(false) + clears overlays + * isEnabled(): // returns scanEnabled + * setRecorder(fn): // commit-loop calls fn(fiber, name, count) + * // instead of the sync flashRenderOverlay + * paint(items): // drives flashRenderOverlay for each buffered + * // item — DOM access lives here, so this MUST + * // only be called from onIdle + * clear(): // clearAllOverlays() + * } + * + * The critical M-B audit fix: `getBoundingClientRect()` no longer runs + * synchronously in `onCommitFiberRoot`. Per-commit work is bounded to fiber + * traversal + buffering; the DOM measurement happens in `onIdle()` when the + * browser is otherwise idle. + * + * Default-on via `KNOWN_DETECTORS_DEFAULTS` (confidence: high → enabled: true). + */ +import type { Detector, DetectorContext } from '../../types/registry'; + +export interface ScanOverlayPendingItem { + fiber: unknown; + componentName: string; + renderCount: number; +} + +interface ScanBridge { + enable?: () => void; + disable?: () => void; + isEnabled?: () => boolean; + setRecorder?: ( + fn: ((fiber: unknown, name: string, count: number) => void) | null, + ) => void; + paint?: (items: ScanOverlayPendingItem[]) => void; + clear?: () => void; +} + +function getBridge(): ScanBridge | null { + if (typeof window === 'undefined') return null; + const bridge = (window as unknown as { + __REACT_DEBUGGER_SCAN_BRIDGE__?: ScanBridge; + }).__REACT_DEBUGGER_SCAN_BRIDGE__; + return bridge ?? null; +} + +const PENDING_KEY = 'pendingFlashes'; +const MAX_PENDING_PER_COMMIT = 200; + +export function createScanOverlayDetector(): Detector { + let ctx: DetectorContext | null = null; + let pending: ScanOverlayPendingItem[] = []; + let recorderAttached = false; + + const recorder = ( + fiber: unknown, + name: string, + count: number, + ): void => { + if (ctx === null) return; + if (pending.length >= MAX_PENDING_PER_COMMIT) return; + pending.push({ fiber, componentName: name, renderCount: count }); + ctx.write(PENDING_KEY, pending); + }; + + return { + id: 'scan-overlay', + category: 'performance', + budgetMs: 0.5, + confidence: 'high', + prodCapable: true, + + init(injected: DetectorContext): void { + ctx = injected; + const bridge = getBridge(); + if (bridge === null) return; + try { + bridge.setRecorder?.(recorder); + recorderAttached = true; + } catch (err) { + injected.log('[scan-overlay] init failed', err); + } + }, + + onCommit(): void { + // Intentionally empty — the host-page commit loop pushes fibers through + // the recorder above. Detector owns no per-commit work other than the + // recorder closure, which already buffers into ctx-staging via write(). + }, + + onIdle(deadline: IdleDeadline): void { + if (pending.length === 0) return; + const bridge = getBridge(); + if (bridge === null || !bridge.paint) { + pending = []; + return; + } + // Drain the buffer in one shot — paint runs sync but is bounded by + // MAX_PENDING_PER_COMMIT (200), and the bridge's flashRenderOverlay + // path already has a 300ms-per-fiber debounce. + const items = pending; + pending = []; + try { + if (deadline.timeRemaining() <= 0 && !deadline.didTimeout) { + // No budget left and not a forced run — drop this batch to avoid + // jank. The next commit will rebuild a fresh batch. + return; + } + bridge.paint(items); + } catch (err) { + ctx?.log('[scan-overlay] paint failed', err); + } + }, + + drain(): never[] { + return []; + }, + + teardown(): void { + const bridge = getBridge(); + if (bridge !== null) { + try { + if (recorderAttached) { + bridge.setRecorder?.(null); + } + bridge.clear?.(); + } catch { + // teardown must never throw + } + } + recorderAttached = false; + pending = []; + ctx = null; + }, + }; +} + +export const scanOverlayDetector = createScanOverlayDetector(); diff --git a/src/inject/index.ts b/src/inject/index.ts index 39232d3..4a182fe 100644 --- a/src/inject/index.ts +++ b/src/inject/index.ts @@ -2,6 +2,9 @@ declare class WeakRef { constructor(target: T); deref(): T | undefined; } import { installCleanupInterval, uninstallCleanupInterval } from './lifecycle'; +import { createRegistry } from './registry'; +import { registerAllDetectors } from './detectors'; +import { sanitizeValue as sanitizeForRegistry } from '../utils/sanitize'; (function() { 'use strict'; @@ -117,6 +120,20 @@ import { installCleanupInterval, uninstallCleanupInterval } from './lifecycle'; } } + // M-B T2: detector registry singleton. T7+ register detectors via `registerAllDetectors`. + // Registration is deferred to the end of the IIFE so that host-page bridges + // (__REACT_DEBUGGER_CLOSURE_BRIDGE__, __REACT_DEBUGGER_SCAN_BRIDGE__) are + // wired BEFORE any detector init() runs — detectors look those up at init. + const registry = createRegistry({ + emit: (payload) => { + const type = (payload as { type?: string })?.type ?? 'DETECTOR_EVENT'; + sendFromPage(type, payload); + }, + log: log, + sanitize: sanitizeForRegistry, + performance: window.performance, + }); + function listenFromContent(callback: (message: { type: string; payload?: unknown }) => void): void { window.addEventListener('message', (event) => { if (event.source !== window) return; @@ -359,6 +376,9 @@ import { installCleanupInterval, uninstallCleanupInterval } from './lifecycle'; const originalSetTimeout = window.setTimeout; const originalSetInterval = window.setInterval; const originalAddEventListener = EventTarget.prototype.addEventListener; + + let closureTrackingInstalled = false; + let closureLeakSink: ((issue: any) => void) | null = null; function getCurrentComponentContext(): { name: string; path: string[]; renderId: number } | null { const hook = (window as any).__REACT_DEVTOOLS_GLOBAL_HOOK__; @@ -464,6 +484,13 @@ import { installCleanupInterval, uninstallCleanupInterval } from './lifecycle'; staleClosureIssues.set(issueKey, issue); sendFromPage('STALE_CLOSURE_DETECTED', issue); + if (closureLeakSink) { + try { + closureLeakSink(issue); + } catch { + // Sink failures must not break legacy emission path. + } + } } } @@ -473,6 +500,8 @@ import { installCleanupInterval, uninstallCleanupInterval } from './lifecycle'; } function _installClosureTracking(): void { + if (closureTrackingInstalled) return; + closureTrackingInstalled = true; (window as any).setTimeout = function(callback: Function, delay?: number, ...args: any[]) { if (typeof callback !== 'function') { return originalSetTimeout.call(window, callback, delay, ...args); @@ -1634,8 +1663,27 @@ import { installCleanupInterval, uninstallCleanupInterval } from './lifecycle'; } function detectReactVersion(): string { - return (window as any).React?.version || 'unknown'; - } + const hook = (window as any).__REACT_DEVTOOLS_GLOBAL_HOOK__; + // Priority 1: renderer.version — works for ESM, Next.js App Router, bundled React + // Source: https://github.com/facebook/react/blob/05ca66ad9c/packages/react-devtools-shared/src/backend/types.js#L131 + if (hook?.renderers instanceof Map && hook.renderers.size > 0) { + const renderer = hook.renderers.values().next().value; + if (typeof renderer?.version === 'string' && renderer.version) { + return renderer.version; + } + } + // Priority 2: window.React.version (fails for ESM-only bundles) + const reactVersion = (window as any).React?.version; + if (typeof reactVersion === 'string' && reactVersion) return reactVersion; + // Priority 3: reconcilerVersion + if (hook?.renderers instanceof Map && hook.renderers.size > 0) { + const renderer = hook.renderers.values().next().value; + if (typeof renderer?.reconcilerVersion === 'string' && renderer.reconcilerVersion) { + return renderer.reconcilerVersion; + } + } + return 'unknown'; + } function detectReactMode(): 'development' | 'production' { const React = (window as any).React; @@ -1753,23 +1801,55 @@ import { installCleanupInterval, uninstallCleanupInterval } from './lifecycle'; pendingRenderSnapshots.push(snapshot); } - // Scan overlay: traverse fiber tree directly at commit time (like v2.0.0). - // NOT tied to snapshot's 2ms budget — scan gets its own traversal with higher limits. - // Only runs when scan is enabled (no perf impact otherwise). - if (scanEnabled) { + // Scan overlay: traverse fiber tree at commit time, BUFFER fibers via + // the scan-overlay detector's recorder. NO DOM access here (T9 fix) — + // getBoundingClientRect is deferred to onIdle via bridge.paint(). + if (scanEnabled && scanRecorder !== null) { try { traverseFiber(root.current, (node, path) => { if (isUserComponent(node) && didFiberRender(node)) { const componentName = getComponentName(node); const fiberId = `${componentName}_${path}`; const count = renderCounts.get(fiberId) || 1; - flashRenderOverlay(node, componentName, count); + scanRecorder!(node, componentName, count); } }, '', 200); } catch (e) { if (DEBUG) console.error('[React Debugger] Scan error:', e); } } + + // M-B T2: registry dispatch runs after snapshot capture + scan buffer. + // Each registered detector gets its own deadline budget from registry. + try { + registry.dispatch({ fiberRoot: root }); + } catch (err) { + log('[registry] dispatch top-level error:', err); + } + + // M-B T9: schedule onIdle for detectors that defer work (scan-overlay + // uses this to run getBoundingClientRect + overlay paint off the + // commit path). + if ('requestIdleCallback' in window) { + requestIdleCallback((deadline: IdleDeadline) => { + try { + registry.dispatchIdle(deadline); + } catch (err) { + log('[registry] dispatchIdle top-level error:', err); + } + }, { timeout: 500 }); + } else { + setTimeout(() => { + try { + registry.dispatchIdle({ + didTimeout: true, + timeRemaining: () => 50, + } as IdleDeadline); + } catch (err) { + log('[registry] dispatchIdle top-level error:', err); + } + }, 16); + } }; // Expose for POLL_DATA handler @@ -2641,6 +2721,7 @@ import { installCleanupInterval, uninstallCleanupInterval } from './lifecycle'; } let scanEnabled = false; + let scanRecorder: ((fiber: FiberNode, name: string, count: number) => void) | null = null; const overlayElements = new Map(); const renderFlashTimers = new Map(); const lastOverlayFlashTime = new Map(); @@ -2807,6 +2888,29 @@ import { installCleanupInterval, uninstallCleanupInterval } from './lifecycle'; isEnabled: () => scanEnabled, }; + // M-B T9: scan-overlay detector bridge. The detector wires `setRecorder` + // at registration so that commit-time fiber traversal calls into the + // detector's buffer instead of synchronously running flashRenderOverlay. + // `paint` is what the detector's onIdle drives — DOM measurement lives here. + (window as any).__REACT_DEBUGGER_SCAN_BRIDGE__ = { + enable: () => toggleScan(true), + disable: () => toggleScan(false), + isEnabled: () => scanEnabled, + setRecorder: (fn: ((fiber: FiberNode, name: string, count: number) => void) | null) => { + scanRecorder = fn; + }, + paint: (items: Array<{ fiber: unknown; componentName: string; renderCount: number }>) => { + for (const item of items) { + try { + flashRenderOverlay(item.fiber as FiberNode, item.componentName, item.renderCount); + } catch (e) { + if (DEBUG) console.error('[React Debugger] scan paint error:', e); + } + } + }, + clear: () => clearAllOverlays(), + }; + let memoryMonitoringEnabled = false; let memoryMonitorInterval: number | null = null; const MEMORY_SAMPLE_INTERVAL = 2000; @@ -2977,6 +3081,8 @@ import { installCleanupInterval, uninstallCleanupInterval } from './lifecycle'; reportedSlowRenders.delete(key); } } + + try { registry.drainAll(); } catch (err) { log('[registry] drainAll cleanup error:', err); } } (window as any).__REACT_DEBUGGER_MEMORY__ = { @@ -3275,6 +3381,33 @@ import { installCleanupInterval, uninstallCleanupInterval } from './lifecycle'; (window as any).__REACT_DEBUGGER_ENABLE_CLOSURE_TRACKING__ = _installClosureTracking; + (window as any).__REACT_DEBUGGER_CLOSURE_BRIDGE__ = { + install: _installClosureTracking, + restoreOriginals: () => { + if (!closureTrackingInstalled) return; + (window as any).setTimeout = originalSetTimeout; + (window as any).setInterval = originalSetInterval; + EventTarget.prototype.addEventListener = originalAddEventListener; + closureTrackingInstalled = false; + }, + clear: () => { + trackedClosures.clear(); + staleClosureIssues.clear(); + }, + setSink: (fn: ((issue: any) => void) | null) => { + closureLeakSink = fn; + }, + }; + + // M-B T2/T8/T9: detector registration runs AFTER bridges are wired (see + // comment near `createRegistry`). Each detector's init() can now find its + // host-page bridge. + try { + registerAllDetectors(registry); + } catch (err) { + log('[registry] registerAllDetectors failed:', err); + } + // React auto-detection deferred to ENABLE_DEBUGGER handler console.log('[React Debugger] Inject script loaded'); diff --git a/src/inject/react-adapters/index.ts b/src/inject/react-adapters/index.ts new file mode 100644 index 0000000..33523e0 --- /dev/null +++ b/src/inject/react-adapters/index.ts @@ -0,0 +1,138 @@ +/** + * React Version Adapter — public entry point + * + * Usage: + * const adapter = getAdapter(); // auto-detects from window.__REACT_DEVTOOLS_GLOBAL_HOOK__ + * const name = adapter.getDisplayName(fiber); + * const tags = adapter.FIBER_TAGS; + * + * Or with an explicit version string: + * const adapter = getAdapterForVersion('18.3.1'); + */ + +export type { ReactVersionAdapter, FiberTagMap, FiberNode, FiberHook, FiberRoot, ReactRenderer, DevToolsProfilingHooks, ProfilingConflict } from './types'; +export { PROFILING_FLAG_BASIC_SUPPORT, PROFILING_FLAG_TIMELINE_SUPPORT, PROFILING_FLAG_PERFORMANCE_TRACKS_SUPPORT } from './types'; + +export { r17Adapter, R17_FIBER_TAGS } from './r17'; +export { r18Adapter, R18_FIBER_TAGS } from './r18'; +export { r19Adapter, R19_FIBER_TAGS } from './r19'; +export { r19_2Adapter, R19_2_FIBER_TAGS } from './r19_2'; + +import type { ReactVersionAdapter, ProfilingConflict } from './types'; +import { r17Adapter } from './r17'; +import { r18Adapter } from './r18'; +import { r19Adapter } from './r19'; +import { r19_2Adapter } from './r19_2'; + +// --------------------------------------------------------------------------- +// Version detection +// --------------------------------------------------------------------------- + +/** + * Detect the React version string from the page. + * + * Priority order (most to least reliable): + * 1. renderer.version from hook.renderers — works in ESM, SSR, RN, any bundler + * 2. window.React.version — fails for ESM-only, Next.js App Router, RN + * 3. renderer.reconcilerVersion — monorepo builds sometimes differ from version + * + * Source for renderer.version reliability: + * https://github.com/facebook/react/blob/05ca66ad9c/packages/react-devtools-shared/src/backend/types.js#L131 + */ +export function detectReactVersion(): string | null { + const hook = (window as any).__REACT_DEVTOOLS_GLOBAL_HOOK__; + + // Priority 1: renderer.version (most reliable) + if (hook?.renderers instanceof Map && hook.renderers.size > 0) { + const renderer = hook.renderers.values().next().value; + if (typeof renderer?.version === 'string' && renderer.version) { + return renderer.version; + } + } + + // Priority 2: window.React.version + const reactVersion = (window as any).React?.version; + if (typeof reactVersion === 'string' && reactVersion) { + return reactVersion; + } + + // Priority 3: reconcilerVersion + if (hook?.renderers instanceof Map && hook.renderers.size > 0) { + const renderer = hook.renderers.values().next().value; + if (typeof renderer?.reconcilerVersion === 'string' && renderer.reconcilerVersion) { + return renderer.reconcilerVersion; + } + } + + return null; +} + +/** + * Parse a semver string into [major, minor, patch]. + * Returns [0, 0, 0] for unparseable strings. + */ +function parseSemver(version: string): [number, number, number] { + const match = version.match(/^(\d+)\.(\d+)\.(\d+)/); + if (!match) return [0, 0, 0]; + return [parseInt(match[1], 10), parseInt(match[2], 10), parseInt(match[3], 10)]; +} + +/** + * Select the correct adapter for a given React version string. + * + * Version routing: + * 19.2+ → r19_2Adapter (ViewTransition/Activity tags; supportsPerformanceTracks) + * 19.x → r19Adapter (HostHoistable/HostSingleton/Throw; no Indeterminate) + * 18.x → r18Adapter (Offscreen=22; injectProfilingHooks present) + * 17.x → r17Adapter (Block=22 / Offscreen=23; no injectProfilingHooks) + * <17 → r17Adapter (best effort — no adapter below 17) + */ +export function getAdapterForVersion(version: string): ReactVersionAdapter { + const [major, minor] = parseSemver(version); + + if (major >= 19) { + if (major > 19 || minor >= 2) return r19_2Adapter; + return r19Adapter; + } + if (major === 18) return r18Adapter; + // 17.x and below + return r17Adapter; +} + +/** + * Detect React version from the page and return the correct adapter. + * Falls back to r17Adapter if version cannot be determined (safest fallback + * since r17 tags 0–19 are a stable subset of all later versions). + */ +export function getAdapter(): ReactVersionAdapter { + const version = detectReactVersion(); + if (version) return getAdapterForVersion(version); + // Unknown version: fallback to r17 (minimal safe tag set) + return r17Adapter; +} + +// --------------------------------------------------------------------------- +// Conflict detection +// --------------------------------------------------------------------------- + +/** + * Detect active profiling hook conflicts before calling injectProfilingHooks. + * + * react-scan detection: + * window.__REACT_SCAN__.ReactScanInternals present + * Source: https://github.com/aidenybai/react-scan/blob/main/packages/scan/src/types.ts + * https://github.com/aidenybai/react-scan/blob/main/packages/scan/src/new-outlines/index.ts + * + * Real React DevTools detection (bippy pattern): + * 'getFiberRoots' in window.__REACT_DEVTOOLS_GLOBAL_HOOK__ + * Source: https://github.com/aidenybai/bippy/blob/main/packages/bippy/src/rdt-hook.ts + */ +export function detectProfilingConflict(): ProfilingConflict { + const reactScan = + typeof (window as any).__REACT_SCAN__?.ReactScanInternals !== 'undefined'; + + const hook = (window as any).__REACT_DEVTOOLS_GLOBAL_HOOK__; + const reactDevTools = Boolean(hook && 'getFiberRoots' in hook); + + return { reactScan, reactDevTools }; +} diff --git a/src/inject/react-adapters/r17.ts b/src/inject/react-adapters/r17.ts new file mode 100644 index 0000000..280d83c --- /dev/null +++ b/src/inject/react-adapters/r17.ts @@ -0,0 +1,159 @@ +/** + * React 17.x adapter + * + * Tag source: https://github.com/facebook/react/blob/v17.0.2/packages/react-reconciler/src/ReactWorkTags.js + * Hook type: https://github.com/facebook/react/blob/v17.0.2/packages/react-reconciler/src/ReactFiberHooks.old.js + * + * Key differences from r18+: + * - Block=22, OffscreenComponent=23, LegacyHiddenComponent=24 (all shifted in r18) + * - FundamentalComponent=20 (removed in r18) + * - IndeterminateComponent=2 (removed in r19) + * - renderer.injectProfilingHooks: absent → injectProfilingHooks() returns null + * - hook.onPostCommitFiberRoot: absent + * - hook.setStrictMode: absent + * - hook.supportsFlight: absent + */ + +import type { + ReactVersionAdapter, + FiberTagMap, + FiberNode, + ReactRenderer, + DevToolsProfilingHooks, +} from './types'; +import { walkFiberImpl, walkHookLinkedList } from './utils'; + +// --------------------------------------------------------------------------- +// Fiber tags — React 17.x +// Source: https://github.com/facebook/react/blob/12adaffef7/packages/react-reconciler/src/ReactWorkTags.js +// --------------------------------------------------------------------------- +export const R17_FIBER_TAGS: FiberTagMap = { + FunctionComponent: 0, + ClassComponent: 1, + IndeterminateComponent: 2, // removed in r19 + HostRoot: 3, + HostPortal: 4, + HostComponent: 5, + HostText: 6, + Fragment: 7, + Mode: 8, + ContextConsumer: 9, + ContextProvider: 10, + ForwardRef: 11, + Profiler: 12, + SuspenseComponent: 13, + MemoComponent: 14, + SimpleMemoComponent: 15, + LazyComponent: 16, + IncompleteClassComponent: 17, + DehydratedFragment: 18, + SuspenseListComponent: 19, + // FundamentalComponent: 20 — removed in r18, not in FiberTagMap interface + ScopeComponent: 21, + OffscreenComponent: 23, // NOTE: 22=Block in r17; Offscreen is 23 + LegacyHiddenComponent: 24, // NOTE: shifts to 23 in r18 + CacheComponent: null, // not in r17 + TracingMarkerComponent: null, // not in r17 + HostHoistable: null, // not in r17 + HostSingleton: null, // not in r17 + IncompleteFunctionComponent: null, // not in r17 + Throw: null, // not in r17 + ViewTransitionComponent: null, // not in r17 + ActivityComponent: null, // not in r17 +}; + +// --------------------------------------------------------------------------- +// getDisplayName — r17 tag set +// --------------------------------------------------------------------------- +function getDisplayName(fiber: FiberNode): string | null { + const { tag, type, elementType } = fiber; + const T = R17_FIBER_TAGS; + + if (tag === T.FunctionComponent || tag === T.IndeterminateComponent) { + return (type as any)?.displayName ?? (type as any)?.name ?? null; + } + if (tag === T.ClassComponent || tag === T.IncompleteClassComponent) { + return (type as any)?.displayName ?? (type as any)?.name ?? null; + } + if (tag === T.ForwardRef) { + const inner = (type as any)?.render ?? type; + const outer = elementType; + return ( + (outer as any)?.displayName ?? + `ForwardRef(${(inner as any)?.displayName ?? (inner as any)?.name ?? 'Anonymous'})` + ); + } + if (tag === T.MemoComponent || tag === T.SimpleMemoComponent) { + const inner = (type as any)?.type ?? type; + return ( + (elementType as any)?.displayName ?? + (inner as any)?.displayName ?? + (inner as any)?.name ?? + null + ); + } + if (tag === T.HostRoot) { + const debugRootType = fiber.stateNode?._debugRootType; + return debugRootType ?? null; + } + if (tag === T.HostComponent) return typeof type === 'string' ? type : null; + if (tag === T.HostText) return null; + if (tag === T.Fragment) return 'Fragment'; + if (tag === T.LazyComponent) return 'Lazy'; + if (tag === T.SuspenseComponent) return 'Suspense'; + if (tag === T.SuspenseListComponent) return 'SuspenseList'; + if (tag === T.Profiler) return 'Profiler'; + if (tag === T.OffscreenComponent) return 'Offscreen'; + if (tag === T.LegacyHiddenComponent) return 'LegacyHidden'; + if (tag === T.Mode) return 'Mode'; + if (tag === T.ContextProvider) { + const context = (type as any)?._context ?? type; + return `${(context as any)?.displayName ?? 'Context'}.Provider`; + } + if (tag === T.ContextConsumer) { + return `${(type as any)?.displayName ?? 'Context'}.Consumer`; + } + + // Tags that CANNOT exist in r17 — hard error to surface misconfiguration + if (tag === 26 || tag === 27 || tag === 28 || tag === 29 || tag === 30 || tag === 31) { + throw new Error( + `[react-adapters/r17] getDisplayName called with tag=${tag} which does not exist in React 17. ` + + 'Wrong adapter selected — check version detection.', + ); + } + + return null; +} + +// --------------------------------------------------------------------------- +// Adapter export +// --------------------------------------------------------------------------- +export const r17Adapter: ReactVersionAdapter = { + FIBER_TAGS: R17_FIBER_TAGS, + + getFiberTag(fiber) { + return fiber.tag; + }, + + getDisplayName, + + getHookValues(fiber) { + return walkHookLinkedList(fiber.memoizedState); + }, + + walkFiber(fiber, visitor) { + walkFiberImpl(fiber, visitor); + }, + + /** + * r17: renderer.injectProfilingHooks does NOT exist. + * Returns null (graceful degradation — caller should skip profiling hooks setup). + * Source: https://github.com/facebook/react/blob/12adaffef7/packages/react-devtools-shared/src/backend/types.js + * (method is absent from ReactRenderer type at v17.0.2) + */ + injectProfilingHooks(_renderer: ReactRenderer, _hooks: DevToolsProfilingHooks): null { + return null; + }, + + supportsPerformanceTracks: false, +}; diff --git a/src/inject/react-adapters/r18.ts b/src/inject/react-adapters/r18.ts new file mode 100644 index 0000000..f7888bb --- /dev/null +++ b/src/inject/react-adapters/r18.ts @@ -0,0 +1,173 @@ +/** + * React 18.x adapter + * + * Tag source: https://github.com/facebook/react/blob/v18.3.1/packages/react-reconciler/src/ReactWorkTags.js + * SHA: f1338f8080abd1386454a10bbf93d67bfe37ce85 + * + * Key differences from r17: + * - OffscreenComponent: 22 (was 23) + * - LegacyHiddenComponent: 23 (was 24) + * - Block (22) removed + * - FundamentalComponent (20) removed + * - CacheComponent=24, TracingMarkerComponent=25 added + * - renderer.injectProfilingHooks: NOW PRESENT + * - hook.onPostCommitFiberRoot: added + * - hook.setStrictMode: added + * - getInternalModuleRanges, registerInternalModuleStart/Stop: added + * + * Key differences from r19: + * - IndeterminateComponent=2 still present (removed in r19) + * - HostHoistable, HostSingleton, IncompleteFunctionComponent, Throw: absent + */ + +import type { + ReactVersionAdapter, + FiberTagMap, + FiberNode, + ReactRenderer, + DevToolsProfilingHooks, +} from './types'; +import { walkFiberImpl, walkHookLinkedList } from './utils'; + +// --------------------------------------------------------------------------- +// Fiber tags — React 18.x +// Source: https://github.com/facebook/react/blob/f1338f8080/packages/react-reconciler/src/ReactWorkTags.js +// --------------------------------------------------------------------------- +export const R18_FIBER_TAGS: FiberTagMap = { + FunctionComponent: 0, + ClassComponent: 1, + IndeterminateComponent: 2, // still present in r18; removed in r19 + HostRoot: 3, + HostPortal: 4, + HostComponent: 5, + HostText: 6, + Fragment: 7, + Mode: 8, + ContextConsumer: 9, + ContextProvider: 10, + ForwardRef: 11, + Profiler: 12, + SuspenseComponent: 13, + MemoComponent: 14, + SimpleMemoComponent: 15, + LazyComponent: 16, + IncompleteClassComponent: 17, + DehydratedFragment: 18, + SuspenseListComponent: 19, + ScopeComponent: 21, + OffscreenComponent: 22, // shifted from 23 in r17 + LegacyHiddenComponent: 23, // shifted from 24 in r17 + CacheComponent: 24, // new in r18 + TracingMarkerComponent: 25, // new in r18 + HostHoistable: null, // not in r18 + HostSingleton: null, // not in r18 + IncompleteFunctionComponent: null, // not in r18 + Throw: null, // not in r18 + ViewTransitionComponent: null, // not in r18 + ActivityComponent: null, // not in r18 +}; + +// --------------------------------------------------------------------------- +// getDisplayName — r18 tag set +// --------------------------------------------------------------------------- +function getDisplayName(fiber: FiberNode): string | null { + const { tag, type, elementType } = fiber; + const T = R18_FIBER_TAGS; + + if ( + tag === T.FunctionComponent || + tag === T.IndeterminateComponent || + tag === T.ClassComponent || + tag === T.IncompleteClassComponent + ) { + return (type as any)?.displayName ?? (type as any)?.name ?? null; + } + if (tag === T.ForwardRef) { + const inner = (type as any)?.render ?? type; + const outer = elementType; + return ( + (outer as any)?.displayName ?? + `ForwardRef(${(inner as any)?.displayName ?? (inner as any)?.name ?? 'Anonymous'})` + ); + } + if (tag === T.MemoComponent || tag === T.SimpleMemoComponent) { + const inner = (type as any)?.type ?? type; + return ( + (elementType as any)?.displayName ?? + (inner as any)?.displayName ?? + (inner as any)?.name ?? + null + ); + } + if (tag === T.HostRoot) { + return fiber.stateNode?._debugRootType ?? null; + } + if (tag === T.HostComponent) return typeof type === 'string' ? type : null; + if (tag === T.HostText) return null; + if (tag === T.Fragment) return 'Fragment'; + if (tag === T.LazyComponent) return 'Lazy'; + if (tag === T.SuspenseComponent) return 'Suspense'; + if (tag === T.SuspenseListComponent) return 'SuspenseList'; + if (tag === T.Profiler) return 'Profiler'; + if (tag === T.OffscreenComponent) return 'Offscreen'; + if (tag === T.LegacyHiddenComponent) return 'LegacyHidden'; + if (tag === T.CacheComponent) return 'Cache'; + if (tag === T.TracingMarkerComponent) return 'TracingMarker'; + if (tag === T.Mode) return 'Mode'; + if (tag === T.ContextProvider) { + const context = (type as any)?._context ?? type; + return `${(context as any)?.displayName ?? 'Context'}.Provider`; + } + if (tag === T.ContextConsumer) { + return `${(type as any)?.displayName ?? 'Context'}.Consumer`; + } + + // Tags that CANNOT exist in r18 — hard error to surface misconfiguration + if (tag === 26 || tag === 27 || tag === 28 || tag === 29 || tag === 30 || tag === 31) { + throw new Error( + `[react-adapters/r18] getDisplayName called with tag=${tag} which does not exist in React 18. ` + + 'Wrong adapter selected — check version detection.', + ); + } + + return null; +} + +// --------------------------------------------------------------------------- +// Adapter export +// --------------------------------------------------------------------------- +export const r18Adapter: ReactVersionAdapter = { + FIBER_TAGS: R18_FIBER_TAGS, + + getFiberTag(fiber) { + return fiber.tag; + }, + + getDisplayName, + + getHookValues(fiber) { + return walkHookLinkedList(fiber.memoizedState); + }, + + walkFiber(fiber, visitor) { + walkFiberImpl(fiber, visitor); + }, + + /** + * r18: renderer.injectProfilingHooks IS present. + * Source: https://github.com/facebook/react/blob/f1338f8080/packages/react-devtools-shared/src/backend/types.js + * + * IMPORTANT: Check ProfilingConflict before calling — react-scan occupies this + * channel via hard replace and must not be overwritten. + */ + injectProfilingHooks(renderer: ReactRenderer, hooks: DevToolsProfilingHooks): true | null { + if (typeof renderer.injectProfilingHooks !== 'function') { + // Guard: some r18 builds (e.g. react-dom/server) may not expose this + return null; + } + renderer.injectProfilingHooks(hooks); + return true; + }, + + supportsPerformanceTracks: false, +}; diff --git a/src/inject/react-adapters/r19.ts b/src/inject/react-adapters/r19.ts new file mode 100644 index 0000000..7834b27 --- /dev/null +++ b/src/inject/react-adapters/r19.ts @@ -0,0 +1,182 @@ +/** + * React 19.0 / 19.1 adapter + * + * Tag source: https://github.com/facebook/react/blob/v19.0.0/packages/react-reconciler/src/ReactWorkTags.js + * SHA: 7aa5dda3b3e4c2baa905a59b922ae7ec14734b24 + * + * Key differences from r18: + * - IndeterminateComponent (2) REMOVED — any tag===2 now means nothing + * - HostHoistable=26 added (new element category for hoistable //) + * - HostSingleton=27 added (new element category for <html>/<head>/<body>) + * - IncompleteFunctionComponent=28 added + * - Throw=29 added + * - hook.supportsFlight: NOW PRESENT + * - hook.backends: Map<string, DevToolsBackend> added + * - ReactFiberDevToolsHook.js: injectProfilingHooks now exported from reconciler side + * + * Key differences from r19.2: + * - ViewTransitionComponent (30), ActivityComponent (31): absent + * - supportsPerformanceTracks: false + * (PROFILING_FLAG_PERFORMANCE_TRACKS_SUPPORT gated on gte('19.2.0')) + * Source: https://github.com/facebook/react/blob/05ca66ad9c/packages/react-devtools-shared/src/backend/fiber/renderer.js#L486 + */ + +import type { + ReactVersionAdapter, + FiberTagMap, + FiberNode, + ReactRenderer, + DevToolsProfilingHooks, +} from './types'; +import { walkFiberImpl, walkHookLinkedList } from './utils'; + +// --------------------------------------------------------------------------- +// Fiber tags — React 19.0.x / 19.1.x +// Source: https://github.com/facebook/react/blob/7aa5dda3b3/packages/react-reconciler/src/ReactWorkTags.js +// --------------------------------------------------------------------------- +export const R19_FIBER_TAGS: FiberTagMap = { + FunctionComponent: 0, + ClassComponent: 1, + IndeterminateComponent: null, // REMOVED in r19 (was 2) + HostRoot: 3, + HostPortal: 4, + HostComponent: 5, + HostText: 6, + Fragment: 7, + Mode: 8, + ContextConsumer: 9, + ContextProvider: 10, + ForwardRef: 11, + Profiler: 12, + SuspenseComponent: 13, + MemoComponent: 14, + SimpleMemoComponent: 15, + LazyComponent: 16, + IncompleteClassComponent: 17, + DehydratedFragment: 18, + SuspenseListComponent: 19, + ScopeComponent: 21, + OffscreenComponent: 22, + LegacyHiddenComponent: 23, + CacheComponent: 24, + TracingMarkerComponent: 25, + HostHoistable: 26, // new in r19.0 + HostSingleton: 27, // new in r19.0 + IncompleteFunctionComponent: 28, // new in r19.0 + Throw: 29, // new in r19.0 + ViewTransitionComponent: null, // not in r19.0/r19.1 + ActivityComponent: null, // not in r19.0/r19.1 +}; + +// --------------------------------------------------------------------------- +// getDisplayName — r19 tag set +// Source pattern: https://github.com/facebook/react/blob/05ca66ad9c/packages/react-devtools-shared/src/backend/fiber/shared/DevToolsFiberInternalReactConstants.js#L356 +// --------------------------------------------------------------------------- +function getDisplayName(fiber: FiberNode): string | null { + const { tag, type, elementType } = fiber; + const T = R19_FIBER_TAGS; + + if ( + tag === T.FunctionComponent || + tag === T.ClassComponent || + tag === T.IncompleteClassComponent || + tag === T.IncompleteFunctionComponent + ) { + return (type as any)?.displayName ?? (type as any)?.name ?? null; + } + if (tag === T.ForwardRef) { + const inner = (type as any)?.render ?? type; + const outer = elementType; + return ( + (outer as any)?.displayName ?? + `ForwardRef(${(inner as any)?.displayName ?? (inner as any)?.name ?? 'Anonymous'})` + ); + } + if (tag === T.MemoComponent || tag === T.SimpleMemoComponent) { + const inner = (type as any)?.type ?? type; + return ( + (elementType as any)?.displayName ?? + (inner as any)?.displayName ?? + (inner as any)?.name ?? + null + ); + } + if (tag === T.HostRoot) { + return fiber.stateNode?._debugRootType ?? null; + } + // HostHoistable and HostSingleton: type is a string tag name + if (tag === T.HostComponent || tag === T.HostHoistable || tag === T.HostSingleton) { + return typeof type === 'string' ? type : null; + } + if (tag === T.HostText) return null; + if (tag === T.Fragment) return 'Fragment'; + if (tag === T.LazyComponent) return 'Lazy'; + if (tag === T.SuspenseComponent) return 'Suspense'; + if (tag === T.SuspenseListComponent) return 'SuspenseList'; + if (tag === T.Profiler) return 'Profiler'; + if (tag === T.OffscreenComponent) return 'Offscreen'; + if (tag === T.LegacyHiddenComponent) return 'LegacyHidden'; + if (tag === T.CacheComponent) return 'Cache'; + if (tag === T.TracingMarkerComponent) return 'TracingMarker'; + if (tag === T.Throw) return 'Throw'; + if (tag === T.Mode) return 'Mode'; + if (tag === T.ContextProvider) { + const context = (type as any)?._context ?? type; + return `${(context as any)?.displayName ?? 'Context'}.Provider`; + } + if (tag === T.ContextConsumer) { + return `${(type as any)?.displayName ?? 'Context'}.Consumer`; + } + + // Tags that CANNOT exist in r19.0/r19.1 — hard error to surface misconfiguration + if (tag === 30 || tag === 31) { + throw new Error( + `[react-adapters/r19] getDisplayName called with tag=${tag} (ViewTransition/Activity) ` + + 'which only exists in React 19.2+. Wrong adapter selected — check version detection.', + ); + } + + return null; +} + +// --------------------------------------------------------------------------- +// Adapter export +// --------------------------------------------------------------------------- +export const r19Adapter: ReactVersionAdapter = { + FIBER_TAGS: R19_FIBER_TAGS, + + getFiberTag(fiber) { + return fiber.tag; + }, + + getDisplayName, + + getHookValues(fiber) { + return walkHookLinkedList(fiber.memoizedState); + }, + + walkFiber(fiber, visitor) { + walkFiberImpl(fiber, visitor); + }, + + /** + * r19.0: renderer.injectProfilingHooks is present. + * Additionally, ReactFiberDevToolsHook.js now exports injectProfilingHooks + * on the reconciler side (new in r19.0 vs r18 where it was renderer-side only). + * Source: https://github.com/facebook/react/blob/7aa5dda3b3/packages/react-reconciler/src/ReactFiberDevToolsHook.js + */ + injectProfilingHooks(renderer: ReactRenderer, hooks: DevToolsProfilingHooks): true | null { + if (typeof renderer.injectProfilingHooks !== 'function') { + return null; + } + renderer.injectProfilingHooks(hooks); + return true; + }, + + /** + * false for 19.0/19.1 — PROFILING_FLAG_PERFORMANCE_TRACKS_SUPPORT is only set + * when gte(version, '19.2.0'). + * Source: https://github.com/facebook/react/blob/05ca66ad9c/packages/react-devtools-shared/src/backend/fiber/renderer.js#L486 + */ + supportsPerformanceTracks: false, +}; diff --git a/src/inject/react-adapters/r19_2.ts b/src/inject/react-adapters/r19_2.ts new file mode 100644 index 0000000..d808916 --- /dev/null +++ b/src/inject/react-adapters/r19_2.ts @@ -0,0 +1,168 @@ +/** + * React 19.2+ adapter + * + * Tag source: https://github.com/facebook/react/blob/v19.2.0/packages/react-reconciler/src/ReactWorkTags.js + * SHA: 7180fba91b1943c77523b818f9b42053ab350aff + * Current main (≈v19.3-dev): https://github.com/facebook/react/blob/05ca66ad9c/packages/react-reconciler/src/ReactWorkTags.js + * + * Key differences from r19.0/r19.1: + * - ViewTransitionComponent=30 added + * - ActivityComponent=31 added + * - supportsPerformanceTracks: TRUE + * PROFILING_FLAG_PERFORMANCE_TRACKS_SUPPORT=0b100 gated on gte('19.2.0') + * Source: https://github.com/facebook/react/blob/05ca66ad9c/packages/react-devtools-shared/src/backend/fiber/renderer.js#L486 + * - renderer.scheduleRetry added + * Source: https://github.com/facebook/react/blob/05ca66ad9c/packages/react-devtools-shared/src/backend/types.js#L194 + */ + +import type { + ReactVersionAdapter, + FiberTagMap, + FiberNode, + ReactRenderer, + DevToolsProfilingHooks, +} from './types'; +import { walkFiberImpl, walkHookLinkedList } from './utils'; + +// --------------------------------------------------------------------------- +// Fiber tags — React 19.2+ +// Source: https://github.com/facebook/react/blob/7180fba91b/packages/react-reconciler/src/ReactWorkTags.js +// --------------------------------------------------------------------------- +export const R19_2_FIBER_TAGS: FiberTagMap = { + FunctionComponent: 0, + ClassComponent: 1, + IndeterminateComponent: null, // removed in r19 + HostRoot: 3, + HostPortal: 4, + HostComponent: 5, + HostText: 6, + Fragment: 7, + Mode: 8, + ContextConsumer: 9, + ContextProvider: 10, + ForwardRef: 11, + Profiler: 12, + SuspenseComponent: 13, + MemoComponent: 14, + SimpleMemoComponent: 15, + LazyComponent: 16, + IncompleteClassComponent: 17, + DehydratedFragment: 18, + SuspenseListComponent: 19, + ScopeComponent: 21, + OffscreenComponent: 22, + LegacyHiddenComponent: 23, + CacheComponent: 24, + TracingMarkerComponent: 25, + HostHoistable: 26, + HostSingleton: 27, + IncompleteFunctionComponent: 28, + Throw: 29, + ViewTransitionComponent: 30, // new in r19.2 + ActivityComponent: 31, // new in r19.2 +}; + +// --------------------------------------------------------------------------- +// getDisplayName — r19.2 tag set +// Source pattern: https://github.com/facebook/react/blob/05ca66ad9c/packages/react-devtools-shared/src/backend/fiber/shared/DevToolsFiberInternalReactConstants.js#L356 +// --------------------------------------------------------------------------- +function getDisplayName(fiber: FiberNode): string | null { + const { tag, type, elementType } = fiber; + const T = R19_2_FIBER_TAGS; + + if ( + tag === T.FunctionComponent || + tag === T.ClassComponent || + tag === T.IncompleteClassComponent || + tag === T.IncompleteFunctionComponent + ) { + return (type as any)?.displayName ?? (type as any)?.name ?? null; + } + if (tag === T.ForwardRef) { + const inner = (type as any)?.render ?? type; + const outer = elementType; + return ( + (outer as any)?.displayName ?? + `ForwardRef(${(inner as any)?.displayName ?? (inner as any)?.name ?? 'Anonymous'})` + ); + } + if (tag === T.MemoComponent || tag === T.SimpleMemoComponent) { + const inner = (type as any)?.type ?? type; + return ( + (elementType as any)?.displayName ?? + (inner as any)?.displayName ?? + (inner as any)?.name ?? + null + ); + } + if (tag === T.HostRoot) { + return fiber.stateNode?._debugRootType ?? null; + } + if (tag === T.HostComponent || tag === T.HostHoistable || tag === T.HostSingleton) { + return typeof type === 'string' ? type : null; + } + if (tag === T.HostText) return null; + if (tag === T.Fragment) return 'Fragment'; + if (tag === T.LazyComponent) return 'Lazy'; + if (tag === T.SuspenseComponent) return 'Suspense'; + if (tag === T.SuspenseListComponent) return 'SuspenseList'; + if (tag === T.Profiler) return 'Profiler'; + if (tag === T.OffscreenComponent) return 'Offscreen'; + if (tag === T.LegacyHiddenComponent) return 'LegacyHidden'; + if (tag === T.CacheComponent) return 'Cache'; + if (tag === T.TracingMarkerComponent) return 'TracingMarker'; + if (tag === T.Throw) return 'Throw'; + if (tag === T.ViewTransitionComponent) return 'ViewTransition'; + if (tag === T.ActivityComponent) return 'Activity'; + if (tag === T.Mode) return 'Mode'; + if (tag === T.ContextProvider) { + const context = (type as any)?._context ?? type; + return `${(context as any)?.displayName ?? 'Context'}.Provider`; + } + if (tag === T.ContextConsumer) { + return `${(type as any)?.displayName ?? 'Context'}.Consumer`; + } + + return null; +} + +// --------------------------------------------------------------------------- +// Adapter export +// --------------------------------------------------------------------------- +export const r19_2Adapter: ReactVersionAdapter = { + FIBER_TAGS: R19_2_FIBER_TAGS, + + getFiberTag(fiber) { + return fiber.tag; + }, + + getDisplayName, + + getHookValues(fiber) { + return walkHookLinkedList(fiber.memoizedState); + }, + + walkFiber(fiber, visitor) { + walkFiberImpl(fiber, visitor); + }, + + injectProfilingHooks(renderer: ReactRenderer, hooks: DevToolsProfilingHooks): true | null { + if (typeof renderer.injectProfilingHooks !== 'function') { + return null; + } + renderer.injectProfilingHooks(hooks); + return true; + }, + + /** + * TRUE for r19.2+ — DevTools sets PROFILING_FLAG_PERFORMANCE_TRACKS_SUPPORT (0b100) + * enabling Performance API tracks-based profiling timeline. + * + * Detection: `(profilingFlags & PROFILING_FLAG_PERFORMANCE_TRACKS_SUPPORT) !== 0` + * Or equivalently: semver gte(rendererVersion, '19.2.0') + * + * Source: https://github.com/facebook/react/blob/05ca66ad9c/packages/react-devtools-shared/src/backend/fiber/renderer.js#L486 + * Constant: https://github.com/facebook/react/blob/05ca66ad9c/packages/react-devtools-shared/src/constants.js#L36 + */ + supportsPerformanceTracks: true, +}; diff --git a/src/inject/react-adapters/types.ts b/src/inject/react-adapters/types.ts new file mode 100644 index 0000000..cae1576 --- /dev/null +++ b/src/inject/react-adapters/types.ts @@ -0,0 +1,227 @@ +/** + * React Version Adapter types + * + * Authoritative sources: + * - ReactWorkTags: https://github.com/facebook/react/blob/05ca66ad9c/packages/react-reconciler/src/ReactWorkTags.js + * - DevToolsHook type: https://github.com/facebook/react/blob/05ca66ad9c/packages/react-devtools-shared/src/backend/types.js#L250 + * - DevToolsProfilingHooks: https://github.com/facebook/react/blob/05ca66ad9c/packages/react-devtools-shared/src/backend/types.js#L55 + * - PROFILING_FLAG_*: https://github.com/facebook/react/blob/05ca66ad9c/packages/react-devtools-shared/src/constants.js#L34 + */ + +// --------------------------------------------------------------------------- +// Fiber tag maps — one per React minor range +// Numbers are STABLE within each range but SHIFT across ranges (see table below). +// +// CRITICAL collision: +// OffscreenComponent: r17=23, r18+=22 +// LegacyHiddenComponent: r17=24, r18+=23 +// IndeterminateComponent: r17/r18=2, r19+=REMOVED +// --------------------------------------------------------------------------- + +/** Fiber tags valid for React 17.x */ +export interface FiberTagMap { + FunctionComponent: number; + ClassComponent: number; + IndeterminateComponent: number | null; // null = removed in this version + HostRoot: number; + HostPortal: number; + HostComponent: number; + HostText: number; + Fragment: number; + Mode: number; + ContextConsumer: number; + ContextProvider: number; + ForwardRef: number; + Profiler: number; + SuspenseComponent: number; + MemoComponent: number; + SimpleMemoComponent: number; + LazyComponent: number; + IncompleteClassComponent: number; + DehydratedFragment: number; + SuspenseListComponent: number; + ScopeComponent: number; + OffscreenComponent: number; + LegacyHiddenComponent: number; + CacheComponent: number | null; + TracingMarkerComponent: number | null; + HostHoistable: number | null; // r19.0+ + HostSingleton: number | null; // r19.0+ + IncompleteFunctionComponent: number | null; // r19.0+ + Throw: number | null; // r19.0+ + ViewTransitionComponent: number | null; // r19.2+ + ActivityComponent: number | null; // r19.2+ +} + +// --------------------------------------------------------------------------- +// Minimal fiber shape — only fields we read; stable across r17–r19.2 +// Source: https://github.com/facebook/react/blob/05ca66ad9c/packages/react-reconciler/src/ReactFiberHooks.js#L194 +// --------------------------------------------------------------------------- + +export interface FiberNode { + tag: number; + key: string | null; + type: any; + elementType: any; + stateNode: any; + return: FiberNode | null; + child: FiberNode | null; + sibling: FiberNode | null; + alternate: FiberNode | null; + memoizedState: any; + memoizedProps: any; + pendingProps: any; + flags: number; + /** @deprecated pre-r17 alias for flags */ + effectTag?: number; + actualDuration?: number; + selfBaseDuration?: number; + treeBaseDuration?: number; + updateQueue: any; +} + +/** Stable Hook struct shape across r17–r19.2 + * Source: https://github.com/facebook/react/blob/v17.0.2/packages/react-reconciler/src/ReactFiberHooks.old.js + * https://github.com/facebook/react/blob/05ca66ad9c/packages/react-reconciler/src/ReactFiberHooks.js#L194 + */ +export interface FiberHook { + memoizedState: any; + baseState: any; + baseQueue: any | null; + queue: any | null; + next: FiberHook | null; +} + +export interface FiberRoot { + current: FiberNode; +} + +// --------------------------------------------------------------------------- +// ReactRenderer — the object React passes to hook.inject(renderer) +// Source: https://github.com/facebook/react/blob/05ca66ad9c/packages/react-devtools-shared/src/backend/types.js#L131 +// --------------------------------------------------------------------------- +export interface ReactRenderer { + version: string; + reconcilerVersion?: string; + /** present in r18+ */ + injectProfilingHooks?: (hooks: DevToolsProfilingHooks) => void; + /** present in r18+ */ + getLaneLabelMap?: () => Map<number, string> | null; + /** present in r19.2+ */ + scheduleRetry?: (fiber: object) => void; +} + +// --------------------------------------------------------------------------- +// DevToolsProfilingHooks — the object passed to renderer.injectProfilingHooks() +// Source: https://github.com/facebook/react/blob/05ca66ad9c/packages/react-devtools-shared/src/backend/types.js#L55 +// --------------------------------------------------------------------------- +export interface DevToolsProfilingHooks { + markComponentRenderStarted: (fiber: FiberNode) => void; + markComponentRenderStopped: () => void; + markComponentPassiveEffectMountStarted: (fiber: FiberNode) => void; + markComponentPassiveEffectMountStopped: () => void; + markComponentPassiveEffectUnmountStarted: (fiber: FiberNode) => void; + markComponentPassiveEffectUnmountStopped: () => void; + markComponentLayoutEffectMountStarted: (fiber: FiberNode) => void; + markComponentLayoutEffectMountStopped: () => void; + markComponentLayoutEffectUnmountStarted: (fiber: FiberNode) => void; + markComponentLayoutEffectUnmountStopped: () => void; + markRenderStarted: (lanes: number) => void; + markRenderYielded: () => void; + markRenderStopped: () => void; + markCommitStarted: (lanes: number) => void; + markCommitStopped: () => void; + markLayoutEffectsStarted: (lanes: number) => void; + markLayoutEffectsStopped: () => void; + markPassiveEffectsStarted: (lanes: number) => void; + markPassiveEffectsStopped: () => void; +} + +// --------------------------------------------------------------------------- +// PROFILING_FLAG_* bitmask constants +// Source: https://github.com/facebook/react/blob/05ca66ad9c/packages/react-devtools-shared/src/constants.js#L34 +// --------------------------------------------------------------------------- +export const PROFILING_FLAG_BASIC_SUPPORT = 0b001; +export const PROFILING_FLAG_TIMELINE_SUPPORT = 0b010; +/** Only set when React version >= 19.2.0 */ +export const PROFILING_FLAG_PERFORMANCE_TRACKS_SUPPORT = 0b100; + +// --------------------------------------------------------------------------- +// Conflict detection signals +// --------------------------------------------------------------------------- +export interface ProfilingConflict { + /** window.__REACT_SCAN__.ReactScanInternals is present + * → react-scan has called renderer.injectProfilingHooks() — hard replace, do NOT re-inject + * Source: https://github.com/aidenybai/react-scan/blob/main/packages/scan/src/types.ts + */ + reactScan: boolean; + /** 'getFiberRoots' in window.__REACT_DEVTOOLS_GLOBAL_HOOK__ + * → Real React DevTools profiler is attached (not just Fast Refresh stub) + * Source: https://github.com/aidenybai/bippy/blob/main/packages/bippy/src/rdt-hook.ts + */ + reactDevTools: boolean; +} + +// --------------------------------------------------------------------------- +// Adapter interface — one implementation per React version range +// --------------------------------------------------------------------------- +export interface ReactVersionAdapter { + /** Fiber tag numeric map for this version range */ + readonly FIBER_TAGS: FiberTagMap; + + /** + * Returns fiber.tag (pass-through; adapter provides the correct FIBER_TAGS map). + * Stable: fiber.tag has been a numeric field since React 16. + */ + getFiberTag(fiber: FiberNode): number; + + /** + * Returns display name for the fiber, or null for host/unknown fibers. + * Algorithm stable since r16; tag cases differ per version. + * + * - Uses `throw new Error('not implemented')` only for fibers that CANNOT + * exist in this version range. + * - Returns null for all other unrecognised tags (graceful degradation). + */ + getDisplayName(fiber: FiberNode): string | null; + + /** + * Returns the linked list of Hook structs for a function component fiber. + * Hook struct shape (memoizedState/baseState/baseQueue/queue/next) is STABLE + * across r17–r19.2. + * Returns [] for class components and non-component fibers. + * + * NOTE: Never throws — degrade gracefully with empty array on unexpected input. + */ + getHookValues(fiber: FiberNode): FiberHook[]; + + /** + * Depth-first fiber tree walk. Calls visitor for each fiber encountered. + * fiber.child / fiber.sibling / fiber.return are STABLE across all versions. + * + * NOTE: Never throws — skip unreachable fibers silently. + */ + walkFiber(fiber: FiberNode, visitor: (fiber: FiberNode) => void): void; + + /** + * Calls renderer.injectProfilingHooks(hooks) on the given renderer object. + * Returns true if successfully injected, null if not supported in this version. + * + * r17: always returns null (method doesn't exist on renderer). + * r18+: calls renderer.injectProfilingHooks if present. + * + * IMPORTANT: Check ProfilingConflict BEFORE calling — if reactScan is active, + * the channel is already occupied and re-injection will clobber react-scan. + */ + injectProfilingHooks( + renderer: ReactRenderer, + hooks: DevToolsProfilingHooks, + ): true | null; + + /** + * Whether this React version supports Performance Timeline tracks for profiling. + * True only for r19.2+. + * Source: https://github.com/facebook/react/blob/05ca66ad9c/packages/react-devtools-shared/src/backend/fiber/renderer.js#L486 + */ + readonly supportsPerformanceTracks: boolean; +} diff --git a/src/inject/react-adapters/utils.ts b/src/inject/react-adapters/utils.ts new file mode 100644 index 0000000..18f0ab2 --- /dev/null +++ b/src/inject/react-adapters/utils.ts @@ -0,0 +1,81 @@ +/** + * Shared utilities for all React version adapters. + * These operations are STABLE across r17–r19.2 — no version branching needed here. + */ + +import type { FiberNode, FiberHook } from './types'; + +/** + * Walk a fiber tree depth-first, calling visitor on each node. + * Uses fiber.child (descend) and fiber.sibling (breadth iteration). + * + * fiber.child / fiber.sibling / fiber.return have been stable since React 16. + * Source: https://github.com/facebook/react/blob/05ca66ad9c/packages/react-reconciler/src/ReactFiber.js + * + * Limits: max 5000 nodes to prevent infinite loops on pathological trees. + * Never throws — silently skips unreachable fibers. + */ +export function walkFiberImpl( + root: FiberNode, + visitor: (fiber: FiberNode) => void, + maxNodes = 5000, +): void { + let count = 0; + // Iterative DFS using an explicit stack to avoid call-stack overflow on deep trees + const stack: FiberNode[] = [root]; + + while (stack.length > 0 && count < maxNodes) { + const fiber = stack.pop()!; + count++; + + try { + visitor(fiber); + } catch { + // visitor errors must not abort the walk + } + + // Push sibling first so child is processed before sibling (DFS pre-order) + if (fiber.sibling !== null) stack.push(fiber.sibling); + if (fiber.child !== null) stack.push(fiber.child); + } +} + +/** + * Walk the memoizedState linked list of a function component fiber + * and return all Hook structs as an array. + * + * The Hook struct shape is STABLE across r17–r19.2: + * { memoizedState, baseState, baseQueue, queue, next } + * Source: https://github.com/facebook/react/blob/v17.0.2/packages/react-reconciler/src/ReactFiberHooks.old.js + * https://github.com/facebook/react/blob/05ca66ad9c/packages/react-reconciler/src/ReactFiberHooks.js#L194 + * + * NOTE: memoizedState SEMANTICS vary by hook type — this returns raw structs only. + * useState/useReducer: hook.memoizedState = current value + * useRef: hook.memoizedState = { current: ... } + * useMemo/useCallback: hook.memoizedState = [value, deps] + * useEffect: hook.memoizedState = Effect object { tag, create, destroy, deps, next } + * useContext: hook.memoizedState = context value (no queue) + * + * Returns [] for class components, host components, and anything without a + * hook linked list. + * + * Never throws. + */ +export function walkHookLinkedList(firstHook: any): FiberHook[] { + const hooks: FiberHook[] = []; + if (firstHook === null || firstHook === undefined) return hooks; + + // Sanity check: a Hook must have a `next` property (may be null) + // This distinguishes a Hook from arbitrary memoizedState objects (e.g. class component state) + if (typeof firstHook !== 'object' || !('next' in firstHook)) return hooks; + + let current: FiberHook | null = firstHook as FiberHook; + let limit = 1000; // guard against circular lists + + while (current !== null && limit-- > 0) { + hooks.push(current); + current = current.next; + } + + return hooks; +} diff --git a/src/inject/registry.ts b/src/inject/registry.ts new file mode 100644 index 0000000..caf8d7c --- /dev/null +++ b/src/inject/registry.ts @@ -0,0 +1,338 @@ +import type { + Detector, + DetectorContext, + DetectorId, + MCPTabCategory, +} from '../types/registry'; + +export type { Detector, DetectorContext, DetectorId, MCPTabCategory }; + +/** + * Options accepted by `createRegistry`. `emit` / `log` / `sanitize` are wired + * into every `DetectorContext` constructed on `register()`. The factory MUST + * NOT reach back into `src/inject/index.ts` — all host wiring flows through + * these injected callables (T2 will assemble them on the inject side). + */ +export interface CreateRegistryOptions { + emit: (payload: unknown) => void; + log: (...args: unknown[]) => void; + sanitize: (v: unknown) => unknown; + performance?: { now(): number }; + dedupeCapDefault?: number; +} + +/** + * Summary entry returned by `registry.list()`. Surfaces enough metadata for + * the Settings UI and CI bench harness without exposing detector internals. + */ +export interface RegistryListEntry { + id: DetectorId; + category: MCPTabCategory; + confidence: 'high' | 'medium' | 'low'; + enabled: boolean; + disabledReason?: string; +} + +/** + * Result entry from `registry.drainAll()`. One element per active detector + * (regardless of whether it produced issues this cycle). + */ +export interface DrainAllEntry { + detectorId: DetectorId; + issues: unknown[]; +} + +/** + * Public shape of the registry returned by `createRegistry`. T2 will wire + * a singleton of this into the inject script. + */ +export interface Registry { + register(detector: Detector): void; + unregister(id: DetectorId): void; + enable(id: DetectorId): void; + disable(id: DetectorId): void; + list(): RegistryListEntry[]; + dispatch(commit: { fiberRoot: unknown }): void; + /** + * Fire each active detector's `onIdle(deadline)` hook (T9). Detectors that + * don't define `onIdle` are skipped. The caller is responsible for + * scheduling — typically via `requestIdleCallback` from the inject script. + * + * Same try/catch isolation as `dispatch`: a throwing detector is disabled + * for the session and its peers run regardless. Staged writes from a + * thrown `onIdle` are discarded; staged writes from a successful one + * commit on return. + */ + dispatchIdle(deadline: IdleDeadline): void; + drainAll(): DrainAllEntry[]; +} + +const DEFAULT_DEDUPE_CAP = 1000; + +/** + * Bounded LRU. Insertion-ordered `Map` is the LRU: on every `has` / `add` + * we delete-then-reinsert the key so it moves to the most-recent slot, and + * when capacity is exceeded we evict the iterator's first (oldest) entry. + * + * Five-line trick rather than a dependency; we use no values, only key + * recency, so a `Set` would do — `Map` is used because its `keys()` + * iterator is well-specified to be insertion-ordered. + */ +class DedupeLRU { + private readonly capacity: number; + private readonly seen = new Map<string, true>(); + + constructor(capacity: number) { + this.capacity = capacity > 0 ? capacity : DEFAULT_DEDUPE_CAP; + } + + /** + * Returns `true` the first time `key` is offered, `false` thereafter. + * Every call (hit or miss) bumps the key to most-recent. + */ + offer(key: string): boolean { + if (this.seen.has(key)) { + this.seen.delete(key); + this.seen.set(key, true); + return false; + } + this.seen.set(key, true); + if (this.seen.size > this.capacity) { + const oldest = this.seen.keys().next().value as string | undefined; + if (oldest !== undefined) this.seen.delete(oldest); + } + return true; + } + + clear(): void { + this.seen.clear(); + } +} + +/** + * Per-detector state held by the registry. `persistentStore`, `stagingBuffer`, + * and `dedupe` are captured by the matching `ctx` closure built in + * `makeEntry` — they are exposed on the entry only so the lifecycle helpers + * (`commitStaging`, `discardStaging`, `unregister`) can act on them directly. + */ +interface DetectorEntry { + detector: Detector; + ctx: DetectorContext; + persistentStore: Map<string, unknown>; + stagingBuffer: Map<string, unknown>; + dedupe: DedupeLRU; + enabled: boolean; + disabledReason?: string; +} + +/** + * Create a detector registry. + * + * The registry is the inject-script's coordinator for detector lifecycle, + * per-detector error isolation, staged writes, and bounded dedupe. T2 wires + * the singleton into `src/inject/index.ts`; this factory is intentionally + * decoupled so the contract can be unit-tested in isolation. + * + * Example wiring (T2 will own this in `src/inject/index.ts`): + * ```ts + * const registry = createRegistry({ + * emit: (payload) => window.postMessage({ source: 'react-debugger', payload }, '*'), + * log: (...args) => console.debug('[react-debugger]', ...args), + * sanitize: sanitizeValue, + * performance: window.performance, + * }); + * registry.register(reconcilerKeysDetector); + * // ...later, inside onCommitFiberRoot: + * registry.dispatch({ fiberRoot }); + * // ...and inside POLL_DATA: + * const all = registry.drainAll(); + * ``` + */ +export function createRegistry(options: CreateRegistryOptions): Registry { + const perf: { now(): number } = + options.performance ?? { now: () => Date.now() }; + const dedupeCapDefault = options.dedupeCapDefault ?? DEFAULT_DEDUPE_CAP; + const entries = new Map<DetectorId, DetectorEntry>(); + + function makeEntry(detector: Detector): DetectorEntry { + const persistentStore = new Map<string, unknown>(); + const stagingBuffer = new Map<string, unknown>(); + const dedupe = new DedupeLRU(dedupeCapDefault); + + const ctx: DetectorContext = { + emit: (payload) => options.emit(payload), + log: (...args) => options.log(...args), + sanitize: (v) => options.sanitize(v), + performance: perf, + dedupe: (key) => dedupe.offer(key), + write: <T>(key: string, value: T) => { + stagingBuffer.set(key, value); + }, + read: <T>(key: string): T | undefined => { + if (persistentStore.has(key)) { + return persistentStore.get(key) as T; + } + return undefined; + }, + }; + + return { + detector, + ctx, + persistentStore, + stagingBuffer, + dedupe, + enabled: true, + }; + } + + function commitStaging(entry: DetectorEntry): void { + if (entry.stagingBuffer.size === 0) return; + for (const [k, v] of entry.stagingBuffer) { + entry.persistentStore.set(k, v); + } + entry.stagingBuffer.clear(); + } + + function discardStaging(entry: DetectorEntry): void { + entry.stagingBuffer.clear(); + } + + function runLifecycle( + entry: DetectorEntry, + phase: 'onCommit' | 'onIdle' | 'drain', + invoke: () => void, + ): boolean { + try { + invoke(); + commitStaging(entry); + return true; + } catch (err) { + discardStaging(entry); + const errorMessage = err instanceof Error ? err.message : String(err); + try { + entry.detector.recover?.(); + } catch (recoverErr) { + // recover() must never break isolation, but DO surface the failure + // through the injected log so operators can diagnose it. + try { + options.log( + '[registry] recover() threw for', + entry.detector.id, + recoverErr, + ); + } catch { + // log throwing is exceedingly unlikely; preserve disable-marking. + } + } + entry.enabled = false; + entry.disabledReason = errorMessage; + try { + options.emit({ + type: 'DETECTOR_DISABLED', + detectorId: entry.detector.id, + error: errorMessage, + phase, + }); + } catch { + // emit failure must not propagate. + } + return false; + } + } + + return { + register(detector: Detector): void { + if (entries.has(detector.id)) { + throw new Error( + `Detector with id '${detector.id}' is already registered`, + ); + } + const entry = makeEntry(detector); + entries.set(detector.id, entry); + detector.init(entry.ctx); + }, + + unregister(id: DetectorId): void { + const entry = entries.get(id); + if (!entry) return; + try { + entry.detector.teardown(); + } catch { + // teardown errors are swallowed: caller has already decided to drop. + } + entry.dedupe.clear(); + entries.delete(id); + }, + + enable(id: DetectorId): void { + const entry = entries.get(id); + if (!entry) return; + entry.enabled = true; + entry.disabledReason = undefined; + }, + + disable(id: DetectorId): void { + const entry = entries.get(id); + if (!entry) return; + entry.enabled = false; + if (entry.disabledReason === undefined) { + entry.disabledReason = 'manually disabled'; + } + }, + + list(): RegistryListEntry[] { + const out: RegistryListEntry[] = []; + for (const entry of entries.values()) { + const listed: RegistryListEntry = { + id: entry.detector.id, + category: entry.detector.category, + confidence: entry.detector.confidence, + enabled: entry.enabled, + }; + if (entry.disabledReason !== undefined) { + listed.disabledReason = entry.disabledReason; + } + out.push(listed); + } + return out; + }, + + dispatch(commit: { fiberRoot: unknown }): void { + for (const entry of entries.values()) { + if (!entry.enabled) continue; + if (!entry.detector.onCommit) continue; + const deadline = perf.now() + entry.detector.budgetMs; + runLifecycle(entry, 'onCommit', () => { + entry.detector.onCommit!(commit.fiberRoot, deadline); + }); + } + }, + + dispatchIdle(deadline: IdleDeadline): void { + for (const entry of entries.values()) { + if (!entry.enabled) continue; + if (!entry.detector.onIdle) continue; + runLifecycle(entry, 'onIdle', () => { + entry.detector.onIdle!(deadline); + }); + } + }, + + drainAll(): DrainAllEntry[] { + const out: DrainAllEntry[] = []; + for (const entry of entries.values()) { + if (!entry.enabled) { + out.push({ detectorId: entry.detector.id, issues: [] }); + continue; + } + let issues: unknown[] = []; + runLifecycle(entry, 'drain', () => { + issues = entry.detector.drain() as unknown[]; + }); + out.push({ detectorId: entry.detector.id, issues }); + } + return out; + }, + }; +} diff --git a/src/panel/Panel.tsx b/src/panel/Panel.tsx index 242b426..c7393c4 100644 --- a/src/panel/Panel.tsx +++ b/src/panel/Panel.tsx @@ -8,8 +8,9 @@ import { ReduxTab } from './tabs/ReduxTab'; import { MemoryTab } from './tabs/MemoryTab'; import { TimelineTab } from './tabs/TimelineTab'; import { AIAnalysisTab } from './tabs/AIAnalysisTab'; +import { SettingsTab } from './tabs/SettingsTab'; -type TabId = 'timeline' | 'ui-state' | 'performance' | 'side-effects' | 'cls' | 'redux' | 'memory' | 'ai-analysis'; +type TabId = 'timeline' | 'ui-state' | 'performance' | 'side-effects' | 'cls' | 'redux' | 'memory' | 'ai-analysis' | 'settings'; function isExtensionContextValid(): boolean { try { @@ -47,6 +48,7 @@ const TABS: TabConfig[] = [ { id: 'cls', label: 'CLS' }, { id: 'redux', label: 'Redux' }, { id: 'ai-analysis', label: 'AI Analysis' }, + { id: 'settings', label: 'Settings' }, ]; const createInitialState = (): TabState => ({ @@ -400,6 +402,8 @@ export function Panel() { ); case 'ai-analysis': return <AIAnalysisTab state={state} />; + case 'settings': + return <SettingsTab />; default: return null; } diff --git a/src/panel/styles/panel.css b/src/panel/styles/panel.css index 70c97e2..7d9f516 100644 --- a/src/panel/styles/panel.css +++ b/src/panel/styles/panel.css @@ -3896,3 +3896,300 @@ body { color: var(--text-secondary); animation: pulse-opacity 1s ease-in-out infinite; } + +/* ─── Settings Tab ─────────────────────────────────────────────────────────── */ + +.settings-tab { + padding: 16px; + overflow-y: auto; + flex: 1; +} + +.settings-loading { + color: var(--text-muted); + font-size: 12px; + padding: 16px; +} + +.settings-section { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 16px; + margin-bottom: 16px; +} + +.settings-section__title { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 4px; +} + +.settings-section__desc { + font-size: 11px; + color: var(--text-secondary); + margin-bottom: 12px; +} + +.settings-empty-hint { + font-size: 11px; + color: var(--text-muted); + font-style: italic; + margin-top: 8px; +} + +/* Detector rows */ + +.detector-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.detector-list--nested { + margin-top: 8px; + padding-left: 8px; + border-left: 2px solid var(--border-subtle); +} + +.detector-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 10px; + background: var(--bg-tertiary); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + gap: 8px; +} + +.detector-row__info { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + min-width: 0; +} + +.detector-row__id { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-primary); + white-space: nowrap; +} + +/* Confidence badges */ + +.confidence-badge { + font-size: 9px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 2px 6px; + border-radius: var(--radius-sm); + flex-shrink: 0; +} + +.confidence-badge--high { + background: rgba(63, 185, 80, 0.15); + color: var(--accent-green); + border: 1px solid rgba(63, 185, 80, 0.3); +} + +.confidence-badge--medium { + background: rgba(210, 153, 34, 0.15); + color: var(--accent-yellow); + border: 1px solid rgba(210, 153, 34, 0.3); +} + +.confidence-badge--low { + background: rgba(248, 81, 73, 0.15); + color: var(--accent-red); + border: 1px solid rgba(248, 81, 73, 0.3); +} + +/* Help icon + tooltip */ + +.detector-row__help-icon { + position: relative; + font-size: 11px; + color: var(--text-secondary); + cursor: help; + flex-shrink: 0; + outline: none; +} + +.detector-row__help-icon:focus-visible { + outline: 1px solid var(--accent-blue); + border-radius: 50%; +} + +.detector-row__tooltip { + position: absolute; + left: 0; + bottom: calc(100% + 6px); + z-index: 100; + width: 240px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + padding: 8px 10px; + font-size: 11px; + color: var(--text-secondary); + font-family: inherit; + line-height: 1.5; + box-shadow: var(--shadow-md); + white-space: normal; + pointer-events: none; +} + +/* Toggle switch */ + +.toggle-switch { + position: relative; + display: inline-flex; + align-items: center; + flex-shrink: 0; + cursor: pointer; +} + +.toggle-switch__input { + position: absolute; + opacity: 0; + width: 0; + height: 0; + pointer-events: none; +} + +.toggle-switch__track { + display: block; + width: 32px; + height: 18px; + background: var(--bg-inset); + border: 1px solid var(--border-color); + border-radius: 9px; + transition: background var(--transition-fast), border-color var(--transition-fast); +} + +.toggle-switch__track::after { + content: ''; + position: absolute; + top: 3px; + left: 3px; + width: 12px; + height: 12px; + background: var(--text-muted); + border-radius: 50%; + transition: transform var(--transition-fast), background var(--transition-fast); +} + +.toggle-switch__input:checked + .toggle-switch__track { + background: rgba(63, 185, 80, 0.25); + border-color: var(--accent-green); +} + +.toggle-switch__input:checked + .toggle-switch__track::after { + background: var(--accent-green); + transform: translateX(14px); +} + +.toggle-switch__input:focus-visible + .toggle-switch__track { + outline: 2px solid var(--accent-blue); + outline-offset: 2px; +} + +/* Per-site overrides */ + +.site-override-add { + display: flex; + gap: 8px; + margin-bottom: 12px; +} + +.site-override-input { + flex: 1; + background: var(--bg-inset); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + padding: 6px 10px; + font-size: 11px; + color: var(--text-primary); + outline: none; + font-family: var(--font-mono); + transition: border-color var(--transition-fast); +} + +.site-override-input:focus { + border-color: var(--accent-blue); +} + +.site-override-input::placeholder { + color: var(--text-muted); +} + +.site-override-add-btn { + background: var(--accent-blue); + color: #fff; + border: none; + border-radius: var(--radius-sm); + padding: 6px 14px; + font-size: 11px; + font-weight: 600; + cursor: pointer; + transition: opacity var(--transition-fast); +} + +.site-override-add-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.site-override-add-btn:not(:disabled):hover { + opacity: 0.85; +} + +.site-override-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.site-override-entry { + background: var(--bg-tertiary); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + padding: 10px; +} + +.site-override-entry__header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; + gap: 8px; +} + +.site-override-entry__origin { + font-family: var(--font-mono); + font-size: 11px; + color: var(--accent-blue); + word-break: break-all; +} + +.site-override-remove-btn { + background: transparent; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + color: var(--accent-red); + font-size: 10px; + padding: 3px 8px; + cursor: pointer; + white-space: nowrap; + transition: background var(--transition-fast); + flex-shrink: 0; +} + +.site-override-remove-btn:hover { + background: rgba(248, 81, 73, 0.1); +} diff --git a/src/panel/tabs/SettingsTab.tsx b/src/panel/tabs/SettingsTab.tsx new file mode 100644 index 0000000..5d1a5a2 --- /dev/null +++ b/src/panel/tabs/SettingsTab.tsx @@ -0,0 +1,255 @@ +import { useState, useEffect } from 'react'; +import { read, write } from '@/settings/storage'; +import { subscribe } from '@/settings/storage'; +import { KNOWN_DETECTORS_DEFAULTS } from '@/settings/migrate'; +import type { Settings } from '@/settings/types'; + +const CLOSURE_LEAK_TOOLTIP = + 'Tracking starts when enabled. Timers created before enable are invisible to the leak detector. Reload the page to capture all timers.'; + +function ConfidenceBadge({ level }: { level: 'high' | 'medium' | 'low' }) { + return ( + <span + className={`confidence-badge confidence-badge--${level}`} + aria-label={`Confidence: ${level}`} + > + {level} + </span> + ); +} + +interface ToggleSwitchProps { + checked: boolean; + onChange: () => void; + id: string; +} + +function ToggleSwitch({ checked, onChange, id }: ToggleSwitchProps) { + return ( + <label className="toggle-switch" htmlFor={id}> + <input + id={id} + type="checkbox" + className="toggle-switch__input" + checked={checked} + onChange={onChange} + /> + <span className="toggle-switch__track" /> + </label> + ); +} + +export function SettingsTab() { + const [settings, setSettings] = useState<Settings | null>(null); + const [newSiteOrigin, setNewSiteOrigin] = useState(''); + const [tooltipVisible, setTooltipVisible] = useState<string | null>(null); + + useEffect(() => { + read().then(setSettings); + }, []); + + useEffect(() => { + const unsubscribe = subscribe((updated) => { + setSettings(updated); + }); + return unsubscribe; + }, []); + + const handleGlobalToggle = async (detectorId: string) => { + if (!settings) return; + const current = settings.detectors[detectorId]; + const currentEnabled = current?.enabled ?? false; + const updated: Settings = { + ...settings, + detectors: { + ...settings.detectors, + [detectorId]: { + ...current, + enabled: !currentEnabled, + }, + }, + }; + setSettings(updated); + await write(updated); + }; + + const handleAddSiteOverride = async () => { + if (!settings || !newSiteOrigin.trim()) return; + const origin = newSiteOrigin.trim(); + const perDetectorOverrides: Record<string, { enabled: boolean }> = {}; + for (const { id } of KNOWN_DETECTORS_DEFAULTS) { + perDetectorOverrides[id] = { enabled: false }; + } + const updated: Settings = { + ...settings, + perSite: { + ...settings.perSite, + [origin]: { detectors: perDetectorOverrides }, + }, + }; + setSettings(updated); + setNewSiteOrigin(''); + await write(updated); + }; + + const handleRemoveSiteOverride = async (origin: string) => { + if (!settings) return; + const { [origin]: _removed, ...rest } = settings.perSite; + const updated: Settings = { ...settings, perSite: rest }; + setSettings(updated); + await write(updated); + }; + + const handleSiteDetectorToggle = async (origin: string, detectorId: string) => { + if (!settings) return; + const site = settings.perSite[origin] ?? { detectors: {} }; + const currentDetectors = site.detectors ?? {}; + const current = currentDetectors[detectorId]; + const currentEnabled = current?.enabled ?? false; + const updated: Settings = { + ...settings, + perSite: { + ...settings.perSite, + [origin]: { + ...site, + detectors: { + ...currentDetectors, + [detectorId]: { ...current, enabled: !currentEnabled }, + }, + }, + }, + }; + setSettings(updated); + await write(updated); + }; + + if (!settings) { + return ( + <div className="tab-panel settings-tab"> + <div className="settings-loading">Loading settings…</div> + </div> + ); + } + + const perSiteEntries = Object.entries(settings.perSite); + + return ( + <div className="tab-panel settings-tab"> + <section className="settings-section"> + <h2 className="settings-section__title">Detectors</h2> + <p className="settings-section__desc"> + Toggle which detectors are active globally. + </p> + + <div className="detector-list"> + {KNOWN_DETECTORS_DEFAULTS.map(({ id, confidence }) => { + const enabled = settings.detectors[id]?.enabled ?? false; + const isClosureLeak = id === 'closure-leak'; + + return ( + <div className="detector-row" key={id}> + <div className="detector-row__info"> + <span className="detector-row__id">{id}</span> + <ConfidenceBadge level={confidence} /> + {isClosureLeak && ( + <span + className="detector-row__help-icon" + role="img" + aria-label="Info" + tabIndex={0} + onMouseEnter={() => setTooltipVisible(id)} + onMouseLeave={() => setTooltipVisible(null)} + onFocus={() => setTooltipVisible(id)} + onBlur={() => setTooltipVisible(null)} + > + ⓘ + {tooltipVisible === id && ( + <span className="detector-row__tooltip" role="tooltip"> + {CLOSURE_LEAK_TOOLTIP} + </span> + )} + </span> + )} + </div> + <ToggleSwitch + id={`detector-toggle-${id}`} + checked={enabled} + onChange={() => handleGlobalToggle(id)} + /> + </div> + ); + })} + </div> + </section> + + <section className="settings-section"> + <h2 className="settings-section__title">Per-Site Overrides</h2> + <p className="settings-section__desc"> + Override detector settings for a specific site. + </p> + + <div className="site-override-add"> + <input + type="url" + className="site-override-input" + placeholder="https://app.example.com" + value={newSiteOrigin} + onChange={(e) => setNewSiteOrigin(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleAddSiteOverride(); + }} + aria-label="Site origin URL" + /> + <button + className="site-override-add-btn" + onClick={handleAddSiteOverride} + disabled={!newSiteOrigin.trim()} + > + Add + </button> + </div> + + {perSiteEntries.length === 0 ? ( + <p className="settings-empty-hint">No per-site overrides configured.</p> + ) : ( + <div className="site-override-list"> + {perSiteEntries.map(([origin, siteConfig]) => ( + <div className="site-override-entry" key={origin}> + <div className="site-override-entry__header"> + <span className="site-override-entry__origin">{origin}</span> + <button + className="site-override-remove-btn" + onClick={() => handleRemoveSiteOverride(origin)} + aria-label={`Remove site override for ${origin}`} + > + Remove site override + </button> + </div> + + <div className="detector-list detector-list--nested"> + {KNOWN_DETECTORS_DEFAULTS.map(({ id, confidence }) => { + const siteEnabled = + siteConfig.detectors?.[id]?.enabled ?? false; + return ( + <div className="detector-row" key={id}> + <div className="detector-row__info"> + <span className="detector-row__id">{id}</span> + <ConfidenceBadge level={confidence} /> + </div> + <ToggleSwitch + id={`site-detector-toggle-${origin}-${id}`} + checked={siteEnabled} + onChange={() => handleSiteDetectorToggle(origin, id)} + /> + </div> + ); + })} + </div> + </div> + ))} + </div> + )} + </section> + </div> + ); +} diff --git a/src/settings/migrate.ts b/src/settings/migrate.ts new file mode 100644 index 0000000..5236977 --- /dev/null +++ b/src/settings/migrate.ts @@ -0,0 +1,187 @@ +/** + * Settings migration shim: upgrades v0 (react_debugger_disabled_sites array) + * to v1 (Settings shape with perSite + detectors) in a single idempotent pass. + * + * Entry point for the extension is {@link migrate}. Called once per content + * script lifecycle via a module-level guard in content/index.ts. + * + * @module settings/migrate + */ + +import { + DEFAULT_SETTINGS, + Settings, + SettingsSchema, + SETTINGS_STORAGE_KEY, +} from './types'; +import { write } from './storage'; + +// ─── Legacy key ────────────────────────────────────────────────────────────── + +/** The legacy storage key that held a plain array of disabled origin strings. */ +const LEGACY_STORAGE_KEY = 'react_debugger_disabled_sites' as const; + +// ─── Known detectors + default-policy ──────────────────────────────────────── + +/** + * Registry of all currently known detectors with their confidence tier. + * + * **Default-policy mapping**: + * - `'high'` → `enabled: true` (reliable signal, no known false-positive modes) + * - `'medium'` → `enabled: false` (known FP modes; user opts in explicitly) + * - `'low'` → `enabled: false` (experimental; user opts in explicitly) + * + * This list is intentionally exported and importable by future migration + * versions (M-C / M-D / M-E / M-F) that will append entries here. + */ +export const KNOWN_DETECTORS_DEFAULTS: Array<{ + id: string; + confidence: 'high' | 'medium' | 'low'; +}> = [ + { id: 'reconciler-keys', confidence: 'high' }, // T7 hero + { id: 'closure-leak', confidence: 'medium' }, // T8 extraction (known FP modes) + { id: 'scan-overlay', confidence: 'high' }, // T9 extraction (visual only, no FP) +]; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** + * Builds the `detectors` map applying the default-policy: + * high-confidence detectors are enabled; medium/low are disabled. + */ +function buildDefaultDetectors(): Record<string, { enabled: boolean }> { + const detectors: Record<string, { enabled: boolean }> = {}; + for (const { id, confidence } of KNOWN_DETECTORS_DEFAULTS) { + detectors[id] = { enabled: confidence === 'high' }; + } + return detectors; +} + +/** + * Builds the `perSite` entry for a single origin that was previously in the + * disabled-sites list: every known detector is set to `enabled: false`, + * matching v0 semantics (the entire site was disabled). + */ +function buildDisabledSiteEntry(): { detectors: Record<string, { enabled: boolean }> } { + const detectors: Record<string, { enabled: boolean }> = {}; + for (const { id } of KNOWN_DETECTORS_DEFAULTS) { + detectors[id] = { enabled: false }; + } + return { detectors }; +} + +// ─── Migration result type ──────────────────────────────────────────────────── + +/** + * Returned by {@link migrate} after each invocation. + * + * - `migrated`: `true` when the v0→v1 write actually happened; `false` when + * v1 already existed (idempotent path) or when the migration build failed. + * - `legacyKeysRemoved`: list of legacy storage keys deleted during this run. + * - `settings`: the authoritative {@link Settings} object after migration. + */ +export type MigrateResult = { + migrated: boolean; + legacyKeysRemoved: string[]; + settings: Settings; +}; + +// ─── Core migration function ────────────────────────────────────────────────── + +/** + * Runs the v0→v1 settings migration exactly once (idempotent). + * + * **Step 1** – Check for existing v1 settings; return early if found. + * **Step 2** – Read the legacy `react_debugger_disabled_sites` array. + * **Step 3** – Build the new {@link Settings} with default-policy detectors + * and per-site overrides for every previously-disabled origin. + * **Step 4** – Validate against {@link SettingsSchema}; abort if invalid. + * **Step 5** – Write new settings via `storage.write()`. + * **Step 6** – Remove the legacy key from chrome.storage.local. + * **Step 7** – Return the migration result. + * + * NEVER throws — all error paths are logged and resolved with the safe default. + */ +export async function migrate(): Promise<MigrateResult> { + // Step 1: Check for existing v1 settings (idempotent guard) + const existingRaw = await new Promise<unknown>((resolve) => { + chrome.storage.local.get(SETTINGS_STORAGE_KEY, (result) => { + resolve(result[SETTINGS_STORAGE_KEY]); + }); + }); + + if (existingRaw !== undefined && existingRaw !== null) { + const parsed = SettingsSchema.safeParse(existingRaw); + if (parsed.success) { + return { migrated: false, legacyKeysRemoved: [], settings: parsed.data }; + } + // Existing value is present but invalid — fall through to rebuild + } + + // Step 2: Read legacy disabled-sites array + const legacyRaw = await new Promise<unknown>((resolve) => { + chrome.storage.local.get(LEGACY_STORAGE_KEY, (result) => { + resolve(result[LEGACY_STORAGE_KEY]); + }); + }); + + // Coerce legacy value — must be an array of strings; anything else → empty + const legacyOrigins: string[] = Array.isArray(legacyRaw) + ? legacyRaw.filter((v): v is string => typeof v === 'string') + : []; + + if (!Array.isArray(legacyRaw) && legacyRaw !== undefined) { + console.warn('SETTINGS_MIGRATION_BUILD_ERROR: legacy value is not an array, treating as empty', legacyRaw); + } + + // Step 3: Build new Settings + const perSite: Record<string, { detectors: Record<string, { enabled: boolean }> }> = {}; + for (const origin of legacyOrigins) { + perSite[origin] = buildDisabledSiteEntry(); + } + + const newSettings: Settings = { + version: 1, + detectors: buildDefaultDetectors(), + perSite, + }; + + // Step 4: Validate + const validation = SettingsSchema.safeParse(newSettings); + if (!validation.success) { + console.warn('SETTINGS_MIGRATION_BUILD_ERROR', validation.error); + return { migrated: false, legacyKeysRemoved: [], settings: DEFAULT_SETTINGS }; + } + + // Step 5: Write — if storage rejects, honour the NEVER-throws contract + try { + await write(validation.data); + } catch (err) { + console.warn('SETTINGS_MIGRATION_WRITE_ERROR', err); + return { migrated: false, legacyKeysRemoved: [], settings: DEFAULT_SETTINGS }; + } + + // Step 6: Best-effort legacy delete — don't fail the migration if this throws + const removed: string[] = []; + try { + await new Promise<void>((resolve, reject) => { + chrome.storage.local.remove(LEGACY_STORAGE_KEY, () => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)); + } else { + resolve(); + } + }); + }); + removed.push(LEGACY_STORAGE_KEY); + } catch (err) { + console.warn('SETTINGS_MIGRATION_LEGACY_REMOVE_ERROR', err); + } + + // Step 7: Return result + return { + migrated: true, + legacyKeysRemoved: removed, + settings: validation.data, + }; +} diff --git a/src/settings/storage.ts b/src/settings/storage.ts new file mode 100644 index 0000000..63df2da --- /dev/null +++ b/src/settings/storage.ts @@ -0,0 +1,104 @@ +/** + * chrome.storage.local wrapper for extension settings. + * + * Runs in panel context (DOM). NEVER throws on read — all errors are logged + * and DEFAULT_SETTINGS is returned. Throws on write only when the value + * fails schema validation (caller responsibility per spec C2). + * + * Design note: subscribe() uses chrome.storage.onChanged rather than polling + * so the panel reacts immediately to changes made by other extension contexts. + */ + +import { + DEFAULT_SETTINGS, + Settings, + SettingsSchema, + SETTINGS_STORAGE_KEY, +} from './types'; + +/** + * Reads settings from chrome.storage.local. + * + * - Returns `DEFAULT_SETTINGS` when the key is absent. + * - Returns `DEFAULT_SETTINGS` and logs `SETTINGS_PARSE_ERROR` when the + * stored value fails schema validation. + * - NEVER throws. + */ +export async function read(): Promise<Settings> { + return new Promise<Settings>((resolve) => { + chrome.storage.local.get(SETTINGS_STORAGE_KEY, (result) => { + const raw = result[SETTINGS_STORAGE_KEY]; + + if (raw === undefined || raw === null) { + resolve(DEFAULT_SETTINGS); + return; + } + + const parsed = SettingsSchema.safeParse(raw); + if (!parsed.success) { + console.warn('SETTINGS_PARSE_ERROR', parsed.error); + resolve(DEFAULT_SETTINGS); + return; + } + + resolve(parsed.data); + }); + }); +} + +/** + * Persists settings to chrome.storage.local after validating against + * {@link SettingsSchema}. + * + * @throws {ZodError} When `settings` fails schema validation. The caller is + * responsible for handling this — do not write invalid settings. + */ +export async function write(settings: Settings): Promise<void> { + SettingsSchema.parse(settings); + + return new Promise<void>((resolve, reject) => { + chrome.storage.local.set({ [SETTINGS_STORAGE_KEY]: settings }, () => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)); + return; + } + resolve(); + }); + }); +} + +/** + * Subscribes to settings changes via chrome.storage.onChanged. + * + * Fires `callback` with the validated new settings whenever our storage key + * changes. Skips the callback (and logs a warning) if the incoming value fails + * schema validation. + * + * @param callback - Invoked with the new {@link Settings} on every valid change. + * @returns An unsubscribe function that removes the listener. + */ +export function subscribe(callback: (settings: Settings) => void): () => void { + const listener = ( + changes: Record<string, chrome.storage.StorageChange>, + areaName: string + ): void => { + if (areaName !== 'local') return; + if (!(SETTINGS_STORAGE_KEY in changes)) return; + + const newValue = changes[SETTINGS_STORAGE_KEY]?.newValue; + + const parsed = SettingsSchema.safeParse(newValue); + if (!parsed.success) { + console.warn('SETTINGS_PARSE_ERROR', parsed.error); + return; + } + + callback(parsed.data); + }; + + chrome.storage.onChanged.addListener(listener); + + return () => { + chrome.storage.onChanged.removeListener(listener); + }; +} diff --git a/src/settings/types.ts b/src/settings/types.ts new file mode 100644 index 0000000..33ca764 --- /dev/null +++ b/src/settings/types.ts @@ -0,0 +1,102 @@ +/** + * Settings types, zod schema, and constants for the React Debugger Extension. + * + * Design rationale: + * - `version: 1` enables future migrations (T5 migration layer reads this). + * - `detectors` is a global per-detector config keyed by detector ID. + * - `perSite` overrides global config for a specific hostname. + * - DEFAULT_SETTINGS intentionally uses empty maps — default policy (which + * detectors are on/off by confidence level) is enforced in T5 migration + + * T6 Settings UI, NOT here. T4 is the storage primitive only. + * + * @see https://vitejs.dev/guide/features.html — build context note + */ + +import { z } from 'zod'; + +// ─── Storage key ──────────────────────────────────────────────────────────── + +/** + * The chrome.storage.local key under which settings are persisted. + * Versioned (`_v1`) to allow clean migrations if the schema changes. + */ +export const SETTINGS_STORAGE_KEY = 'react_debugger_settings_v1' as const; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +/** + * Per-detector configuration: whether it's active and an optional render- + * budget threshold in milliseconds. + */ +export type DetectorConfig = { + enabled: boolean; + budgetMs?: number; +}; + +/** + * Per-site override block. Allows disabling or tuning detectors for a + * specific hostname without touching global defaults. + */ +export type SiteConfig = { + detectors?: Record<string, DetectorConfig>; +}; + +/** + * The canonical shape of extension settings stored in chrome.storage.local. + * + * - `version`: schema version; currently always `1`. Used by the T5 migration + * layer to detect old formats. + * - `detectors`: global detector configuration map keyed by detector ID. + * - `perSite`: per-hostname overrides keyed by hostname (e.g. "example.com"). + */ +export type Settings = { + version: 1; + detectors: Record<string, DetectorConfig>; + perSite: Record<string, SiteConfig>; +}; + +// ─── Zod Schema ────────────────────────────────────────────────────────────── + +/** + * Zod schema for {@link DetectorConfig}. + * `budgetMs` is optional; must be a positive number when present. + */ +const DetectorConfigSchema = z.object({ + enabled: z.boolean(), + budgetMs: z.number().positive().optional(), +}); + +/** + * Zod schema for {@link SiteConfig}. + */ +const SiteConfigSchema = z.object({ + detectors: z.record(z.string(), DetectorConfigSchema).optional(), +}); + +/** + * Zod schema that validates the full {@link Settings} shape. + * + * Used by `storage.read()` to guard against corrupt data and by + * `storage.write()` to prevent writing malformed settings. + * + * Typed as `z.ZodType<Settings>` so callers can reference the output type + * without importing zod internals. + */ +export const SettingsSchema: z.ZodType<Settings> = z.object({ + version: z.literal(1), + detectors: z.record(z.string(), DetectorConfigSchema), + perSite: z.record(z.string(), SiteConfigSchema), +}); + +// ─── Defaults ──────────────────────────────────────────────────────────────── + +/** + * Safe default returned when no settings are stored or the stored value is + * corrupt. Empty maps mean "no overrides" — the runtime applies built-in + * detector defaults (handled in T5/T6, not here). + */ +export const DEFAULT_SETTINGS: Settings = { + version: 1, + detectors: {}, + perSite: {}, +} as const; diff --git a/src/types/index.ts b/src/types/index.ts index 93fd681..3c7e837 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -11,7 +11,8 @@ export type IssueType = | 'STALE_CLOSURE_RISK' | 'SLOW_RENDER' | 'MEMORY_GROWTH' - | 'POTENTIAL_MEMORY_LEAK'; + | 'POTENTIAL_MEMORY_LEAK' + | 'UNSTABLE_LIST_KEY'; export interface IssueLocation { componentName: string; diff --git a/src/types/registry.ts b/src/types/registry.ts new file mode 100644 index 0000000..d334e82 --- /dev/null +++ b/src/types/registry.ts @@ -0,0 +1,178 @@ +/** + * Detector registry type contracts. + * + * These interfaces define the contract between the inject-script registry + * (`src/inject/registry.ts`) and all future detector modules. Every detector + * shipped under the self-roadmap H2-2026 plan conforms to `Detector<TIssue>`. + * + * First detector through this interface: T7 reconciler-keys. + * Canary extractions: T8 closure-leak, T9 scan-overlay. + * + * See `openspec/changes/self-roadmap-h2-2026/specs/detector-registry/spec.md` + * for the WHEN/THEN scenarios this contract is designed to satisfy. + */ + +/** + * Stable string identifier for a detector. Used as the key in the registry's + * active set and as the `detectorId` field on emitted issue payloads. + */ +export type DetectorId = string; + +/** + * Mirrors the MCP / Panel tab grouping for detector categorisation. Each + * detector declares which UI tab its issues feed. + */ +export type MCPTabCategory = + | 'performance' + | 'memory' + | 'cls' + | 'side-effects' + | 'redux' + | 'ui-state'; + +/** + * Lifecycle-scoped context handed to every detector at `init` time. The + * registry constructs a fresh `DetectorContext` per registered detector so + * each detector gets its own staging buffer, persistent store, and dedupe LRU. + * + * Mutations via `write` are staged for the duration of the current lifecycle + * call and only commit if the call returns without throwing — see C6 in + * the task harness for the transactionality contract. + */ +export interface DetectorContext { + /** + * Emit a sanitized payload upstream (typically to the panel / MCP). The + * registry injects this from `createRegistry({ emit })`. + */ + emit(payload: unknown): void; + + /** + * Diagnostic logging hook. The registry injects this from + * `createRegistry({ log })`. Detector code SHOULD NOT call `console.*` + * directly — route through `ctx.log` so the host can mute / redirect. + */ + log(...args: unknown[]): void; + + /** + * Sanitize a single value before emission. Wired to the project's + * existing `sanitizeValue` primitive (see `src/utils/sanitize.ts`). + */ + sanitize: (value: unknown) => unknown; + + /** + * High-resolution timing source, dependency-injected for testability. + * Defaults to the host page's `window.performance` when the registry is + * created without an override. + */ + performance: { now(): number }; + + /** + * Bounded-LRU dedupe. Returns `true` on first sighting of `key`, `false` + * on every subsequent sighting (until the key is evicted by LRU pressure). + * Capacity is `dedupeCapDefault` from `createRegistry` options (default 1000). + */ + dedupe(key: string): boolean; + + /** + * Staged write. The value lands in a per-call staging buffer that commits + * to the persistent store on lifecycle-call return, or is discarded on + * throw. Reads via `read` only see persisted values — writes within the + * same call are NOT visible to reads within the same call (intentional + * foot-gun avoidance). + */ + write<T>(key: string, value: T): void; + + /** + * Read from the persistent store. Returns `undefined` for unknown keys + * and for keys that were written-but-not-yet-committed within the same + * lifecycle call. + */ + read<T>(key: string): T | undefined; +} + +/** + * Detector contract. Every detector module exports an object (or factory + * result) conforming to this interface and registers itself with the + * registry at inject-script init time. + * + * Lifecycle order, per commit: + * 1. `init(ctx)` — once, at `register()` time. Synchronous. Allocate + * observers, capture refs. + * 2. `onCommit(fiberRoot, deadline)` — once per React commit, if defined. + * MUST respect the `deadline` (host-page `performance.now()` cutoff) + * and bail out before exceeding `budgetMs`. + * 3. `onIdle(deadline)` — optional, runs in `requestIdleCallback` for + * deferred work. MUST respect `deadline.timeRemaining()`. + * 4. `drain()` — pull and clear the detector's accumulated issues. Called + * from the panel's POLL_DATA cycle and the MCP `get_issues` tool. + * Pure (no side effects other than clearing the internal buffer). + * 5. `teardown()` — release all hooks, timers, observers. After teardown + * the detector emits nothing and is re-registerable. + * 6. `recover()` — optional. Called by the registry after a throw inside + * a lifecycle call, before the detector is marked + * `disabled-for-session`. Use to revert partial mutations. + * + * The `fiberRoot` parameter on `onCommit` is typed `unknown` because the + * registry runs in the host-page world where React's internal types are + * not available; detectors that need to walk fibers cast at their own + * boundary. + * + * First detector through this interface: T7 reconciler-keys. + * Canary extractions: T8 closure-leak, T9 scan-overlay. + */ +export interface Detector<TIssue = unknown> { + /** Stable identifier (e.g. `'reconciler-keys'`). Unique within a registry. */ + readonly id: DetectorId; + + /** UI tab grouping for the detector's emitted issues. */ + readonly category: MCPTabCategory; + + /** + * Per-commit time budget in milliseconds. The registry computes the + * deadline as `performance.now() + budgetMs` and passes it to `onCommit`. + */ + readonly budgetMs: number; + + /** + * Self-declared signal confidence. Surfaced via `registry.list()` for the + * Settings UI and the CI bench harness. + */ + readonly confidence: 'high' | 'medium' | 'low'; + + /** + * Whether the detector is safe to run in production builds. Detectors + * that depend on React DEV-only internals MUST declare `false`. + */ + readonly prodCapable: boolean; + + /** + * One-time initialisation. Called synchronously by `registry.register()`. + * The `ctx` reference is stable for the lifetime of the registration. + */ + init(ctx: DetectorContext): void; + + /** + * Per-commit hook. `fiberRoot` is the React internal root (typed + * `unknown` because React types are not in scope here). `deadline` is the + * `performance.now()` cutoff the detector should respect. + */ + onCommit?(fiberRoot: unknown, deadline: number): void; + + /** + * Idle-time hook. Use for non-critical deferred work that should yield + * to user input. + */ + onIdle?(deadline: IdleDeadline): void; + + /** Drain accumulated issues and clear the internal buffer. */ + drain(): TIssue[]; + + /** Release all resources. Idempotent. */ + teardown(): void; + + /** + * Optional partial-mutation recovery. Called by the registry between a + * thrown lifecycle call and `disabled-for-session` marking. + */ + recover?(): void; +}