Theme System Infinite Loop Fixes

Detailed technical documentation of infinite loop fixes in the theme system

Theme System Infinite Loop Fixes

Problem Description

The theme system was experiencing severe infinite loops manifesting as:

  1. Cascading theme activations - Every theme in the library was being set as active, one after another
  2. Race conditions - Backend sync was overwriting freshly-applied themes
  3. Effect re-triggering - The constructor effect was causing re-applications
  4. Console flooding - Thousands of log entries per second

Root Cause Analysis

Issue 1: saveTheme() Default setAsActive: true

Location: simple-theme-storage.service.ts

Problem: The saveTheme() method defaulted setAsActive to true:

// ❌ PROBLEMATIC CODE
async saveTheme(theme: Theme, options = {}) {
  const { mode = 'override', setAsActive = true } = options;
  // ...
  if (setAsActive) {
    await this.setActiveThemeId(finalTheme.id);  // ALWAYS ran!
  }
}

This meant that ANY call to save a theme would also set it as active.

Impact: When reloadThemes() called ensureThemeIsSaved() for each theme:

allThemes.forEach(theme => {
  this.ensureThemeIsSaved(theme);  // → saveTheme() → setAsActive!
});

Each theme was activated sequentially, creating the visible "cascade" of theme changes.

Fix:

// ✅ FIXED CODE
async saveTheme(theme: Theme, options = {}) {
  const { mode = 'override', setAsActive = false } = options;  // Default FALSE
  // ...
}

Issue 2: Space Change Effect Without Guards

Location: simple-theme-storage.service.ts

Problem: When space context changed, the effect immediately synced from backend:

// ❌ PROBLEMATIC CODE
effect(() => {
  const spaceId = this.universeContext.spaceId();
  if (spaceId && spaceId !== lastLoadedSpaceId) {
    this.loadThemeFromSpaceDocument();  // Could overwrite valid theme!
  }
});

Impact: After initialization loaded the correct theme, the space change effect would fire and potentially load a different (stale) theme from the backend.

Fix:

// ✅ FIXED CODE
effect(() => {
  const spaceId = this.universeContext.spaceId();
  if (spaceId && spaceId !== lastLoadedSpaceId) {
    const currentActiveId = this.activeThemeId();
    // Skip sync if we already have a valid (non-default) theme
    if (currentActiveId && !currentActiveId.includes('default')) {
      return;  // Don't overwrite!
    }
    this.loadThemeFromSpaceDocument();
  }
});

Issue 3: Backend Overwrites During Startup

Location: simple-theme-storage.service.ts

Problem: Backend sync could overwrite a theme that was just applied locally.

Fix: Added a 5-second protection window:

// Track when we last applied a theme locally
private lastLocalApplyTimestamp = 0;
private readonly LOCAL_APPLY_PROTECTION_MS = 5000;

private applyThemeIdAndHistory(state: ThemeSystemState): void {
  if (state.activeThemeId !== currentActiveId) {
    // Check protection window
    const timeSinceLastApply = Date.now() - this.lastLocalApplyTimestamp;
    if (this.lastLocalApplyTimestamp > 0 && timeSinceLastApply < this.LOCAL_APPLY_PROTECTION_MS) {
      return;  // Skip - local theme recently applied
    }
    // ... apply backend state
  }
}

Issue 4: Effect Calling loadAndApplyThemeById()

Location: theme-manager.service.ts

Problem: The constructor effect was calling loadAndApplyThemeById() when theme wasn't in available themes, which triggered storage saves:

// ❌ PROBLEMATIC CODE
effect(() => {
  const theme = availableThemes.find(t => t.id === activeThemeId);
  const apply$ = theme
    ? this.applyTheme(theme, { reason: 'storage-sync' })
    : this.loadAndApplyThemeById(activeThemeId);  // Causes loops!
});

Fix:

// ✅ FIXED CODE
effect(() => {
  const theme = availableThemes.find(t => t.id === activeThemeId);
  if (!theme) {
    // Theme not loaded yet - skip, don't try to load
    return;
  }
  this.applyTheme(theme, { reason: 'storage-sync' }).subscribe();
});

Issue 5: Reason Option Overwritten

Location: theme-manager.service.ts

Problem: The doApplyTheme() method was overwriting the reason option:

// ❌ PROBLEMATIC CODE
return this.orchestratorService.applyTheme(theme, {
  reason: options.manualSelection ? 'manual-selection' : 'programmatic'
  // ↑ Ignores options.reason completely!
});

This meant reason: 'storage-sync' was never passed through, so loop prevention checks failed.

Fix:

// ✅ FIXED CODE
const orchestratorReason = options.reason || (options.manualSelection ? 'manual-selection' : 'programmatic');
return this.orchestratorService.applyTheme(theme, {
  reason: orchestratorReason  // Preserves 'storage-sync' if passed
});

Issue 6: Constructor Auto-Initialization

Location: theme-state-manager.ts

Problem: ThemeStateManager was auto-initializing in its constructor:

// ❌ PROBLEMATIC CODE
constructor() {
  this.initializeUnifiedStorage();  // Fire-and-forget async!
}

This ran concurrently with ThemeManagerService.initialize(), causing race conditions.

Fix:

// ✅ FIXED CODE
constructor() {
  // Only synchronous setup - no async initialization
  // ThemeManagerService.initialize() is the single source of truth
}

Issue 7: lastAppliedThemeId Not Updated

Location: theme-manager.service.ts

Problem: After applying a theme in doApplyTheme(), the lastAppliedThemeId wasn't updated, so the effect didn't know the theme was already applied.

Fix:

// In doApplyTheme(), after successful application:
this.lastAppliedThemeId = themeId;

Summary of Changes

File Line Change
simple-theme-storage.service.ts ~296 setAsActive default → false
simple-theme-storage.service.ts ~1075 saveThemeQuietly() explicitly passes setAsActive: false
simple-theme-storage.service.ts ~120 Added lastLocalApplyTimestamp protection
simple-theme-storage.service.ts ~195 Space effect checks for existing valid theme
simple-theme-storage.service.ts ~640 applyThemeIdAndHistory() respects protection window
theme-manager.service.ts ~145 Effect returns early if theme not in available themes
theme-manager.service.ts ~1555 reason option preserved through call chain
theme-manager.service.ts ~1615 lastAppliedThemeId set after successful apply
theme-state-manager.ts ~70 Removed initializeUnifiedStorage() from constructor

Testing Verification

After applying all fixes:

  1. ✅ Theme loads correctly on page load
  2. ✅ Theme persists across reloads
  3. ✅ No cascading theme changes
  4. ✅ No console flooding
  5. ✅ Theme switching works smoothly
  6. ✅ Cross-tab sync still works

Session: 8
Date: December 8, 2025