feat/discovery
needs attentionviewing older commit2451ee0 · incrementalPR #305reviewed 2026-07-20 16:09 UTC0H · 5M · 6L · 4I- 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
- 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+103−65apps/platform/src/messages+0−0
- 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.
Findings · 15
correctness3
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.
?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.
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
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.
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
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.
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 ?? (() => {})`.
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
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.
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.
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.
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
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.
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.
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
- c1fe7b7needs attentionincremental3H · 4M · 3L2026-07-27 06:02
- 414db56safeincremental0H · 0M · 2L2026-07-23 00:52
- ea68968needs attentionincremental0H · 4M · 7L2026-07-22 23:01
- 4bdb2c9needs attentionincremental1H · 3M · 5L2026-07-21 23:22
- 6e3f255safeincremental0H · 0M · 0L2026-07-20 16:46
- cc41079blockedincremental5H · 5M · 3L2026-07-20 16:25
- 2451ee0needs attentionincremental0H · 5M · 6L2026-07-20 16:09current
- ab7c103needs attentionincremental0H · 1M · 6L2026-07-20 15:52
- 7eff611needs attentionincremental0H · 1M · 2L2026-07-18 00:41
- 246c67csafeincremental0H · 0M · 0L2026-07-17 23:58
- 7521b17safeincremental0H · 0M · 0L2026-07-17 23:35
- 1847ec8needs attentionincremental0H · 2M · 4L2026-07-17 23:28
- 5d3175cneeds attentionincremental3H · 6M · 4L2026-07-17 19:31
- d3f875dneeds attentionincremental1H · 2M · 4L2026-07-17 18:01
- 47d25faneeds attentionincremental1H · 1M · 2L2026-07-17 00:46
- 440d838needs attentionincremental4H · 9M · 9L2026-07-17 00:27
- 7466f97needs attentionincremental0H · 1M · 5L2026-07-16 23:20
- b686d58needs attentionincremental0H · 2M · 2L2026-07-16 14:24
- 9ea1446blockedincremental2H · 7M · 10L2026-07-16 13:44
- 6f39bb9needs attentionincremental0H · 2M · 7L2026-07-14 21:53
- 2eadf2aneeds attentionincremental0H · 1M · 2L2026-07-14 20:12
- 97bd08fneeds attentionincremental0H · 2M · 5L2026-07-14 19:40
- a48e3ffneeds attentionincremental5H · 8M · 6L2026-07-14 18:22
- 69473b7needs attentionincremental2H · 5M · 3L2026-07-14 01:34
- 92e4235needs attentionincremental2H · 1M · 3L2026-07-14 01:04
- 6be8018safeincremental0H · 0M · 3L2026-07-14 00:15
- 090b4daneeds attentionincremental1H · 3M · 5L2026-07-13 23:50
- fa7683bneeds attentionincremental1H · 4M · 3L2026-07-13 18:55
- d64cd72needs attentionincremental2H · 3M · 2L2026-07-13 16:27
- c1f337bneeds attentionincremental3H · 7M · 14L2026-07-13 13:30
- 46e32b2safeincremental0H · 0M · 3L2026-07-11 02:54
- 3b30949needs attentionincremental0H · 2M · 3L2026-07-11 02:39
- 9f8dbdfneeds attentionincremental3H · 5M · 5L2026-07-11 02:32
- 23191eeneeds attentionincremental5H · 12M · 7L2026-07-11 02:18
- fa9b88fneeds attentionincremental0H · 1M · 2L2026-07-11 01:30
- 6a8bf42blockedfull8H · 11M · 8L2026-07-11 01:08