Skip to content

docs(readme): star-growth polish — positioning, badges, screenshots, comparison#52

Merged
hoainho merged 2 commits into
mainfrom
docs/star-growth-readme-polish
Jun 2, 2026
Merged

docs(readme): star-growth polish — positioning, badges, screenshots, comparison#52
hoainho merged 2 commits into
mainfrom
docs/star-growth-readme-polish

Conversation

@hoainho
Copy link
Copy Markdown
Owner

@hoainho hoainho commented Jun 2, 2026

What this changes

Conversion-killer fixes from the star-growth research session. Pure docs/README, zero code touched.

Above the fold

  • Stars badge + npm downloads badge added — social proof + FOMO
  • New 1-line positioning statement: "The only Chrome extension that unifies React Timeline + Performance + Memory + Side Effects + Redux + CLS + AI analysis into a single DevTools tab — zero install in your app."
  • ⚡ Try it in 30 seconds section with npx command above the fold
  • Replaced the broken MP4 link with inline GIF reference (the linked MP4 react-ext-demo-3x.mp4 doesn't exist in docs/video/)

Why this extension

Expanded from 4 to 5 competitors with concrete weakness for each:

  • react-devtools — no CLS, no memory leak detection
  • Chrome Perf tab — no React context
  • Redux DevTools — isolated from render cycle
  • react-scan — requires app-code changes; no Redux/CLS/memory
  • why-did-you-render — console-only, needs Babel

Then leads with the defensible triple-lock thesis: CLS overlay + useEffect audit + AI analysis — no other tool combines these.

Screenshots section

6 inline tab screenshots (Timeline, Performance, Memory, Side Effects, CLS, Redux) using existing assets in docs/images/. Audit flagged that these existed but were never surfaced in the README body.

Comparison table

8 features × 5 tools (React Debugger / react-devtools / react-scan / why-did-you-render / redux-devtools) — bold rows on the 3 features no competitor combines.

Star CTA + star-history chart

End-of-README "⭐ If this saves you time, please star" with star-history.com chart + Watch-releases prompt.

Why

Based on the competitive positioning research + repo audit. Current state: 14 stars. Goal: ship the conversion-killer fixes BEFORE driving any traffic, because driving traffic to a broken funnel is malpractice.

Net diff

+56 / -6 lines. No code, no behavior change.

Risk

Zero — README only. CI will re-run but no logic changes.

hoainho added 2 commits June 2, 2026 01:57
…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.
…ots, comparison table

Conversion-killer fixes based on competitive positioning research:
- Add GitHub stars badge + npm downloads badge to badge row
- Lead with defensible triple-lock positioning: CLS overlay + useEffect audit + AI analysis (no competitor combines these)
- Replace broken MP4 link with inline GIF reference (MP4 file doesn't exist)
- Add ⚡ Try it in 30 seconds section with npx one-liner above the fold
- Expand 'Why this extension' to name 5 competitors with concrete gaps (react-devtools, Chrome Perf, redux-devtools, react-scan, why-did-you-render)
- Add Screenshots section with 6 tab images from docs/images/
- Add How it compares table — 8 features × 5 competitors with bold rows on the 3 unique-to-us features
- Add ⭐ Star CTA at end with star-history.com chart + Watch-releases prompt

Net diff: +56 / -6 lines. No code changes.
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 establishes a pluggable detector registry foundation, introduces React version adapters (supporting React 17 through 19.2), and migrates several detectors (including reconciler-keys, scan-overlay, and closure-leak) to this new architecture. Feedback on the changes highlights a critical bug in site-enablement checks post-migration, potential crash risks in fiber traversal and display name resolution, missing error handling in settings persistence, and opportunities to optimize traversal budgets and preserve pending render flashes during busy idle periods.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/content/index.ts
Comment on lines 67 to 86
async function checkIfSiteEnabled(): Promise<boolean> {
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;
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 the settings migration is completed (migrationDone is true), any subsequent calls to checkIfSiteEnabled will fall back to querying the legacy STORAGE_KEY (react_debugger_disabled_sites). However, the migration process deletes this legacy key from storage. As a result, subsequent checks will read undefined, causing the debugger to be enabled on all sites and ignoring any site-specific overrides configured in the new settings.

Instead of falling back to the legacy key, we should always use the migrated settings. Since migrate() is idempotent and safely returns the existing settings if they already exist, we can simplify this function to always use migrate().

async function checkIfSiteEnabled(): Promise<boolean> {
  try {
    const hostname = window.location.hostname;
    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;
  } catch {
    return true;
  }
}

Comment on lines +18 to +22
export function walkFiberImpl(
root: FiberNode,
visitor: (fiber: FiberNode) => void,
maxNodes = 5000,
): void {
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

If root is null or undefined, stack is initialized with [root]. When popped, fiber will be null, and accessing fiber.sibling or fiber.child outside of the try/catch block will throw a TypeError: Cannot read properties of null, crashing the traversal.

Additionally, the visitor currently returns void, meaning there is no way to abort the traversal early (e.g., when a budget/deadline is exceeded). We should allow the visitor to return boolean and break the loop if it returns false.

Suggested change
export function walkFiberImpl(
root: FiberNode,
visitor: (fiber: FiberNode) => void,
maxNodes = 5000,
): void {
export function walkFiberImpl(
root: FiberNode,
visitor: (fiber: FiberNode) => void | boolean,
maxNodes = 5000,
): void {
if (root === null || root === undefined) return;
let count = 0;
const stack: FiberNode[] = [root];
while (stack.length > 0 && count < maxNodes) {
const fiber = stack.pop()!;
count++;
try {
if (visitor(fiber) === false) {
break;
}
} catch {
// visitor errors must not abort the walk
}

Comment on lines +178 to +181
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;
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

With the updated walkFiberImpl supporting early termination, we should return false when the deadline is exceeded to stop the traversal immediately. Currently, returning void only skips the current node but continues traversing the rest of the tree (up to 5000 nodes), wasting CPU cycles.

Suggested change
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;
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 false;

Comment on lines +114 to +122
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);
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

If deadline.timeRemaining() <= 0 and it is not a forced run, the pending flashes are cleared (pending = []) and permanently dropped. This means the user will miss visual render flashes if the browser is temporarily busy.

Instead of dropping the batch, we should keep the items in pending and return early, allowing them to be painted on the next idle callback.

        if (deadline.timeRemaining() <= 0 && !deadline.didTimeout) {
          // No budget left and not a forced run — keep this batch for the next idle tick
          return;
        }
        const items = pending;
        pending = [];
        bridge.paint(items);

Comment on lines +58 to +74
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);
};
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

The asynchronous write(updated) call is not wrapped in a try/catch block. If persisting to chrome.storage.local fails (e.g., due to quota limits or an invalidated extension context), the promise rejection will go unhandled, potentially crashing the panel or leaving the UI state out of sync with storage.

We should wrap the write operation in a try/catch block and revert the local state if it fails.

  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);
    try {
      await write(updated);
    } catch (err) {
      setSettings(settings);
      console.error('Failed to save settings:', err);
    }
  };

Comment on lines +70 to +78
/**
* 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)];
}
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

The parseSemver function expects a strict major.minor.patch format. If a React version string is non-standard or missing the patch version (e.g., "19", "19.2"), it returns [0, 0, 0], falling back to the React 17 adapter.

We should make the regex more robust by allowing optional minor and patch segments.

Suggested change
/**
* 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)];
}
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),
match[2] ? parseInt(match[2], 10) : 0,
match[3] ? parseInt(match[3], 10) : 0,
];
}

Comment on lines +117 to +123
// 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.',
);
}
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

Throwing a hard error in getDisplayName is risky. If an unexpected tag is encountered in production, throwing an error can crash the host application's commit phase if not caught properly. It is safer to return null or log a warning to degrade gracefully.

  // Tags that CANNOT exist in r17 — return null to degrade gracefully
  if (tag === 26 || tag === 27 || tag === 28 || tag === 29 || tag === 30 || tag === 31) {
    return null;
  }

@hoainho hoainho merged commit 926d6ad into main Jun 2, 2026
2 checks passed
@hoainho hoainho deleted the docs/star-growth-readme-polish branch June 2, 2026 02:39
hoainho added a commit that referenced this pull request Jun 2, 2026
… preview (#53)

Closes 2 of the 3 critical conversion gaps from the star-growth audit:

## Demo GIF re-encoded — 8.9 MB → 3.3 MB (-64%)

Original: 900x594, 289 frames @ 12.5 fps, 8.9 MB. GitHub markdown timed out
loading on slower connections, and mobile views often showed a broken image.

Re-encoded: 500x330, 97 frames @ ~4 fps with FloydSteinberg dithering + 64-color
adaptive palette. Total play time preserved (~23s). Panel UI still readable;
motion slightly choppier but the trade-off is worth the 64% size reduction
since the GIF is the single biggest first-impression conversion element.

Tooling note: ffmpeg/gifsicle unavailable in agent sandbox, used Python PIL
with quantize(colors=64, dither=FLOYDSTEINBERG) workaround. If a future
re-encode uses ffmpeg, the recommended command is:

  ffmpeg -i react-ext-demo.gif -vf 'fps=10,scale=800:-1:flags=lanczos'     -loop 0 react-ext-demo-opt.gif

## Social preview image — 1280x640 (GitHub spec)

New file: docs/images/social-preview-1280x640.png (130 KB).

Generated from docs/images/preview.png (2382x1728, 1.38:1) by center-cropping
to 2:1 aspect ratio then resizing to 1280x640 with Lanczos resampling.

This file is READY for the maintainer to upload via
Settings → Social preview → 'Edit' → drag-drop. GitHub's REST/GraphQL API
does not expose social preview upload, so this final step is unavoidably
manual but takes ~30 seconds.

Currently every share of the repo URL on Twitter/LinkedIn/Slack shows the
generic GitHub social card. Setting this image converts an unbranded
impression into a branded one.

## Impact

Closes audit GAP 1 (demo GIF too large) and prepares GAP 3 (social preview
missing) for a 30-second manual finish. Combined with the README polish
shipped in PR #52, this finalizes Phase 1 of the 30-day star-growth plan
documented in .sisyphus/growth/30-day-star-plan.md.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant