← all branches

feat/discovery

needs attentionviewing older commit
2451ee0 · incrementalPR #305reviewed 2026-07-20 16:09 UTC0H · 5M · 6L · 4I
The branch
Purpose
Feature Discovery v1 for Batu Energy platform — contextual spotlights, Descubre hub, and first-run product tour, flag-gated and off by default
Goal
Gate both discovery surfaces (coachmark + hub highlight) through a single shared hook so they can't drift; fix the pull→pull stale-rect flash by pairing key+rect in a Pulse struct
Sub-goals
  • SG-1: Extract useDiscoveryGate hook centralizing flag+desktop+org gate
  • SG-2: Introduce Pulse interface (key+rect) to prevent stale-rect flash on pull→pull switch
  • SG-3: Cache anchor in anchorRef for scroll/resize tracking (avoids per-event DOM query)
  • SG-4: Extract DISCOVERY_LOCATE_RETRY_MS/MAX_ATTEMPTS constants to catalog (shared by both surfaces)
  • SG-5: Add unit tests for resolveSpotlightPage and pathForPage round-trip
The changes (whole branch)
What
5 files changed in this increment. Extracted useDiscoveryGate hook from FeatureSpotlight's inline logic; FeatureHighlight now gates on the hook (was unguarded). Replaced rect: DOMRect|null state with pulse: Pulse|null (key+rect) so the render guard catches superseded-key rects. Cached anchor in a ref for the scroll/resize effect. Extracted magic retry numbers to named catalog constants. Added spotlight-page.test.ts.
Why
Prior to this change, FeatureHighlight had no flag gate — a bookmarked ?descubre URL could fire even with the feature flag off. The gate logic was duplicated between FeatureSpotlight and what would have been FeatureHighlight. The Pulse struct solves a pull→pull bug where the previous floating-card approach jammed (both cards vanish, none reopen).
Areas
apps/platform/src/components/discovery+10365apps/platform/src/messages+00
Blast
5 files changed, +153/-65 in this increment. Whole branch: 68 files, +18828/-65 across platform, domains/core, domains/cross-domain, packages. Flag OFF by default — no user impact until flag is flipped.
flag-gated: feature-discovery-spotlights (OFF by default) no CI data for this push
typecheck· no CI data available for this pushtests· no CI data available for this pushcoderabbit· no .coderabbit.yaml configured

Findings · 15

correctness3

medium

Stale scroll/resize listeners when gate disables mid-pulse

apps/platform/src/components/discovery/FeatureHighlight.tsx:135

The scroll/resize effect depends on `[pulse !== null]`. If `enabled` flips false mid-pulse (e.g., user resizes below 1024px during the 2600ms ring), the locate effect's cleanup cancels `pulseTimer` before it fires, so `setPulse(null)` never runs. `pulse` stays non-null and the scroll/resize effect's listener keeps calling `setPulse(...)` on every scroll/resize until unmount. The render guard (`pulse.key !== key` where `key` is now null) suppresses the portal, so there's no visual glitch — but the listeners are leaked. Fix: also clear `pulse` in the locate effect's cleanup, or include `key` in the scroll/resize effect's deps.

low

?descubre param not stripped when gate disables mid-pulse

apps/platform/src/components/discovery/FeatureHighlight.tsx:113

When `enabled` flips false mid-pulse, the locate effect cleanup cancels `pulseTimer` — so `stripRef.current()` (inside the timer callback) is never called. The `?descubre` param lingers in the URL. If the user resizes back to desktop, `key` becomes non-null again and the pulse re-triggers — the exact retry behaviour the comment says to prevent. The param strip should also fire from the locate effect cleanup path.

info

Test PAGES array manually mirrors the SpotlightPage union

apps/platform/src/components/discovery/__tests__/spotlight-page.test.ts:14

TypeScript will catch a value that is NOT in the union, but not a member that is MISSING from the array. Derive from the catalog or use a Record keyed by SpotlightPage to make exhaustiveness compile-time-checkable.

security2

low

GuidedTour/RpuSubTour still have inline gate logic — drift risk

apps/platform/src/components/discovery/GuidedTour.tsx

GuidedTour and RpuSubTour compute their own enabled flag (isPreviewDeployment() && isDesktop && !!orgId) rather than consuming useDiscoveryGate. If a new condition is added to the gate (e.g., a role check), those two surfaces will silently diverge and activate when they shouldn't. Follow-up: migrate them to useDiscoveryGate.

