Skip to content

feat(roadmap): M-B foundation — detector registry + hero detector + onIdle driver (recovery of #41)#51

Merged
hoainho merged 1 commit into
mainfrom
feat/m-b-recovery
Jun 2, 2026
Merged

feat(roadmap): M-B foundation — detector registry + hero detector + onIdle driver (recovery of #41)#51
hoainho merged 1 commit into
mainfrom
feat/m-b-recovery

Conversation

@hoainho
Copy link
Copy Markdown
Owner

@hoainho hoainho commented Jun 1, 2026

Recovery PR. Original PR was #41.
#41 auto-closed when yesterday's identity-rewrite force-push orphaned the M-B branch ancestry from main. Same problem as M-A PR #39 (recovery in PR #50).

Base = main. PR #50 (M-A recovery) is merged-equivalent — this PR brings forward ALL of M-A's work AND M-B's work in a single squash. If reviewing this PR, you can review PR #50 first for the M-A subset, but they could also merge in either order.

Self-Roadmap H2 2026 — Milestone M-B (Detector Registry + Hero #1)

Ships the detector registry foundation + first new detector since v2.0.3 + registry migration of the two existing detectors, all per the Momus-approved OpenSpec change.

Why M-B matters

  • First detector since Feb 2026 (reconciler-keys): flags Math.random() / Date.now() keys + verified cross-commit reorders on numeric-index keys. Confidence: high, defaults ON. Production-capable.
  • Biggest live perf-budget violation closed: getBoundingClientRect() moved from synchronous onCommit to deferred onIdle via new registry idle driver.
  • Settings UI lands: users can finally toggle detectors per-domain.
  • Pattern proven across 3 detector shapes: pure-logic (reconciler-keys), lazy-install (closure-leak), event-driven (scan-overlay).

10 task groups, squashed into one commit

Task Headline
T1 Detector registry + Detector<TIssue> interface (staged-write transactionality, LRU dedupe, throw isolation). 20 tests.
T2 Wire registry dispatch into onCommitFiberRoot AFTER snapshot capture
T3 React version adapters (r17/r18/r19/r19.2) with version-aware FIBER_TAGS handling OffscreenComponent landmines. 11 tests.
T4 Settings storage primitive (zod-validated, chrome.storage.local). 7 tests.
T5 Settings migration v0→v1 + default-policy fill. 7 tests.
T6 Settings UI panel tab + per-detector toggles + per-site override. 7 tests + ~300 LoC CSS.
T7 Hero detector #1: reconciler-keys (new UNSTABLE_LIST_KEY issue type). 11 tests.
T8 closure-leak extracted to registry (Strategy A bridge pattern). 7 tests.
T9 scan-overlay extracted + registry dispatchIdle driver added. getBoundingClientRect() now runs only in onIdle. 8 tests.
T10 periodicCleanup drains registry, M_B_SMOKE_TEST.md runbook, CHANGELOG [Unreleased] (merged with PR #47/#48 entries).

Verification gates (all green)

tsc --noEmit  → ZERO errors
build         → exit 0
test:run      → 29 pre-existing failures + 219 passing
                (was 141 in M-A; +78 from M-B tests)
bench         → exit 0

Critical perf gate

After T9, zero synchronous getBoundingClientRect calls in the inject onCommitFiberRoot hot path when scan is enabled. The only callsite is now inside flashRenderOverlay, invoked exclusively from bridge.paint() driven by the detector's onIdle lifecycle.

Files in this PR

  • 34 files changed, +5129/-30 lines
  • 32 new files: 11 detector/adapter/settings source files + 9 test files + 5 docs/fixtures + 7 config
  • 8 modified files: types/index.ts (additive UNSTABLE_LIST_KEY), inject/index.ts, content/index.ts, Panel.tsx, panel.css, package.json, package-lock.json, CHANGELOG.md

CHANGELOG merge

M-B's [Unreleased] section is merged with main's existing [Unreleased] content (PR #47 claim rule + PR #48 star check). Combined section now lists: Added (workflow + detector registry + adapters + settings storage + UI + reconciler-keys + onIdle driver), Changed (claim policy hardened + closure-leak migration + scan-overlay perf fix), Fixed (drainAll), Migration (settings v0→v1 + star-rule grandfathering).

Production smoke test (cannot run in this PR)

M_B_SMOKE_TEST.md — 12 TCs covering build verification, default-policy correctness, all 3 detectors, Settings UI walkthrough, migration paths, memory growth check.

Pipeline used

Each of T1-T10 ran through the SDD pipeline (implementer → spec-compliance reviewer → code-quality reviewer). 1 loop-back on T1 (anti-pattern cast fix), 1 on T5 (contract violation), all 8 others first-pass.

Notes

  • Atomic-task SHAs lost in the orphan rewrite. Harness file .opencode/plans/2026-05-31-self-roadmap-m-b.md preserves the task narrative + done-notes for posterity.
  • This PR is tracked-plan labeled, so the Star Check workflow auto-exempts (maintainer + tracked-plan double exemption).

@hoainho hoainho added the tracked-plan Maintainer-driven milestone PR (M-A, M-B, Self-Roadmap); exempt from Star Check CI label Jun 1, 2026
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a pluggable detector registry foundation and React version adapters to handle fiber tag differences across React versions. It migrates existing detectors to this registry pattern, schedules deferred work via idle callbacks, adds a new reconciler-keys detector, and introduces a schema-validated settings storage system with a corresponding Settings UI tab. The review feedback identifies several critical issues: a protocol mismatch in the per-site override settings where full URLs are saved instead of hostnames, a logic bug in the site enablement check that falls back to deleted legacy storage after migration, a positional bug in the mixed keyed/unkeyed sibling check of the reconciler-keys detector, and duplicated React version detection logic in the inject script.

Comment on lines +76 to +93
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);
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The input field has type="url" and placeholder="https://app.example.com", which forces/encourages the user to enter a full URL with a protocol (e.g., https://app.example.com). However, origin is saved directly as the key in perSite, while checkIfSiteEnabled in content/index.ts looks up window.location.hostname (e.g., app.example.com). Because of the protocol mismatch, the per-site override will never match and the feature is completely broken. We should extract the hostname from the entered URL before saving it.

  const handleAddSiteOverride = async () => {
    if (!settings || !newSiteOrigin.trim()) return;
    let hostname = newSiteOrigin.trim();
    try {
      const url = new URL(hostname);
      hostname = url.hostname;
    } catch {
      // Fallback to raw input if it's not a parseable URL
    }
    if (!hostname) return;

    const perDetectorOverrides: Record<string, { enabled: boolean }> = {};
    for (const { id } of KNOWN_DETECTORS_DEFAULTS) {
      perDetectorOverrides[id] = { enabled: false };
    }
    const updated: Settings = {
      ...settings,
      perSite: {
        ...settings.perSite,
        [hostname]: { detectors: perDetectorOverrides },
      },
    };
    setSettings(updated);
    setNewSiteOrigin('');
    await write(updated);
  };

Comment thread src/content/index.ts
Comment on lines +70 to 84
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);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Once migrationDone is set to true, subsequent calls to checkIfSiteEnabled will bypass the new settings check and fall back to reading the legacy STORAGE_KEY (react_debugger_disabled_sites). Since the legacy key is deleted during migration, legacyResult[STORAGE_KEY] will be undefined, causing the function to always return true (enabled) even if the site was disabled in the new settings. We should always use the migrated settings directly.

    const result = await migrate();
    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;

Comment on lines +185 to +201
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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There is a positional bug in the mixed keyed/unkeyed sibling check. If an unkeyed sibling is at the beginning of the list (e.g., [unkeyed, keyed, keyed]), keyed.length is 0 when the unkeyed sibling is processed, so hasUnkeyed remains false and the list is incorrectly analyzed. If the unkeyed sibling is at the end, it is correctly punted. We should count the unkeyed siblings and check if both keyed and unkeyed siblings exist under the same parent to consistently punt on mixed lists.

Suggested change
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;
let child: FiberNode | null = fiber.child;
const keyed: ListChild[] = [];
let index = 0;
let unkeyedCount = 0;
while (child !== null) {
if (child.key !== null && child.key !== undefined) {
keyed.push({ key: String(child.key), index });
} else {
unkeyedCount++;
}
index++;
child = child.sibling;
}
if (unkeyedCount > 0 && keyed.length > 0) return;
if (keyed.length < 2) return;

Comment thread src/inject/index.ts
Comment on lines +4 to +7
import { installCleanupInterval, uninstallCleanupInterval } from './lifecycle';
import { createRegistry } from './registry';
import { registerAllDetectors } from './detectors';
import { sanitizeValue as sanitizeForRegistry } from '../utils/sanitize';
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To avoid code duplication, we should import detectReactVersion from the ./react-adapters module instead of redefining it inside this file.

Suggested change
import { installCleanupInterval, uninstallCleanupInterval } from './lifecycle';
import { createRegistry } from './registry';
import { registerAllDetectors } from './detectors';
import { sanitizeValue as sanitizeForRegistry } from '../utils/sanitize';
import { installCleanupInterval, uninstallCleanupInterval } from './lifecycle';
import { createRegistry } from './registry';
import { registerAllDetectors } from './detectors';
import { sanitizeValue as sanitizeForRegistry } from '../utils/sanitize';
import { detectReactVersion as getReactVersion } from './react-adapters';

Comment thread src/inject/index.ts
Comment on lines 1665 to +1686
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';
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This function is duplicated in src/inject/react-adapters/index.ts. We should replace it with a call to the imported getReactVersion helper.

  function detectReactVersion(): string {
    return getReactVersion() || 'unknown';
  }

@hoainho hoainho added tracked-plan Maintainer-driven milestone PR (M-A, M-B, Self-Roadmap); exempt from Star Check CI and removed tracked-plan Maintainer-driven milestone PR (M-A, M-B, Self-Roadmap); exempt from Star Check CI labels Jun 1, 2026
…nIdle driver

T1: Detector<TIssue> 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.
@hoainho hoainho force-pushed the feat/m-b-recovery branch from 047749d to 5d1cb4e Compare June 2, 2026 01:58
@hoainho hoainho merged commit 29d4085 into main Jun 2, 2026
2 checks passed
@hoainho hoainho deleted the feat/m-b-recovery branch June 2, 2026 02:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

tracked-plan Maintainer-driven milestone PR (M-A, M-B, Self-Roadmap); exempt from Star Check CI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant