Skip to content

feat(scan-react): parallel idiomatic-React port of the Preact toolbar UI#458

Open
aidenybai wants to merge 1 commit into
mainfrom
port/scan-react-parallel-package
Open

feat(scan-react): parallel idiomatic-React port of the Preact toolbar UI#458
aidenybai wants to merge 1 commit into
mainfrom
port/scan-react-parallel-package

Conversation

@aidenybai

@aidenybai aidenybai commented May 28, 2026

Copy link
Copy Markdown
Owner

Summary

Adds packages/scan-react, a parallel idiomatic-React port of the react-scan toolbar UI that currently lives in packages/scan/src/web (Preact). The existing Preact package is left completely untouched — this is additive and exploratory, and ships nothing in production.

  • All 37 Preact UI files ported to React; 0 preact/@preact imports remain.
  • tsc --noEmit passes with 0 errors.

How it works

Signals → idiomatic React

A hand-written compat layer (src/web/utils/signals.ts) replaces @preact/signals:

  • Module-level signal() / computed() keep their .value / .peek() / .subscribe() store API, so non-render code and the shared engine work unchanged.
  • Reads inside a component's render body become useSignalValue / useComputed hooks (backed by useSyncExternalStore).
  • Trivially-local useSignaluseState; useSignalEffect retained via the compat module.

Framework API + JSX

  • render(vnode, container)createRoot(container).render(...) / root.unmount() (the old double-render(null) unmount hack is gone).
  • preact/hooks + preact/compatreact (createPortal from react-dom).
  • constant() HOC: Preact's shouldComponentUpdate = falsememo(Component, () => true).
  • Class error boundary ported to React.Component.
  • JSX idioms normalized: classclassName, onInputonChange, onDblClickonDoubleClick, SVG kebab→camelCase, React synthetic event types.

Engine re-use (no core edits)

The framework-agnostic profiling engine is re-used as-is from react-scan via the ~core/* alias — no core files were modified. Cross-package resolution is handled purely in tsconfig.json (a src/* path mapping + including upstream types.ts) and a local global.d.ts (*.css ambient module). react/bippy are pinned to dedupe with the original package.

See packages/scan-react/PORT_SPEC.md for the full porting conventions.

⚠️ Architectural caveat (intentional, scoped)

react-scan deliberately uses Preact for its own toolbar to avoid (a) instrumenting its own UI, (b) adding a second reconciler / coupling to the host app's React version, and (c) bundle bloat. A React port re-introduces all three — hence this lives as an isolated parallel package rather than replacing the Preact UI.

Not included (follow-ups)

  • No build wiring (tsup) or auto.ts-equivalent mount entry — typecheck-validated but not bundled.
  • No browser runtime verification yet.

Test plan

  • pnpm --filter react-scan-react typecheck → 0 errors
  • No preact/@preact imports remain in packages/scan-react/src
  • packages/scan (Preact original) unchanged

🤖 Generated with Claude Code


Note

Medium Risk
Large new UI surface and a second React reconciler in the host if ever mounted, but isolated private package with no core edits and no production bundle wiring yet.

Overview
Introduces packages/scan-react, a private parallel package that copies the react-scan toolbar UI from Preact into idiomatic React while packages/scan stays unchanged.

The port adds a ~web/utils/signals layer so module-level signal/computed keep .value/.subscribe for handlers and shared ~core, while components subscribe via useSignalValue / useComputed (useSyncExternalStore). Toolbar mounting moves to createRoot / root.unmount() with a React Component error boundary; JSX and types are normalized for React (className, onChange, synthetic events, etc.).

PORT_SPEC.md documents the full Preact→React mapping. TypeScript wires ~core/* and src/* to ../scan/src so the profiling engine is reused without editing core; global.d.ts adds *.css modules. The diff includes the full Tailwind/CSS asset tree plus shared UI pieces (widget shell, inspector tree, notifications, hooks, utilities). package.json is typecheck-only (no tsup / auto mount yet)—exploratory, not wired into production.

Reviewed by Cursor Bugbot for commit da5dde3. Bugbot is set up for automated code reviews on this repo. Configure here.

Adds packages/scan-react, a parallel React port of the react-scan toolbar
UI that currently lives in packages/scan/src/web (Preact). The original
Preact package is left untouched.

- All 37 Preact UI files ported to React; 0 preact imports remain.
- Signals replaced with a React-idiomatic compat layer
  (src/web/utils/signals.ts) over useSyncExternalStore: module-level
  signal()/computed() keep their .value/.subscribe store API; render-body
  reads become useSignalValue/useComputed hooks; trivial useSignal -> useState.
- render() -> createRoot()/root.unmount(); preact/compat + preact/hooks ->
  react; constant() HOC -> memo(C, () => true); class error boundary ported.
- JSX idioms normalized (class->className, onInput->onChange, SVG camelCase,
  React event types).
- The framework-agnostic profiling engine is re-used as-is from react-scan
  via the ~core/* alias (no core edits); cross-package resolution handled
  purely in tsconfig + a local global.d.ts.
- tsc --noEmit passes with 0 errors.

See packages/scan-react/PORT_SPEC.md for the full porting conventions.
This is exploratory and ships nothing in production.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 3 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit da5dde3. Configure here.

if (this.dirty) this.recompute();
return this.current;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Computed never marks dirty before dependency-triggered recompute

Medium Severity

When a dependency signal changes, the Computed's reaction calls recompute() directly without first setting this.dirty = true. If any code reads the computed via peek() or .value during the notification cascade (before this particular computed's reaction runs), it will return the stale cached value because dirty is still false and read() short-circuits. The reaction constructor stores () => this.recompute() but the computed should be marked dirty so any intermediate reads see the need for recalculation.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit da5dde3. Configure here.

const ref = useRef<Computed<T> | null>(null);
if (ref.current === null) ref.current = computed(compute);
return useSignalValue(ref.current);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

useComputed captures stale compute closure forever

Medium Severity

useComputed stores the compute function in a ref that is only set once (on mount). If the compute closure captures component props or local state, those values become permanently stale. The Computed instance will always re-run the original closure, ignoring any prop/state updates that don't come through signals.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit da5dde3. Configure here.

const timeout = setTimeout(() => setDelayedValue(value), delay);

return () => clearTimeout(timeout);
}, [value, onDelay, offDelay]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

useDelayedValue stale closure skips necessary timeout

Low Severity

The useEffect reads delayedValue but omits it from the dependency array. When value toggles rapidly (e.g., true→false→true before the first timeout fires), the effect re-runs with the new value but the stale delayedValue from the closure. The value === delayedValue early-return check can incorrectly match against the stale value, causing the hook to skip scheduling the timeout and leaving delayedValue stuck at its previous state.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit da5dde3. Configure here.

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