info

orgId correctly threaded through the refactor — no cross-org leakage

apps/platform/src/components/discovery/FeatureSpotlight.tsx

orgId from useDiscoveryGate is still present in the TanStack Query key and API call params in FeatureSpotlight. The refactor did not introduce any cross-org data leakage.

conventions3

medium

Boolean expression `[pulse !== null]` as effect dependency — intent unclear

apps/platform/src/components/discovery/FeatureHighlight.tsx:135

Passing a computed boolean to the deps array is non-idiomatic and the eslint-disable comment is too terse. When `pulse` transitions from one non-null Pulse to another (pull→pull), `pulse !== null` stays `true` — the effect does NOT re-subscribe. The correctness argument is that `anchorRef.current` is updated in the locate effect before the listener fires, so reads are correct. But this is a subtle invariant. A comment explaining the intent, or using `[pulse?.key]` as the dep (which would re-subscribe on key change and is self-documenting), would make this clearer.

medium

useSyncExternalStore subscribe may return undefined — not aligned with sibling pattern

apps/platform/src/components/discovery/use-discovery-gate.ts:31

`posthog?.onFeatureFlags?.(() => onStoreChange())` returns `undefined` when posthog is null. The returned cleanup `() => unsubscribe?.()` is technically valid (no-op via optional chaining), but GuidedTour.tsx and RpuSubTour.tsx use the explicit `?? (() => {})` guard pattern to return a stable no-op. Align for consistency: `return unsubscribe ?? (() => {})`.

info

useEffect with no dependency array should have an explanatory comment

apps/platform/src/components/discovery/FeatureHighlight.tsx:68

The effect that updates `stripRef.current` and `pageRef.current` runs after every render intentionally (to keep refs current without adding to the locate-effect deps). A brief comment would prevent a future reader from 'fixing' it by adding deps and introducing stale closures.

tests4

medium

useDiscoveryGate has no unit test

apps/platform/src/components/discovery/use-discovery-gate.ts

useDiscoveryGate is the single gate for both FeatureHighlight and FeatureSpotlight — if it drifts, both surfaces misfire silently. The boolean result `(flagEnabled || isPreviewDeployment()) && isDesktop && !!orgId` is the highest-value pure predicate added in this diff. Suggested: extract `computeGate(flagEnabled, isDesktop, orgId, isPreview)` as a pure function and test all 8 on/off combinations; leave the hook as thin useSyncExternalStore wiring.

medium

pull→pull guard (pulse.key !== key) has no render test

apps/platform/src/components/discovery/FeatureHighlight.tsx:139

The key correctness invariant — a DOMRect measured for a superseded key never renders the ring at the wrong control — is enforced by a single line. It motivated the entire Pulse struct rewrite. A focused render test (jsdom + RTL, mocking useDiscoveryGate + useSearchParams) simulating a pull→pull key change would pin this regression path. Without it, a future refactor of the useState/useEffect interplay could silently re-introduce the pull→pull flicker.

low

PAGES array in spotlight-page.test.ts is not exhaustive — new pages silently skipped

apps/platform/src/components/discovery/__tests__/spotlight-page.test.ts:15

The array is typed `SpotlightPage[]` but TS won't catch a missing union member — only a wrong one. If a fourth SpotlightPage is added, the round-trip test silently skips it. Use `as const satisfies readonly SpotlightPage[]` or key by a Record to enforce exhaustiveness at compile time.

low

pathForPage silent fallback to '/bills/contratos' is untested

apps/platform/src/components/discovery/__tests__/spotlight-page.test.ts

`pathForPage` falls back to `'/bills/contratos'` for unknown keys. The current type signature makes it hard to pass an unknown value, but the fallback exists. A test making the contract explicit (or asserting it throws) prevents silent wrong-navigation if the fallback is ever changed.

improvement3

medium

matchMedia creates two separate objects in subscribe vs getSnapshot

apps/platform/src/components/discovery/use-discovery-gate.ts:44

The `subscribe` callback calls `window.matchMedia(DESKTOP_QUERY)` and attaches a listener to it. `getSnapshot` calls `window.matchMedia(DESKTOP_QUERY)` again, creating a second object. Modern browsers return the same underlying `MediaQueryList` for identical query strings, but this relies on implementation detail. The standard pattern is to hoist to module scope: `const desktopMq = typeof window !== 'undefined' ? window.matchMedia(DESKTOP_QUERY) : null` so both share the same object and the SSR guard is written once.

