jam-cloud/.planning/phases/32-state-update-optimization/32-01-PLAN.md

10 KiB

phase plan type wave depends_on files_modified autonomous must_haves
32-state-update-optimization 01 execute 1
jam-ui/src/components/client/JKSessionScreen.js
jam-ui/src/hooks/useSessionModel.js
jam-ui/src/hooks/useDebounceCallback.js
true
truths artifacts key_links
Track sync fires once per session join (not 3 times)
Debounce timer in trackChanges is stable across state changes
Debounced callbacks read fresh state values
path provides exports
jam-ui/src/hooks/useDebounceCallback.js Reusable ref-based debounce hook
useDebounceCallback
path provides contains
jam-ui/src/components/client/JKSessionScreen.js Single debounced track sync syncTracksDebounced
path provides contains
jam-ui/src/hooks/useSessionModel.js Stable trackChanges debounce useDebounceCallback
from to via pattern
jam-ui/src/components/client/JKSessionScreen.js syncTracksToServer single debounced call useMemo.*debounce.*syncTracksToServer
from to via pattern
jam-ui/src/hooks/useSessionModel.js useDebounceCallback import and usage useDebounceCallback.*trackChanges
Fix redundant track sync calls and debounce instance recreation

Purpose: Eliminate triple API calls on session join (STATE-01) and fix debounce timer reset on state changes (STATE-02). These are the two remaining redundant operation patterns identified in the investigation phase.

Output: Single debounced track sync call, stable useDebounceCallback hook, refactored trackChanges handler

<execution_context> @/Users/nuwan/.claude/get-shit-done/workflows/execute-plan.md @/Users/nuwan/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/32-state-update-optimization/32-RESEARCH.md

Source files to modify

@jam-ui/src/components/client/JKSessionScreen.js @jam-ui/src/hooks/useSessionModel.js

Task 1: Create useDebounceCallback hook jam-ui/src/hooks/useDebounceCallback.js Create a new reusable hook that solves the stale closure problem with debounced callbacks:
import { useRef, useEffect, useMemo } from 'react';
import { debounce } from 'lodash';

/**
 * Hook that creates a stable debounced callback with fresh closure access.
 * Solves the problem of debounce recreation when dependencies change.
 *
 * Pattern: useRef stores latest callback, useMemo creates debounce once.
 * Source: https://www.developerway.com/posts/debouncing-in-react
 *
 * @param {Function} callback - The callback to debounce
 * @param {number} delay - Debounce delay in ms (default: 500)
 * @returns {Function} Debounced function that always uses latest callback
 */
export const useDebounceCallback = (callback, delay = 500) => {
  const callbackRef = useRef(callback);

  // Always keep ref current with latest callback
  useEffect(() => {
    callbackRef.current = callback;
  }, [callback]);

  // Create debounced function once - never recreated
  const debouncedFn = useMemo(() =>
    debounce((...args) => {
      callbackRef.current?.(...args);
    }, delay),
    [delay]  // Only recreate if delay changes
  );

  // Cleanup on unmount
  useEffect(() => {
    return () => debouncedFn.cancel();
  }, [debouncedFn]);

  return debouncedFn;
};

export default useDebounceCallback;

Key design decisions:

  • Uses useRef to store callback, keeping it always current
  • Uses useMemo with [delay] deps to create debounce function once
  • Handles cleanup on unmount via useEffect return
  • Exports both named and default for flexibility File exists at jam-ui/src/hooks/useDebounceCallback.js Exports useDebounceCallback function No ESLint errors: cd jam-ui && npx eslint src/hooks/useDebounceCallback.js useDebounceCallback hook created with ref-based pattern, ready for import in useSessionModel
Task 2: Consolidate track sync to single debounced call jam-ui/src/components/client/JKSessionScreen.js Replace the triple setTimeout pattern (lines ~462-485) with a single debounced call.
  1. Add import for useMemo at top (if not already present)

  2. Find the current track sync useEffect (around lines 453-485):

// Track sync: Sync tracks to server when session joined (3-call pattern matching legacy)
useEffect(() => {
  if (!hasJoined || !sessionId || !server?.clientId || !mixersReady) {
    return;
  }
  const timer1 = setTimeout(() => { ... }, 1000);
  const timer2 = setTimeout(() => { ... }, 1400);
  const timer3 = setTimeout(() => { ... }, 6000);
  return () => { clearTimeout(timer1); ... };
}, [hasJoined, sessionId, mixersReady, dispatch])
  1. Replace with single debounced call pattern:
// Create stable debounced sync function
const syncTracksDebounced = useMemo(() =>
  debounce((sid, cid, d) => {
    d(syncTracksToServer(sid, cid));
  }, 1500),  // 1.5s delay - adequate for mixer initialization
  []
);

// Track sync: Single debounced call when session joined
// Replaces legacy 3-timer pattern (1s, 1.4s, 6s) with single 1.5s debounced call
useEffect(() => {
  if (!hasJoined || !sessionId || !server?.clientId || !mixersReady) {
    return;
  }

  // console.log('[Track Sync] Mixers ready, scheduling single debounced sync');
  syncTracksDebounced(sessionId, server.clientId, dispatch);

  return () => syncTracksDebounced.cancel();
  // Note: server intentionally NOT in deps to avoid re-running on server reference changes
  // eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasJoined, sessionId, mixersReady, dispatch, syncTracksDebounced]);
  1. Add lodash debounce import if not present:
import { debounce } from 'lodash';

Why 1.5s delay: Legacy used 1s, 1.4s, 6s. The first two calls at 1s and 1.4s were redundant. A single 1.5s debounced call covers the initial mixer setup window while preventing rapid repeated calls. The 6s call was likely for slow networks - if issues arise, this delay can be increased. Check the track sync useEffect has single debounce call: grep -A 20 "Track sync:" jam-ui/src/components/client/JKSessionScreen.js

Verify no setTimeout pattern remains in that section: grep -A 20 "Track sync:" jam-ui/src/components/client/JKSessionScreen.js | grep -c setTimeout should be 0

ESLint passes: cd jam-ui && npx eslint src/components/client/JKSessionScreen.js --max-warnings=0 Track sync useEffect uses single debounced call instead of 3 setTimeout calls

Task 3: Fix trackChanges debounce in useSessionModel jam-ui/src/hooks/useSessionModel.js Replace the useCallback-wrapped debounce with useDebounceCallback hook.
  1. Add import at top:
import { useDebounceCallback } from './useDebounceCallback';
  1. Find current trackChanges (around lines 604-614):
// Track changes handler - debounced to prevent excessive session refreshes
const trackChanges = useCallback(debounce((header, payload) => {
  if (currentTrackChanges < payload.track_changes_counter) {
    logger.debug("track_changes_counter = stale. refreshing...");
    refreshCurrentSession();
  } else {
    if (header.type !== 'HEARTBEAT_ACK') {
      logger.info("track_changes_counter = fresh. skipping refresh...", header, payload);
    }
  }
}, 500), [currentTrackChanges, refreshCurrentSession]);
  1. Replace with useDebounceCallback pattern:
// Track changes handler - debounced to prevent excessive session refreshes
// Uses useDebounceCallback for stable timer (doesn't reset when deps change)
const trackChanges = useDebounceCallback((header, payload) => {
  if (currentTrackChanges < payload.track_changes_counter) {
    logger.debug("track_changes_counter = stale. refreshing...");
    refreshCurrentSession();
  } else {
    if (header.type !== 'HEARTBEAT_ACK') {
      logger.info("track_changes_counter = fresh. skipping refresh...", header, payload);
    }
  }
}, 500);

Key changes:

  • Remove useCallback wrapper (useDebounceCallback handles memoization internally)
  • Remove debounce import call (it's inside useDebounceCallback)
  • Remove [currentTrackChanges, refreshCurrentSession] dependency array
  • The callback now always reads fresh values via ref closure
  • Timer is stable - won't reset on currentTrackChanges updates
  1. Remove lodash debounce import if no longer used elsewhere in file: Check for other usages first: grep -n "debounce" useSessionModel.js If trackChanges was the only use, remove: import { debounce } from 'lodash'; If other usages exist, keep the import. trackChanges now uses useDebounceCallback: grep -B 2 -A 10 "trackChanges = " jam-ui/src/hooks/useSessionModel.js | head -15

No useCallback wrapper on trackChanges: grep "useCallback(debounce" jam-ui/src/hooks/useSessionModel.js | wc -l should be 0

ESLint passes: cd jam-ui && npx eslint src/hooks/useSessionModel.js --max-warnings=0 trackChanges uses useDebounceCallback - timer stable across state changes, always reads fresh values

1. Track sync fires once per session join: - Grep for setTimeout in track sync section should return 0 - useMemo + debounce pattern present
  1. Debounce stability verified:

    • useDebounceCallback hook exists with ref pattern
    • trackChanges in useSessionModel uses useDebounceCallback
    • No useCallback(debounce) pattern in useSessionModel
  2. All files pass ESLint:

    cd jam-ui && npx eslint src/hooks/useDebounceCallback.js src/hooks/useSessionModel.js src/components/client/JKSessionScreen.js --max-warnings=0
    

<success_criteria>

  • useDebounceCallback.js created with ref-based debounce pattern
  • JKSessionScreen track sync uses single debounced call (not 3 setTimeout)
  • useSessionModel trackChanges uses useDebounceCallback (no dependency array recreation)
  • All modified files pass ESLint
  • No regressions - session join still works (manual verification if time permits) </success_criteria>
After completion, create `.planning/phases/32-state-update-optimization/32-01-SUMMARY.md`