The React children to be wrapped by the provider.
The debug context provider with value for consumers.
export function DebugProvider({
children,
}: Readonly<{ children: React.ReactNode }>) {
const [isDebugEnabled, setIsDebugEnabled] = useState(false);
const [featureToggles, setFeatureToggles] = useState<DebugFeatureToggles>(
DEFAULT_FEATURE_TOGGLES,
);
const [logEntries, setLogEntries] = useState<LogEntry[]>([]);
const [stateSourceSnapshots, setStateSourceSnapshots] = useState<
StateInspectorSourceSnapshot[]
>([]);
const stateSourcesRef = useRef(
new Map<string, StateInspectorSourceInternal>(),
);
const storageDebuggerEnabled = featureToggles.storageDebugger;
const logViewerEnabled = featureToggles.logViewer;
const stateInspectorEnabled = featureToggles.stateInspector;
useEffect(() => {
// Only install the console interceptor when BOTH debug mode AND the log viewer feature are enabled.
if (!(isDebugEnabled && logViewerEnabled)) return;
const detachConsole = installConsoleInterceptor();
return () => {
detachConsole?.();
};
}, [isDebugEnabled, logViewerEnabled]);
useEffect(() => {
if (!isDebugEnabled && !logViewerEnabled) {
return;
}
setLogEntries(logCollector.getEntries());
const unsubscribe = logCollector.subscribe((entries) => {
setLogEntries(entries);
});
return () => {
unsubscribe();
};
}, [isDebugEnabled, logViewerEnabled]);
// Load debug state from localStorage on initialization
useEffect(() => {
try {
const savedDebugState = localStorage.getItem(DEBUG_STORAGE_KEY);
if (savedDebugState !== null) {
setIsDebugEnabled(JSON.parse(savedDebugState));
}
} catch (error) {
console.error("Failed to load debug state from localStorage:", error);
}
}, []);
// Load feature toggles on initialization
useEffect(() => {
try {
const storedToggles = localStorage.getItem(DEBUG_FEATURE_TOGGLES_KEY);
if (storedToggles) {
const parsed = JSON.parse(
storedToggles,
) as Partial<DebugFeatureToggles>;
setFeatureToggles((prev) => ({
...prev,
...parsed,
}));
}
} catch (error) {
console.error(
"Failed to load debug feature toggles from localStorage:",
error,
);
}
}, []);
// Save debug state to localStorage whenever it changes
const setDebugEnabled = useCallback((enabled: boolean) => {
setIsDebugEnabled(enabled);
try {
localStorage.setItem(DEBUG_STORAGE_KEY, JSON.stringify(enabled));
} catch (error) {
console.error("Failed to save debug state to localStorage:", error);
}
}, []);
const toggleDebug = useCallback(() => {
setDebugEnabled(!isDebugEnabled);
}, [isDebugEnabled, setDebugEnabled]);
const persistFeatureToggles = useCallback(
(updater: (prev: DebugFeatureToggles) => DebugFeatureToggles) => {
setFeatureToggles((prev) => {
const next = updater(prev);
try {
localStorage.setItem(DEBUG_FEATURE_TOGGLES_KEY, JSON.stringify(next));
} catch (error) {
console.error(
"Failed to save debug feature toggles to localStorage:",
error,
);
}
return next;
});
},
[],
);
const setStorageDebuggerEnabled = useCallback(
(enabled: boolean) => {
persistFeatureToggles((prev) => ({
...prev,
storageDebugger: enabled,
}));
},
[persistFeatureToggles],
);
const toggleStorageDebugger = useCallback(() => {
persistFeatureToggles((prev) => ({
...prev,
storageDebugger: !prev.storageDebugger,
}));
}, [persistFeatureToggles]);
const setLogViewerEnabled = useCallback(
(enabled: boolean) => {
persistFeatureToggles((prev) => ({
...prev,
logViewer: enabled,
}));
},
[persistFeatureToggles],
);
const toggleLogViewer = useCallback(() => {
persistFeatureToggles((prev) => ({
...prev,
logViewer: !prev.logViewer,
}));
}, [persistFeatureToggles]);
const setStateInspectorEnabled = useCallback(
(enabled: boolean) => {
persistFeatureToggles((prev) => ({
...prev,
stateInspector: enabled,
}));
},
[persistFeatureToggles],
);
const toggleStateInspector = useCallback(() => {
persistFeatureToggles((prev) => ({
...prev,
stateInspector: !prev.stateInspector,
}));
}, [persistFeatureToggles]);
const registerStateInspector = useCallback(
<T,>(config: StateInspectorRegistration<T>): StateInspectorHandle<T> => {
const serialize = config.serialize
? (value: unknown) => config.serialize!(value as T) as unknown
: (value: unknown) => value;
const deserialize = config.deserialize
? (value: unknown) => config.deserialize!(value) as unknown
: (value: unknown) => value;
const getSnapshot = () => config.getSnapshot() as unknown;
const setSnapshot = config.setSnapshot
? (value: unknown) => {
(config.setSnapshot as (next: T) => void)(value as T);
}
: undefined;
const initialRaw = getSnapshot();
const initialDisplay = serialize(initialRaw);
const internal: StateInspectorSourceInternal = {
id: config.id,
label: config.label,
description: config.description,
group: config.group ?? "General",
getSnapshot,
setSnapshot,
serialize,
deserialize,
latestRawValue: initialRaw,
latestDisplayValue: initialDisplay,
lastUpdated: Date.now(),
};
const nextSources = new Map(stateSourcesRef.current);
nextSources.set(config.id, internal);
stateSourcesRef.current = nextSources;
setStateSourceSnapshots(toStateInspectorSnapshots(nextSources));
return {
publish: (value: T) => {
const current = stateSourcesRef.current.get(config.id);
if (!current) return;
const next = {
...current,
latestRawValue: value,
latestDisplayValue: current.serialize(value as unknown),
lastUpdated: Date.now(),
} satisfies StateInspectorSourceInternal;
const map = new Map(stateSourcesRef.current);
map.set(config.id, next);
stateSourcesRef.current = map;
setStateSourceSnapshots(toStateInspectorSnapshots(map));
},
unregister: () => {
const current = new Map(stateSourcesRef.current);
current.delete(config.id);
stateSourcesRef.current = current;
setStateSourceSnapshots(toStateInspectorSnapshots(current));
},
};
},
[],
);
const refreshStateInspectorSource = useCallback((id: string) => {
const source = stateSourcesRef.current.get(id);
if (!source) return;
try {
const snapshot = source.getSnapshot();
const map = new Map(stateSourcesRef.current);
map.set(id, {
...source,
latestRawValue: snapshot,
latestDisplayValue: source.serialize(snapshot),
lastUpdated: Date.now(),
});
stateSourcesRef.current = map;
setStateSourceSnapshots(toStateInspectorSnapshots(map));
} catch (error) {
console.error("Failed to refresh state inspector source", {
id,
error,
});
}
}, []);
const applyStateInspectorUpdate = useCallback(
(id: string, value: unknown) => {
const source = stateSourcesRef.current.get(id);
if (!source) {
throw new Error(`Unknown state inspector source: ${id}`);
}
if (!source.setSnapshot) {
throw new Error(`State inspector source '${id}' is read-only`);
}
const nextValue = source.deserialize(value);
try {
source.setSnapshot(nextValue);
const refreshed = source.getSnapshot();
const map = new Map(stateSourcesRef.current);
map.set(id, {
...source,
latestRawValue: refreshed,
latestDisplayValue: source.serialize(refreshed),
lastUpdated: Date.now(),
});
stateSourcesRef.current = map;
setStateSourceSnapshots(toStateInspectorSnapshots(map));
} catch (error) {
console.error("Failed to apply state inspector update", {
id,
error,
});
throw error;
}
},
[],
);
useEffect(() => {
const settingsId = "settings-state";
try {
const handle = registerStateInspector<SettingsDebugSnapshot>({
id: settingsId,
label: "Application Settings",
description:
"Persisted sync and matching configuration stored in local preferences.",
group: "Settings",
getSnapshot: () => ({
syncConfig: getSyncConfig(),
matchConfig: getMatchConfig(),
}),
setSnapshot: (snapshot) => {
if (snapshot.syncConfig) {
saveSyncConfig(snapshot.syncConfig);
}
if (snapshot.matchConfig) {
saveMatchConfig(snapshot.matchConfig);
}
},
serialize: (snapshot) => ({
syncConfig: snapshot.syncConfig,
matchConfig: snapshot.matchConfig,
}),
deserialize: (value) => {
const candidate = value as Partial<SettingsDebugSnapshot> | null;
return {
syncConfig: candidate?.syncConfig ?? getSyncConfig(),
matchConfig: candidate?.matchConfig ?? getMatchConfig(),
} satisfies SettingsDebugSnapshot;
},
});
return () => {
handle.unregister();
};
} catch (error) {
console.error("Failed to register settings state inspector", error);
return undefined;
}
}, [registerStateInspector]);
const clearLogs = useCallback(() => {
logCollector.clear();
}, []);
const exportLogs = useCallback(() => {
const entries = logCollector.getEntries();
if (!entries.length) {
console.warn("No debug logs available to export");
return;
}
try {
const payload = {
exportedAt: new Date().toISOString(),
userAgent:
typeof navigator === "undefined" ? undefined : navigator.userAgent,
totalEntries: entries.length,
maxEntries: MAX_LOG_ENTRIES,
logs: serialiseLogEntries(entries),
};
const json = JSON.stringify(payload, null, 2);
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
const timestamp = new Date()
.toISOString()
.replaceAll(":", "-")
.replaceAll(".", "-");
link.href = url;
link.download = `kenmei-debug-logs-${timestamp}.json`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
} catch (error) {
console.error("Failed to export debug logs:", error);
}
}, []);
const value = React.useMemo<DebugContextType>(
() => ({
isDebugEnabled,
toggleDebug,
setDebugEnabled,
storageDebuggerEnabled,
setStorageDebuggerEnabled,
toggleStorageDebugger,
logViewerEnabled,
setLogViewerEnabled,
toggleLogViewer,
stateInspectorEnabled,
setStateInspectorEnabled,
toggleStateInspector,
stateInspectorSources: stateSourceSnapshots,
registerStateInspector,
applyStateInspectorUpdate,
refreshStateInspectorSource,
logEntries,
clearLogs,
exportLogs,
maxLogEntries: MAX_LOG_ENTRIES,
}),
[
isDebugEnabled,
toggleDebug,
setDebugEnabled,
storageDebuggerEnabled,
setStorageDebuggerEnabled,
toggleStorageDebugger,
logViewerEnabled,
setLogViewerEnabled,
toggleLogViewer,
stateInspectorEnabled,
setStateInspectorEnabled,
toggleStateInspector,
stateSourceSnapshots,
registerStateInspector,
applyStateInspectorUpdate,
refreshStateInspectorSource,
logEntries,
clearLogs,
exportLogs,
],
);
return (
<DebugContext.Provider value={value}>{children}</DebugContext.Provider>
);
}
Provides debug context to its children, managing debug state and persistence.