|
1 | 1 | "use client" |
2 | | -import { useEffect, useMemo, useRef } from "react" |
| 2 | +import { useEffect, useMemo, useRef, useSyncExternalStore } from "react" |
3 | 3 | import type { Datum } from "../charts/shared/datumTypes" |
4 | 4 | import { profileData, type ProfileDataOptions } from "./profileData" |
5 | 5 | import { suggestCharts, type SuggestChartsOptions } from "./suggestCharts" |
6 | 6 | import type { ChartDataProfile, Suggestion } from "./chartCapabilityTypes" |
7 | | -import { getConversationArcStore } from "./conversationArc" |
| 7 | +import { |
| 8 | + getConversationArcStore, |
| 9 | + subscribeToConversationArcChange, |
| 10 | +} from "./conversationArc" |
| 11 | + |
| 12 | +// Snapshot fn for the change subscription — `useSyncExternalStore` |
| 13 | +// compares with `Object.is`, so same-value mutations (every record() |
| 14 | +// while enabled, etc.) don't re-render the consumer. Only actual |
| 15 | +// enable/disable flips do. Lives outside the hook so the function |
| 16 | +// reference stays stable. |
| 17 | +const subscribeArc = (onChange: () => void) => subscribeToConversationArcChange(onChange) |
| 18 | +const getArcEnabled = () => getConversationArcStore().enabled |
| 19 | +const getArcEnabledOnServer = () => false |
8 | 20 |
|
9 | 21 | export interface UseChartSuggestionsOptions extends SuggestChartsOptions, ProfileDataOptions {} |
10 | 22 |
|
@@ -60,27 +72,64 @@ export function useChartSuggestions( |
60 | 72 | // until a consumer calls `enableConversationArc()` — at which point |
61 | 73 | // every recomputation of `useChartSuggestions` lands in the buffer. |
62 | 74 | // |
63 | | - // Dedup by the (component-list, intent, audience-target) signature |
64 | | - // so React's strict-mode double-invocation doesn't double-stamp, |
65 | | - // and a stable suggestions list across renders doesn't either. |
| 75 | + // Dedup happens in two layers: |
| 76 | + // 1. A per-instance signature ref catches stable-suggestions |
| 77 | + // re-renders within one mounted hook. |
| 78 | + // 2. A peek at the store's most recent `suggestion-shown` event |
| 79 | + // catches React StrictMode's mount → unmount → remount cycle |
| 80 | + // (where the per-instance ref resets) plus cross-instance |
| 81 | + // duplicates from a parent re-mounting children. |
| 82 | + // The signature also resets when recording is disabled, so a |
| 83 | + // mid-session enable correctly emits the current suggestions — |
| 84 | + // tracked via `useSyncExternalStore` so the effect re-runs when |
| 85 | + // the enabled flag flips. |
| 86 | + const arcEnabled = useSyncExternalStore(subscribeArc, getArcEnabled, getArcEnabledOnServer) |
66 | 87 | const lastSignatureRef = useRef<string | null>(null) |
67 | 88 | useEffect(() => { |
68 | 89 | if (suggestions.length === 0) { |
69 | 90 | lastSignatureRef.current = null |
70 | 91 | return |
71 | 92 | } |
| 93 | + if (!arcEnabled) { |
| 94 | + // Drop the signature so the next enable cycle re-emits the |
| 95 | + // current ranking. Otherwise a consumer that enables after |
| 96 | + // the suggester has already run sees no `suggestion-shown` at |
| 97 | + // all. |
| 98 | + lastSignatureRef.current = null |
| 99 | + return |
| 100 | + } |
| 101 | + const store = getConversationArcStore() |
| 102 | + |
72 | 103 | const audienceTarget = audience?.name ?? (audience ? "custom" : undefined) |
73 | 104 | const signature = `${intent ?? ""}|${audienceTarget ?? ""}|${suggestions.map((s) => s.component).join(",")}` |
74 | 105 | if (signature === lastSignatureRef.current) return |
| 106 | + |
| 107 | + // Cross-instance dedup: if the most recent buffered |
| 108 | + // suggestion-shown event matches this signature, skip. Catches |
| 109 | + // StrictMode remounts and parent re-mounts that would otherwise |
| 110 | + // double-stamp the same ranking. |
| 111 | + const recent = store.getEvents() |
| 112 | + for (let i = recent.length - 1; i >= 0; i--) { |
| 113 | + const e = recent[i] |
| 114 | + if (e.type !== "suggestion-shown") continue |
| 115 | + const recentIntent = Array.isArray(e.intent) ? e.intent.join(",") : (e.intent ?? "") |
| 116 | + const recentSignature = `${recentIntent}|${e.audience ?? ""}|${e.components.join(",")}` |
| 117 | + if (recentSignature === signature) { |
| 118 | + lastSignatureRef.current = signature |
| 119 | + return |
| 120 | + } |
| 121 | + break // only check the most recent one |
| 122 | + } |
| 123 | + |
75 | 124 | lastSignatureRef.current = signature |
76 | | - getConversationArcStore().record({ |
| 125 | + store.record({ |
77 | 126 | type: "suggestion-shown", |
78 | 127 | intent, |
79 | 128 | components: suggestions.map((s) => s.component), |
80 | 129 | topScore: suggestions[0]?.score, |
81 | 130 | audience: audienceTarget, |
82 | 131 | }) |
83 | | - }, [suggestions, intent, audience]) |
| 132 | + }, [suggestions, intent, audience, arcEnabled]) |
84 | 133 |
|
85 | 134 | return { suggestions, profile } |
86 | 135 | } |
0 commit comments