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:
- Cascading theme activations - Every theme in the library was being set as active, one after another
- Race conditions - Backend sync was overwriting freshly-applied themes
- Effect re-triggering - The constructor effect was causing re-applications
- 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:
- ✅ Theme loads correctly on page load
- ✅ Theme persists across reloads
- ✅ No cascading theme changes
- ✅ No console flooding
- ✅ Theme switching works smoothly
- ✅ Cross-tab sync still works
Session: 8
Date: December 8, 2025