@@ -74,6 +74,11 @@ const channelHistorySchema: LocalStorageSchema<StoredChannelHistory> = {
7474 } ,
7575} ;
7676
77+ // Coalesce bursts of channel messages into one write instead of
78+ // re-serializing + persisting the whole history on every message. Mirrors
79+ // output.tsx's SAVE_DEBOUNCE_MS.
80+ const SAVE_DEBOUNCE_MS = 500 ;
81+
7782export const MAX_ALL_BUFFER_MESSAGES = 1000 ;
7883export const MAX_CHANNEL_BUFFER_MESSAGES = 500 ;
7984export const MAX_PERSISTED_ALL_MESSAGES = 200 ;
@@ -168,6 +173,43 @@ export const useChannelHistory = () => {
168173 const [ linkPickerLinks , setLinkPickerLinks ] = useState < ExtractedLink [ ] | null > ( null ) ;
169174 const lastKeyPress = useRef < { key : string ; time : number ; count : number } | null > ( null ) ;
170175 const lastProcessedChannelEntryId = useRef ( 0 ) ;
176+ const saveTimerRef = useRef < number | undefined > ( undefined ) ;
177+ // Always holds the latest live state so the debounced timer callback and
178+ // the unmount flush never persist stale values. Serialization happens in
179+ // flushSave, not per change — that's the expensive half of the work.
180+ const latestStateRef = useRef < {
181+ buffers : Map < string , Buffer > ;
182+ bufferOrder : string [ ] ;
183+ currentBufferIndex : number ;
184+ timestampsEnabled : boolean ;
185+ } | null > ( null ) ;
186+
187+ const cancelScheduledSave = useCallback ( ( ) => {
188+ if ( saveTimerRef . current !== undefined ) {
189+ window . clearTimeout ( saveTimerRef . current ) ;
190+ saveTimerRef . current = undefined ;
191+ }
192+ } , [ ] ) ;
193+
194+ const flushSave = useCallback ( ( ) => {
195+ const latest = latestStateRef . current ;
196+ if ( ! latest ) {
197+ return ;
198+ }
199+ saveStoredValue ( channelHistorySchema , {
200+ buffers : serializeBuffersForStorage ( latest . buffers ) ,
201+ bufferOrder : latest . bufferOrder ,
202+ currentBufferIndex : latest . currentBufferIndex ,
203+ timestampsEnabled : latest . timestampsEnabled ,
204+ } ) ;
205+ } , [ ] ) ;
206+
207+ const flushPendingSave = useCallback ( ( ) => {
208+ if ( saveTimerRef . current !== undefined ) {
209+ cancelScheduledSave ( ) ;
210+ flushSave ( ) ;
211+ }
212+ } , [ cancelScheduledSave , flushSave ] ) ;
171213
172214 // Load state through the shared versioned persistence owner on mount.
173215 useEffect ( ( ) => {
@@ -195,15 +237,38 @@ export const useChannelHistory = () => {
195237 setTimestampsEnabled ( parsed . timestampsEnabled ?? true ) ;
196238 } , [ ] ) ;
197239
198- // Save state through the same schema whenever it changes.
240+ // Save state through the same schema whenever it changes, debounced so a
241+ // burst of channel messages coalesces into one write instead of
242+ // re-serializing the whole history on every message.
199243 useEffect ( ( ) => {
200- saveStoredValue ( channelHistorySchema , {
201- buffers : serializeBuffersForStorage ( buffers ) ,
202- bufferOrder,
203- currentBufferIndex,
204- timestampsEnabled,
205- } ) ;
206- } , [ buffers , bufferOrder , currentBufferIndex , timestampsEnabled ] ) ;
244+ latestStateRef . current = {
245+ buffers,
246+ bufferOrder,
247+ currentBufferIndex,
248+ timestampsEnabled,
249+ } ;
250+
251+ cancelScheduledSave ( ) ;
252+ saveTimerRef . current = window . setTimeout ( ( ) => {
253+ saveTimerRef . current = undefined ;
254+ flushSave ( ) ;
255+ } , SAVE_DEBOUNCE_MS ) ;
256+ } , [ buffers , bufferOrder , currentBufferIndex , timestampsEnabled , cancelScheduledSave , flushSave ] ) ;
257+
258+ // React root cleanup is not guaranteed during a reload or tab close, so
259+ // synchronously flush pending history from the browser lifecycle as well as
260+ // on unmount. The pending-timer check keeps consecutive lifecycle events
261+ // (for example, beforeunload followed by pagehide) idempotent.
262+ useEffect ( ( ) => {
263+ window . addEventListener ( "pagehide" , flushPendingSave ) ;
264+ window . addEventListener ( "beforeunload" , flushPendingSave ) ;
265+
266+ return ( ) => {
267+ window . removeEventListener ( "pagehide" , flushPendingSave ) ;
268+ window . removeEventListener ( "beforeunload" , flushPendingSave ) ;
269+ flushPendingSave ( ) ;
270+ } ;
271+ } , [ flushPendingSave ] ) ;
207272
208273 // Handle channel messages. The "all" buffer is the aggregate of every
209274 // channel, so each channel message is appended both to its own channel
0 commit comments