perf(canvas): reduce Convex hot-path query load
This commit is contained in:
@@ -1,15 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useConvex, useConvexAuth, useMutation } from "convex/react";
|
||||
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import type { Doc } from "@/convex/_generated/dataModel";
|
||||
import { useAuthQuery } from "@/hooks/use-auth-query";
|
||||
|
||||
type AdjustmentPresetDoc = Doc<"adjustmentPresets">;
|
||||
type PresetsByNodeType = Map<AdjustmentPresetDoc["nodeType"], AdjustmentPresetDoc[]>;
|
||||
type SaveAdjustmentPresetArgs = {
|
||||
name: string;
|
||||
nodeType: AdjustmentPresetDoc["nodeType"];
|
||||
params: unknown;
|
||||
};
|
||||
|
||||
const CanvasPresetsContext = createContext<PresetsByNodeType | null>(null);
|
||||
type CanvasPresetsContextValue = {
|
||||
presetsByNodeType: PresetsByNodeType;
|
||||
savePreset: (args: SaveAdjustmentPresetArgs) => Promise<void>;
|
||||
};
|
||||
|
||||
const MAX_AUTO_LOAD_ATTEMPTS = 2;
|
||||
const RETRY_DELAY_MS = 1000;
|
||||
const CanvasPresetsContext = createContext<CanvasPresetsContextValue | null>(null);
|
||||
|
||||
function groupPresetsByNodeType(rawPresets: AdjustmentPresetDoc[]): PresetsByNodeType {
|
||||
const next = new Map<AdjustmentPresetDoc["nodeType"], AdjustmentPresetDoc[]>();
|
||||
|
||||
for (const preset of rawPresets) {
|
||||
const existing = next.get(preset.nodeType);
|
||||
if (existing) {
|
||||
existing.push(preset);
|
||||
} else {
|
||||
next.set(preset.nodeType, [preset]);
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
type CanvasPresetsProviderProps = {
|
||||
enabled?: boolean;
|
||||
@@ -20,25 +56,163 @@ export function CanvasPresetsProvider({
|
||||
enabled = true,
|
||||
children,
|
||||
}: CanvasPresetsProviderProps) {
|
||||
const rawPresets = useAuthQuery(api.presets.list, enabled ? {} : "skip");
|
||||
const convex = useConvex();
|
||||
const { isAuthenticated } = useConvexAuth();
|
||||
const savePresetMutation = useMutation(api.presets.save);
|
||||
const latestLoadRequestIdRef = useRef(0);
|
||||
const autoLoadInFlightRef = useRef(false);
|
||||
const retryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [rawPresets, setRawPresets] = useState<AdjustmentPresetDoc[]>([]);
|
||||
const [hasLoadedOnce, setHasLoadedOnce] = useState(false);
|
||||
const [retryNonce, setRetryNonce] = useState(0);
|
||||
|
||||
const presetsByNodeType = useMemo<PresetsByNodeType>(() => {
|
||||
const next = new Map<AdjustmentPresetDoc["nodeType"], AdjustmentPresetDoc[]>();
|
||||
const clearScheduledRetry = useCallback(() => {
|
||||
if (retryTimeoutRef.current !== null) {
|
||||
clearTimeout(retryTimeoutRef.current);
|
||||
retryTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
for (const preset of (rawPresets ?? []) as AdjustmentPresetDoc[]) {
|
||||
const existing = next.get(preset.nodeType);
|
||||
if (existing) {
|
||||
existing.push(preset);
|
||||
} else {
|
||||
next.set(preset.nodeType, [preset]);
|
||||
}
|
||||
const scheduleRetry = useCallback(() => {
|
||||
if (retryTimeoutRef.current !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
return next;
|
||||
}, [rawPresets]);
|
||||
retryTimeoutRef.current = setTimeout(() => {
|
||||
retryTimeoutRef.current = null;
|
||||
setRetryNonce((current) => current + 1);
|
||||
}, RETRY_DELAY_MS);
|
||||
}, []);
|
||||
|
||||
const loadPresets = useCallback(async () => {
|
||||
const presets = await convex.query(api.presets.list, {});
|
||||
return presets as AdjustmentPresetDoc[];
|
||||
}, [convex]);
|
||||
|
||||
const refreshPresets = useCallback(async () => {
|
||||
const requestId = ++latestLoadRequestIdRef.current;
|
||||
|
||||
try {
|
||||
const presets = await loadPresets();
|
||||
|
||||
if (requestId === latestLoadRequestIdRef.current) {
|
||||
clearScheduledRetry();
|
||||
setRawPresets(presets);
|
||||
}
|
||||
|
||||
return { requestId, presets };
|
||||
} catch (error: unknown) {
|
||||
throw { requestId, error };
|
||||
}
|
||||
}, [clearScheduledRetry, loadPresets]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearScheduledRetry();
|
||||
};
|
||||
}, [clearScheduledRetry]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!enabled ||
|
||||
!isAuthenticated ||
|
||||
hasLoadedOnce ||
|
||||
autoLoadInFlightRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
autoLoadInFlightRef.current = true;
|
||||
|
||||
void (async () => {
|
||||
for (let attempt = 0; attempt < MAX_AUTO_LOAD_ATTEMPTS; attempt += 1) {
|
||||
try {
|
||||
await refreshPresets();
|
||||
if (!cancelled) {
|
||||
setHasLoadedOnce(true);
|
||||
}
|
||||
return;
|
||||
} catch (caught: unknown) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const refreshError =
|
||||
typeof caught === "object" && caught !== null && "error" in caught
|
||||
? ((caught as { error: unknown }).error ?? caught)
|
||||
: caught;
|
||||
|
||||
console.warn("[CanvasPresetsProvider] failed to load presets", {
|
||||
message:
|
||||
refreshError instanceof Error
|
||||
? refreshError.message
|
||||
: String(refreshError),
|
||||
attempt: attempt + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
scheduleRetry();
|
||||
}
|
||||
})().finally(() => {
|
||||
autoLoadInFlightRef.current = false;
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [enabled, hasLoadedOnce, isAuthenticated, refreshPresets, retryNonce, scheduleRetry]);
|
||||
|
||||
const savePreset = useCallback(
|
||||
async (args: SaveAdjustmentPresetArgs) => {
|
||||
await savePresetMutation(args);
|
||||
|
||||
try {
|
||||
await refreshPresets();
|
||||
setHasLoadedOnce(true);
|
||||
} catch (caught: unknown) {
|
||||
const refreshError =
|
||||
typeof caught === "object" && caught !== null && "error" in caught
|
||||
? ((caught as { error: unknown }).error ?? caught)
|
||||
: caught;
|
||||
const requestId =
|
||||
typeof caught === "object" && caught !== null && "requestId" in caught
|
||||
? ((caught as { requestId: unknown }).requestId as number)
|
||||
: latestLoadRequestIdRef.current;
|
||||
|
||||
console.warn("[CanvasPresetsProvider] failed to refresh presets after save", {
|
||||
message:
|
||||
refreshError instanceof Error
|
||||
? refreshError.message
|
||||
: String(refreshError),
|
||||
});
|
||||
|
||||
if (requestId === latestLoadRequestIdRef.current) {
|
||||
setHasLoadedOnce(false);
|
||||
scheduleRetry();
|
||||
}
|
||||
}
|
||||
},
|
||||
[refreshPresets, savePresetMutation, scheduleRetry],
|
||||
);
|
||||
|
||||
const presetsByNodeType = useMemo<PresetsByNodeType>(
|
||||
() => groupPresetsByNodeType(rawPresets),
|
||||
[rawPresets],
|
||||
);
|
||||
|
||||
const contextValue = useMemo<CanvasPresetsContextValue>(
|
||||
() => ({
|
||||
presetsByNodeType,
|
||||
savePreset,
|
||||
}),
|
||||
[presetsByNodeType, savePreset],
|
||||
);
|
||||
|
||||
return (
|
||||
<CanvasPresetsContext.Provider value={presetsByNodeType}>
|
||||
<CanvasPresetsContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</CanvasPresetsContext.Provider>
|
||||
);
|
||||
@@ -52,5 +226,14 @@ export function useCanvasAdjustmentPresets(
|
||||
return [];
|
||||
}
|
||||
|
||||
return context.get(nodeType) ?? [];
|
||||
return context.presetsByNodeType.get(nodeType) ?? [];
|
||||
}
|
||||
|
||||
export function useSaveCanvasAdjustmentPreset(): CanvasPresetsContextValue["savePreset"] {
|
||||
const context = useContext(CanvasPresetsContext);
|
||||
if (context === null) {
|
||||
throw new Error("useSaveCanvasAdjustmentPreset must be used within CanvasPresetsProvider");
|
||||
}
|
||||
|
||||
return context.savePreset;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user