low

isPreviewDeployment() called on every render — hoist to module scope

apps/platform/src/components/discovery/use-discovery-gate.ts:56

`isPreviewDeployment()` reads `process.env.NEXT_PUBLIC_VERCEL_ENV`, which is inlined at build time — it's effectively a constant after compilation. Hoisting it as `const IS_PREVIEW = isPreviewDeployment()` at module scope makes the build-time nature explicit and avoids the trivial function-call overhead on every render of every mounted gate consumer.

low

Two matchMedia subscriptions when both surfaces are mounted simultaneously

apps/platform/src/components/discovery/use-discovery-gate.ts:44

FeatureSpotlight and FeatureHighlight both mount useDiscoveryGate in the dashboard layout, creating two separate matchMedia subscriptions for the same `(min-width: 1024px)` query. A module-level singleton subscribe/getSnapshot (the standard useSyncExternalStore pattern) would deduplicate. Negligible for two callers, but worth noting as discovery surfaces grow.

History · 36 commits

  1. c1fe7b7needs attentionincremental3H · 4M · 3L2026-07-27 06:02
  2. 414db56safeincremental0H · 0M · 2L2026-07-23 00:52
  3. ea68968needs attentionincremental0H · 4M · 7L2026-07-22 23:01
  4. 4bdb2c9needs attentionincremental1H · 3M · 5L2026-07-21 23:22
  5. 6e3f255safeincremental0H · 0M · 0L2026-07-20 16:46
  6. cc41079blockedincremental5H · 5M · 3L2026-07-20 16:25
  7. 2451ee0needs attentionincremental0H · 5M · 6L2026-07-20 16:09current
  8. ab7c103needs attentionincremental0H · 1M · 6L2026-07-20 15:52
  9. 7eff611needs attentionincremental0H · 1M · 2L2026-07-18 00:41
  10. 246c67csafeincremental0H · 0M · 0L2026-07-17 23:58
  11. 7521b17safeincremental0H · 0M · 0L2026-07-17 23:35
  12. 1847ec8needs attentionincremental0H · 2M · 4L2026-07-17 23:28
  13. 5d3175cneeds attentionincremental3H · 6M · 4L2026-07-17 19:31
  14. d3f875dneeds attentionincremental1H · 2M · 4L2026-07-17 18:01
  15. 47d25faneeds attentionincremental1H · 1M · 2L2026-07-17 00:46
  16. 440d838needs attentionincremental4H · 9M · 9L2026-07-17 00:27
  17. 7466f97needs attentionincremental0H · 1M · 5L2026-07-16 23:20
  18. b686d58needs attentionincremental0H · 2M · 2L2026-07-16 14:24
  19. 9ea1446blockedincremental2H · 7M · 10L2026-07-16 13:44
  20. 6f39bb9needs attentionincremental0H · 2M · 7L2026-07-14 21:53
  21. 2eadf2aneeds attentionincremental0H · 1M · 2L2026-07-14 20:12
  22. 97bd08fneeds attentionincremental0H · 2M · 5L2026-07-14 19:40
  23. a48e3ffneeds attentionincremental5H · 8M · 6L2026-07-14 18:22
  24. 69473b7needs attentionincremental2H · 5M · 3L2026-07-14 01:34
  25. 92e4235needs attentionincremental2H · 1M · 3L2026-07-14 01:04
  26. 6be8018safeincremental0H · 0M · 3L2026-07-14 00:15
  27. 090b4daneeds attentionincremental1H · 3M · 5L2026-07-13 23:50
  28. fa7683bneeds attentionincremental1H · 4M · 3L2026-07-13 18:55
  29. d64cd72needs attentionincremental2H · 3M · 2L2026-07-13 16:27
  30. c1f337bneeds attentionincremental3H · 7M · 14L2026-07-13 13:30
  31. 46e32b2safeincremental0H · 0M · 3L2026-07-11 02:54
  32. 3b30949needs attentionincremental0H · 2M · 3L2026-07-11 02:39
  33. 9f8dbdfneeds attentionincremental3H · 5M · 5L2026-07-11 02:32
  34. 23191eeneeds attentionincremental5H · 12M · 7L2026-07-11 02:18
  35. fa9b88fneeds attentionincremental0H · 1M · 2L2026-07-11 01:30
  36. 6a8bf42blockedfull8H · 11M · 8L2026-07-11 01:08