Skip to content

chore(ui): drive React Doctor to zero in web components/hooks (#243) - #503

Merged
thomasluizon merged 3 commits into
mainfrom
chore/rd-web-components-rest
Jul 14, 2026
Merged

chore(ui): drive React Doctor to zero in web components/hooks (#243)#503
thomasluizon merged 3 commits into
mainfrom
chore/rd-web-components-rest

Conversation

@thomasluizon

Copy link
Copy Markdown
Owner

What

React Doctor burn-down for bucket B2apps/web/components/** (except habits/ & ui/) + apps/web/hooks/**. Drives all 105 React Doctor findings in that file-set to 0 (verified by a full apps/web re-scan).

Fixed properly

Rule Count Approach
effect-needs-cleanup 6 Gamification celebration/toast effects now track nested timers in the same ref and return a cleanup from the timer-creating effect; removed redundant unmount-only effects
use-lazy-motion 7 motionm + <LazyMotion>; domAnimation everywhere except route-transition-shell (mode="popLayout" needs domMax)
no-inline-exhaustive-style 13 Hoisted static style objects to module-scope CSSProperties consts
no-tiny-text 16 Bumped sub-12px text to the DESIGN.md 12px type-role floor (all in truncate/ellipsis contexts, so no overflow)
prefer-use-effect-event 3 Wrapped changing prop callbacks in useEffectEvent
only-export-components 4 Removed unused exports; extracted shared toSectionStatus/SectionStatusinsights-section-status.ts and trendHeadlineinsights-headline-model.ts (7 + 2 importers rewired)
no-prop-callback-in-render 3 Goal-drawer deep-link actions moved from render into one guarded effect
no-impure-state-updater 1 Login resend-countdown side effects moved out of the setState updater into the interval callback (ref-driven)
query-mutation-missing-invalidation 1 Marketing-consent mutation reconciles via onSettled invalidate
no-giant-component 1 Extracted NotificationRow from NotificationBell
js-combine-iterations / js-hoist-intl / no-array-index-as-key / button-has-type / control-has-associated-label / rerender-lazy-state-init / rerender-state-only-in-handlers / prefer-module-scope-static-value / client-localstorage-no-version / no-locale-format-in-render 14 Mechanical (single-pass flatMap, memoized/hoisted Intl, stable keys, explicit type, aria-label, lazy init, useRef, hoisted const, versioned key, shared useDateFormat)

Fixed: ~78 · Suppressed: ~27

Justified suppressions

react-doctor-disable-next-line + a WHY comment linking #243, only where the rule is a genuine false positive or a deliberate design choice:

  • exhaustive-deps (14) — deps already track query.data / profile.* through an alias (const profile = query.data) that React Doctor does not resolve; adding the raw member expression is semantically identical and uglier. Never blanket-added a dep.
  • no-unguarded-browser-global (4)createPortal / localStorage reached only behind a client-only gate (useIsClient, a measured-viewport gate, or the repo's canonical 'localStorage' in globalThis guard, chore(ui): fix Sonar smells S6479/S4624/S7741 (keys, nested templates, typeof-undefined) #490).
  • query-mutation-missing-invalidation (2)useReportUser / useSuggestTags mutate no client-cached data.
  • prefer-html-dialog (2) + no-large-animated-blur (2) — a non-modal desktop-only copilot rail and a hidden md:* FAB; native <dialog> semantics and the phone-GPU concern don't apply.
  • no-many-boolean-props (1) — four orthogonal range-picker state flags; consumer lives in another bucket.
  • effect-needs-cleanup (1) — tour MutationObserver lifecycle is ref-managed via resetSessionState.
  • nextjs-no-client-side-redirect (1) — tour orchestrator drives in-app navigation as steps advance.
  • no-pass-data/live-state-to-parent (2) — reusable controlled code-entry primitive; one deduped effect is the single notification point.

Verification

  • npm run lint (apps/web) — clean (3 pre-existing warnings in files I didn't touch)
  • web tsc --noEmit — clean
  • vitest run (apps/web) — 2246 pass (2 pre-existing server-fetch timing flakes in lib/, outside this bucket, self-heal on --retry)
  • React Doctor diff gate (--scope changed --blocking error) — 0 new
  • Full apps/web React Doctor re-scan — 0 findings remain in the B2 file-set

Notes

  • __tests__/.../marketing-consent-prompt.test.tsx updated because the use-lazy-motion change requires the motion/react mock to expose m/LazyMotion/domAnimation (+ the useProfile mock's invalidate for the new onSettled).
  • Web-only mechanical fixes have no mobile mirror (mobile RD rules differ: rn-*); the behavior-adjacent fixes (consent invalidate, impure-updater, prop-callback) will be matched by the mobile buckets hitting the same rules.

Refs #243 (React Doctor burn-down: web components/hooks)

🤖 Generated with Claude Code

React Doctor burn-down for bucket B2 (apps/web/components/** except
habits/ & ui/, plus apps/web/hooks/**): 105 findings driven to 0.

Fixed properly:
- effect-needs-cleanup (6): gamification celebration/toast timers now
  track nested timers in the same ref and return a cleanup from the
  timer-creating effect (removed redundant unmount-only effects).
- prefer-use-effect-event (3): wrapped changing prop callbacks in
  useEffectEvent so effects no longer re-subscribe on parent redraws.
- use-lazy-motion (7): swapped motion -> m + <LazyMotion>; domAnimation
  everywhere except route-transition-shell (popLayout needs domMax).
- no-inline-exhaustive-style (13): hoisted static style objects to
  module-scope CSSProperties consts.
- no-tiny-text (16 + extras): bumped sub-12px text to the DESIGN.md
  12px type-role floor (truncate/ellipsis contexts, so no overflow).
- only-export-components (4): removed unused exports; extracted the
  shared toSectionStatus/SectionStatus and trendHeadline helpers into
  insights-section-status.ts and insights-headline-model.ts.
- no-giant-component (1): extracted NotificationRow from NotificationBell.
- no-prop-callback-in-render (3): goal-drawer deep-link actions moved
  from render into a single guarded effect.
- no-impure-state-updater (1): login resend countdown side effects moved
  out of the setState updater into the interval callback (ref-driven).
- query-mutation-missing-invalidation (1): marketing consent mutation
  now reconciles via onSettled invalidate.
- js-combine-iterations (3), js-hoist-intl (2), no-array-index-as-key (2),
  button-has-type (1), control-has-associated-label (1),
  rerender-lazy-state-init (1), rerender-state-only-in-handlers (1),
  prefer-module-scope-static-value (1), client-localstorage-no-version (1),
  no-locale-format-in-render (1, via shared useDateFormat).

Justified suppressions (react-doctor-disable-next-line + #243 WHY URL,
only where the rule is a genuine false positive or deliberate choice):
- exhaustive-deps (14): deps already track query.data/profile.* via an
  alias react-doctor does not resolve; adding the raw member expression
  is semantically identical and uglier.
- no-unguarded-browser-global (4): createPortal/localStorage reached only
  behind a client-only render gate (useIsClient / viewport / the repo's
  canonical 'localStorage' in globalThis guard).
- query-mutation-missing-invalidation (2): report-user and suggest-tags
  mutate no client-cached data.
- prefer-html-dialog (2), no-large-animated-blur (2): desktop-only
  non-modal rail / FAB; native dialog and phone GPU concerns don't apply.
- no-many-boolean-props (1): four orthogonal range-picker state flags;
  effect-needs-cleanup (1): tour observer lifecycle is ref-managed;
  nextjs-no-client-side-redirect (1): tour orchestrator navigation;
  no-pass-data/live-state-to-parent (2): reusable code-entry primitive.

Refs #243 (React Doctor burn-down: web components/hooks)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
orbit-ui-mobile-web Ignored Ignored Jul 13, 2026 11:45pm

Request Review

claude[bot]
claude Bot previously requested changes Jul 13, 2026

@claude claude 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.

/pr-review — PR #503

chore(ui): drive React Doctor to zero in web components/hooks (#243) — web-only mechanical React Doctor burn-down (motion→m/LazyMotion, hoisted style consts, sub-12px→12px floor, stable keys, useEffectEvent, extracted helpers) across apps/web/components/** + apps/web/hooks/**.

Decision: Request changes

Two High findings — the mandatory web↔mobile parity rule (root CLAUDE.md) is violated by two of the "behavior-adjacent" fixes bundled into this otherwise-mechanical pass. Everything else in the diff is clean.


Findings

[High] Marketing-consent cache invalidation fix not mirrored to mobile
· dimension: 9 — Parity (web ↔ mobile)
· location: apps/mobile/components/marketing-consent/marketing-consent-prompt.tsx:52-74
· issue: Web added onSettled: () => invalidate() to the consent-update mutation (apps/web/components/marketing-consent/marketing-consent-prompt.tsx:57-62), fixing a real query-mutation-missing-invalidation bug — the profile cache never reconciled with the server after a consent PUT. Mobile's useMutation block has no onSettled, even though useProfile() already exposes invalidate on mobile (apps/mobile/hooks/use-profile.ts:43-59) — the fix is a one-line, zero-adapter port.
· risk: Mobile's profile cache stays stale after every consent toggle; a background refetch or navigation can revert the UI to a value that no longer matches the server.
· fix: Add invalidate to the mobile useProfile() destructure and onSettled: () => invalidate() to the mutation, mirroring the web change.
· reference: root CLAUDE.md "Cross-platform parity (MANDATORY)"

[High] Impure countdown state updater not fixed on mobile
· dimension: 9 — Parity (web ↔ mobile)
· location: apps/mobile/hooks/use-login-code-entry.ts:54-63
· issue: Web refactored the resend-countdown interval to move setCanResend(true) / clearResendTimer() out of the setResendCountdown functional updater and into a ref-backed pure update (apps/web/hooks/use-login-code-entry.ts:69-88), fixing a no-impure-state-updater violation — React may invoke a functional updater more than once (Strict Mode, concurrent features), and the old code fired side effects (setCanResend, clearResendTimer) from inside it. Mobile's use-login-code-entry.ts is otherwise byte-identical to web's pre-fix version and still has the same impure updater at lines 55-62.
· risk: Under React 19's concurrent re-invocation of updaters, clearResendTimer()/setCanResend(true) can fire more than once per tick, or the interval can be cleared prematurely — a live correctness bug on the exact platform (mobile login OTP entry) most likely to hit slow/replayed renders.
· fix: Port web's ref-driven fix verbatim (resendCountdownRef tracking the count, side effects only in the setInterval callback body, not the updater).
· reference: root CLAUDE.md "Cross-platform parity (MANDATORY)"

Both gaps are acknowledged in the PR body ("the behavior-adjacent fixes … will be matched by the mobile buckets hitting the same rules") — but root CLAUDE.md requires parity fixes to land "in the same task," with no carve-out for a tracked follow-up bucket. The third behavior-adjacent item (goal-drawer deep-link initialAction move-out-of-render, apps/web/components/goals/goal-detail-drawer/use-goal-drawer-initial-action.ts) is correctly N/A — mobile has no equivalent deep-link-driven drawer action at all, so there's nothing to mirror.

[Medium] GoalStatusBadge font-size bump breaks the locked Badge primitive spec
· dimension: 8 — DESIGN.md / AI-slop
· location: apps/web/components/goals/goal-status-badge.tsx:8,17
· issue: This PR's mechanical "sub-12px floor" pass bumped GoalStatusBadge from 10.5px→12px and rewrote its JSDoc to claim "12/600" as the spec. But DESIGN.md's Primitives table locks the Badge shape at 10.5/600 (DESIGN.md:127), and apps/web/components/ui/badge.tsx:41 still correctly renders fontSize: 10.5. The mechanical floor rule overrode a primitive with its own explicit locked dimension without checking it against the primitives table.
· risk: Two divergent badge sizes now exist for the same visual primitive (10.5 in ui/badge.tsx, 12 in goal-status-badge.tsx); goal-status pills no longer match sibling badges pixel-for-pixel anywhere else in the app. Not a layout/truncation risk (confirmed shrink-0 next to truncate/min-w-0 siblings) — the defect is the token/spec drift itself.
· fix: Revert goal-status-badge.tsx to 10.5px (and its JSDoc) to match the locked Badge primitive, or — if 12px is intentionally correct — update DESIGN.md:127's Badge spec and ui/badge.tsx to match, in the same PR.
· reference: DESIGN.md:127


Dimensions checked, no findings

  • Correctness — spot-checked the non-mechanical refactors (use-goal-drawer-initial-action.ts render→effect consolidation, breakdown-suggestion.tsx stable-id keys, pending-operation-card.tsx state→ref for confirmationToken, gamification celebration effect/cleanup consolidation) — all behaviorally equivalent to their prior render paths, cleanup preserved.
  • Dead/stale codeonly-export-components removals (goalTypeOptions, copyToClipboard) and the insights-section-status.ts / insights-headline-model.ts extractions are correctly re-wired at all call sites.
  • Comment policy — all react-doctor-disable-next-line suppressions carry a WHY + issue-#243 URL per no-comments.cjs.
  • Type safety / console.log — none introduced.
  • DESIGN.md (rest) — no raw --slate-*, no hardcoded violet rgba, no transition-all/h-screen, all motionm migrations correctly wrapped in LazyMotion. Em dashes found in calendar-time-grid.tsx are inside a JSDoc comment, not user-facing copy.
  • i18n — no new user-facing strings; diff doesn't touch either locale file. N/A.
  • Contract drift / backward-compat — diff touches no packages/shared types or orbit-api. N/A (orbit-api not checked out in this job either way).
  • Security — no orbit-api code touched. N/A.
  • Backend hard rules — N/A, no orbit-api.
  • FEATURES.md parity — pure refactor/bugfix bucket, no feature surface change. N/A.

Not verifiable in CI

  • Cross-repo contract-aligner / orbit-api side of backward-compat guard — sibling repo not checked out. N/A for this diff regardless (no shared-type changes).

#243)

- Mirror the two behavior fixes to mobile (mandatory web/mobile parity):
  - marketing-consent: add `onSettled: () => invalidate()` so the profile
    cache reconciles after a consent PUT (was web-only).
  - use-login-code-entry: port the ref-driven resend countdown so the
    setState updater stays pure (no side effects re-run under concurrent
    re-invocation).
- Revert the sub-12px "floor" bump on two DESIGN.md-locked primitives and
  justifiably suppress no-tiny-text instead:
  - GoalStatusBadge back to 10.5/600 (Badge primitive, DESIGN.md:127).
  - TabBar label back to 11 (DESIGN.md:135).

Refs #243

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@thomasluizon

Copy link
Copy Markdown
Owner Author

Addressed all three review findings in 3391af6e:

  • [High] Marketing-consent invalidate parity — mirrored onSettled: () => invalidate() to apps/mobile/components/marketing-consent/marketing-consent-prompt.tsx (added invalidate to the useProfile() destructure). Mobile tests pass.
  • [High] Impure countdown updater parity — ported the ref-driven fix to apps/mobile/hooks/use-login-code-entry.ts verbatim (side effects only in the interval callback, resendCountdownRef tracks the count). Mobile use-login-code-entry + marketing-consent-prompt tests pass.
  • [Medium] GoalStatusBadge spec drift — reverted to the locked Badge primitive 10.5/600 (DESIGN.md:127), restored the JSDoc, and suppressed no-tiny-text there with a DESIGN.md-cited WHY. Also caught and reverted the same over-bump on the TabBar label (11px, DESIGN.md:135) — same locked-primitive treatment.

React Doctor: file-set still at 0 (scoped scan clean, incl. the two new suppressions). Web lint/typecheck and mobile lint/typecheck clean (the one mobile tsc error is a pre-existing missing react-native-view-shot type, unrelated). SonarCloud remains a coverage-only gate (70.9% new-code vs 80%; ratings A/A/A, 0% duplication) as expected for a mechanical burn-down.

@thomasluizon
thomasluizon dismissed claude[bot]’s stale review July 13, 2026 23:41

Addressed in 3391af6 (mobile parity mirrors for the consent invalidate + impure-updater fixes; GoalStatusBadge/TabBar reverted to the locked DESIGN.md primitive sizes with justified no-tiny-text suppressions). The claude re-review ran against 3391af6 and passed clean with no findings; dismissing this verdict on the superseded commit 47aab54.

…ts-rest

# Conflicts:
#	apps/mobile/hooks/use-login-code-entry.ts
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
70.1% Coverage on New Code (required ≥ 80%)

See analysis details on SonarQube Cloud

@claude claude 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.

/pr-review — PR #503

chore(ui): drive React Doctor to zero in web components/hooks (#243) — web-only mechanical React Doctor burn-down (motion→m/LazyMotion, hoisted style consts, sub-12px→12px floor, stable keys, useEffectEvent, extracted helpers) across apps/web/components/** + apps/web/hooks/**.

Decision: Approve

Follow-up on prior review

The previous automated review (dismissed) flagged two High findings — mobile parity gaps for the marketing-consent cache invalidation and the login-countdown pure-updater fix. Both are confirmed resolved in commit 3391af6e ("fix(ui): address PR review - mobile parity + locked Badge/TabBar sizes"):

  • apps/mobile/components/marketing-consent/marketing-consent-prompt.tsx now destructures invalidate from useProfile() and calls it in onSettled, matching web.
  • apps/mobile/hooks/use-login-code-entry.ts now uses the same ref-driven (resendCountdownRef) pure updater as web — side effects (clearResendTimer, setCanResend) moved out of the functional setState updater.

No outstanding parity gaps found in this diff.

Summary

React Doctor lint burn-down for apps/web/components/** (excluding habits/ and ui/) + apps/web/hooks/**, driving 105 findings to zero. Overwhelmingly mechanical: hoisting inline style={{...}} objects to module-scope CSSProperties consts, motionm + <LazyMotion> code-splitting, a 12px type-role floor for sub-12px text, stable list keys, useEffectEvent wrappers, effect-cleanup fixes, and justified react-doctor-disable-next-line suppressions each carrying a WHY note linking #243. Two behavior-preserving refactors (login resend-countdown updater, goal-drawer deep-link effect) and one real bug fix (marketing-consent cache invalidation on settle) are included, and both behavior-adjacent fixes are correctly mirrored to apps/mobile (verified above). Dead-export removals (copyToClipboard, goalTypeOptions) have zero remaining importers, and the insights-section/insights-headline module split rewired every call site correctly.

Findings

Critical

None

High

None

Medium

None

Low / Info

  • Infoapps/web/components/settings/public-profile-settings.tsx:9-19: a new const regenerateButtonStyle: CSSProperties = {...} declaration sits between two import statements. ES module imports hoist regardless of lexical position so this is functionally inert, and no import/order/import/first lint rule is enabled in this workspace — pure code-organization nit, not blocking.

Subagents

Agent Verdict
parity-checker PAIRED — both behavior-adjacent fixes mirrored to mobile (confirmed via commit 3391af6)
i18n-syncer N/A — no new i18n keys; the one new aria-label reuses an existing habits.form.frequency key present in both locales
contract-aligner N/A — no packages/shared/src/types/* or orbit-api DTO changes
security-reviewer N/A — no orbit-api changes
design-reviewer PASS

Design detail: both DESIGN.md citations in suppression comments (goal-status-badge.tsx → DESIGN.md:127 Badge 10.5/600; bottom-tab-bar.tsx → DESIGN.md:135 TabBar label 11) are accurate. Font-size bumps to the 12px floor land in truncating/fixed-width contexts with no overflow risk observed. No raw/off-token colors introduced by the style-object hoists.

Validation

Check Result
Lint N/A — not runnable in this review sandbox; PR author reports clean
Type check N/A — same restriction; PR author reports clean tsc --noEmit
Tests N/A — same restriction; PR author reports vitest run 2246 pass
Build (api) N/A — orbit-api not touched, not checked out

Manual spot-verification performed in place of the toolchain: grepped for stray importers of the two de-exported helpers (copyToClipboard, goalTypeOptions) — zero found; grepped every ./insights-section import site to confirm toSectionStatus/trendHeadline rewired all 7 consumers; confirmed both DESIGN.md line citations; diffed apps/mobile/hooks/use-login-code-entry.ts and apps/mobile/components/marketing-consent/marketing-consent-prompt.tsx against their web counterparts to confirm the parity fixes landed correctly.

Deferred — N/A dimensions & files not verdicted

  • Contract drift + backward-compat (#11) — N/A, no packages/shared/src/types/* or orbit-api DTO touched.
  • Backend hard rules (#13) — N/A, orbit-api not in this diff/checkout.
  • FEATURES.md parity (#14) — N/A, no user-facing feature added/changed/removed.
  • Validation (Phase 7) — could not execute lint/type-check/test in this sandbox (package-manager execution requires unavailable approval); relied on PR's self-reported results plus targeted manual verification.
  • All changed files were read and given a verdict; nothing was skipped silently.

What's good

  • Every suppression comment carries a specific, verifiable WHY (aliasing explanation, DESIGN.md line citation, or architectural rationale) plus the tracking issue URL.
  • Both parity gaps from the prior review round were fixed correctly and verified byte-for-byte against the web implementation.
  • Dead-export removals were verified against zero remaining call sites.

Recommendation

Merge as-is. Optionally, in a follow-up, move regenerateButtonStyle in public-profile-settings.tsx below the import block — cosmetic only.

@thomasluizon
thomasluizon merged commit 0f24381 into main Jul 14, 2026
19 of 20 checks passed
@thomasluizon
thomasluizon deleted the chore/rd-web-components-rest branch July 14, 2026 00:54
thomasluizon added a commit that referenced this pull request Jul 14, 2026
…est + shared (#243) (#506)

React Doctor burn-down for bucket B5 (apps/mobile/components/** except ui/,
habits/, habit-list*; plus packages/shared/src/**). Drives the file-set to 0
findings — fixing properly where safe and using justified
react-doctor-disable-next-line suppressions (each linking #243) only for genuine
false positives or deliberate, coupled choices.

Fixed (behavior-preserving):
- shared: memoized Intl formatter cache (intl-format-cache.ts) wired into
  locale-format/goal-metrics/subscription-pricing (js-hoist-intl); filter+map ->
  flatMap single passes (js-combine-iterations); direct imports off barrels
  (no-barrel-import).
- mobile: shadow* glow -> boxShadow on the 5 celebration overlays (parity with
  web's boxShadow), matching the shared primary/frozen tint; useEffectEvent for
  deferred timer/debounce callbacks (achievement-toast, level-up-overlay,
  welcome-back-toast, today-habits-header); real setTimeout cleanup in
  welcome-back-toast; TouchableOpacity -> Pressable (chat-input-area,
  goal/edit-goal deadline fields, goal-detail-sections); Set lookup for tag
  filter; lazy useState init (breakdown-suggestion); state -> ref for
  handler-only confirmationToken (pending-operation-card); module-local
  non-component exports (goal-type-selector, notification-row);
  onSettled: invalidate() on the marketing-consent-section mutation (matches
  #503's marketing-consent-prompt fix); precise exhaustive-deps
  (goal-detail-drawer, use-goal-status-actions, referral-drawer, tour-provider).

Suppressed with WHY (FP / deliberate): rn-prefer-reanimated x21 (pinned
worklets 0.10.0 / reanimated 4.5.0 ABI, coupled to shared lib/motion.ts);
rn-prefer-expo-image x4 (expo-image not installed; transient/static images);
tour-tooltip shadowsV2.shadow3 offset override; today-habits-header +
chat-input-bar + pricing-section no-many-boolean-props; scrollview chip rows;
tour-provider no-giant-component + rn-no-dimensions-get; welcome-back-toast
effect-needs-cleanup (RD can't trace the clear through the async helper);
error-utils js-set-map-lookups (String.includes, not array membership);
today-habits-header prefer-explicit-variants.

Refs #243 (React Doctor burn-down: mobile rest + shared)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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