fix(web): make invalidation hooks async with Promise.all and fix cross-view staleness

- useInvalidateTimeline and useInvalidatePlanningViews now return
  Promise.all instead of fire-and-forget void calls
- Timeline mutations now use useInvalidatePlanningViews to also
  invalidate allocation list views, preventing stale data
- AllocationsClient sequential awaits replaced with single
  invalidatePlanningViews() call (parallel invalidation)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-11 08:24:33 +02:00
parent f18777c365
commit f3fa902773
11 changed files with 372 additions and 229 deletions
+91 -41
View File
@@ -1,8 +1,15 @@
"use client";
import { useCallback, useDeferredValue, useEffect, useRef, useState, type MutableRefObject } from "react";
import {
useCallback,
useDeferredValue,
useEffect,
useRef,
useState,
type MutableRefObject,
} from "react";
import { trpc } from "~/lib/trpc/client.js";
import { useInvalidateTimeline } from "./useInvalidatePlanningViews.js";
import { useInvalidatePlanningViews } from "./useInvalidatePlanningViews.js";
import { pixelsToDays } from "~/components/timeline/dragMath.js";
import {
clearLivePreview,
@@ -20,8 +27,14 @@ import { beginAllocationMultiDragSession } from "./timelineAllocationMultiDragSe
import { createAllocationDragState } from "./timelineAllocationDragState.js";
import { beginAllocationDragSession } from "./timelineAllocationDragSession.js";
import { finalizeAllocationReleaseEffects } from "./timelineAllocationReleaseEffects.js";
import { cancelTransientMultiSelectState, cleanupTimelineDragState } from "./timelineDragCleanup.js";
import { resolveAllocationDragPosition, resolveProjectDragPosition } from "./timelineDragPosition.js";
import {
cancelTransientMultiSelectState,
cleanupTimelineDragState,
} from "./timelineDragCleanup.js";
import {
resolveAllocationDragPosition,
resolveProjectDragPosition,
} from "./timelineDragPosition.js";
import { attachDocumentMouseDrag } from "./timelineDocumentDrag.js";
import { finalizeProjectDrag } from "./timelineProjectDragFinalize.js";
import { createProjectDragState } from "./timelineProjectDrag.js";
@@ -38,8 +51,14 @@ import {
} from "./timelineMultiSelect.js";
import { beginCanvasMultiSelectSession } from "./timelineMultiSelectSession.js";
import { reconcileOptimisticEntries } from "./timelineOptimisticAllocations.js";
import { createAllocationPreviewSession, createProjectPreviewSession } from "./timelinePreviewSession.js";
import { resolveRangeSelectionCancel, resolveRangeSelectionRelease } from "./timelineRangeRelease.js";
import {
createAllocationPreviewSession,
createProjectPreviewSession,
} from "./timelinePreviewSession.js";
import {
resolveRangeSelectionCancel,
resolveRangeSelectionRelease,
} from "./timelineRangeRelease.js";
import { createRangeSelectionState, updateRangeSelectionDraft } from "./timelineRangeSelection.js";
import { type TouchCanvasPointerEvent, type TouchMouseDownEvent } from "./timelineTouchAdapters.js";
@@ -284,28 +303,38 @@ export function useTimelineDrag({
onMutationErrorRef.current = onMutationError;
const utils = trpc.useUtils();
const invalidateTimeline = useInvalidateTimeline();
const invalidatePlanningViews = useInvalidatePlanningViews();
const setProjectPreviewTargets = useCallback((projectId: string, currentTarget?: EventTarget | null) => {
clearLivePreview(projectPreviewRef.current);
projectPreviewRef.current = createProjectPreviewSession({
projectId,
currentTarget,
cellWidth: cellWidthRef.current,
});
}, []);
const setProjectPreviewTargets = useCallback(
(projectId: string, currentTarget?: EventTarget | null) => {
clearLivePreview(projectPreviewRef.current);
projectPreviewRef.current = createProjectPreviewSession({
projectId,
currentTarget,
cellWidth: cellWidthRef.current,
});
},
[],
);
const setAllocationPreviewTarget = useCallback((currentTarget?: EventTarget | null, mode: AllocDragMode = "move") => {
clearLivePreview(allocPreviewRef.current);
allocPreviewRef.current = createAllocationPreviewSession({
currentTarget,
mode,
cellWidth: cellWidthRef.current,
});
}, []);
const setAllocationPreviewTarget = useCallback(
(currentTarget?: EventTarget | null, mode: AllocDragMode = "move") => {
clearLivePreview(allocPreviewRef.current);
allocPreviewRef.current = createAllocationPreviewSession({
currentTarget,
mode,
cellWidth: cellWidthRef.current,
});
},
[],
);
const updateLivePreview = useCallback(
(previewRef: MutableRefObject<LivePreviewSession | null>, pointerDeltaX: number, daysDelta: number) => {
(
previewRef: MutableRefObject<LivePreviewSession | null>,
pointerDeltaX: number,
daysDelta: number,
) => {
const preview = previewRef.current;
if (!preview) return;
preview.cellWidth = cellWidthRef.current;
@@ -318,7 +347,11 @@ export function useTimelineDrag({
const updateProjectDragPosition = useCallback(
(clientX: number) => {
const result = resolveProjectDragPosition(dragStateRef.current, clientX, cellWidthRef.current);
const result = resolveProjectDragPosition(
dragStateRef.current,
clientX,
cellWidthRef.current,
);
if (!result.handled) return false;
updateLivePreview(projectPreviewRef, result.pointerDeltaX, result.daysDelta);
@@ -333,7 +366,11 @@ export function useTimelineDrag({
const updateAllocationDragPosition = useCallback(
(clientX: number) => {
const result = resolveAllocationDragPosition(allocDragRef.current, clientX, cellWidthRef.current);
const result = resolveAllocationDragPosition(
allocDragRef.current,
clientX,
cellWidthRef.current,
);
if (!result.handled) return false;
updateLivePreview(allocPreviewRef, result.pointerDeltaX, result.daysDelta);
@@ -415,7 +452,7 @@ export function useTimelineDrag({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const applyShiftMutation = (trpc.timeline.applyShift.useMutation as any)({
onSuccess: (data: { project: { id: string } }) => {
invalidateTimeline();
void invalidatePlanningViews();
void utils.project.list.invalidate();
onShiftApplied?.(data.project.id);
},
@@ -442,13 +479,13 @@ export function useTimelineDrag({
const pendingSnapshotRef = useRef<AllocationMovedSnapshot | null>(null);
const pendingOptimisticAllocationIdRef = useRef<string | null>(null);
const [optimisticAllocations, setOptimisticAllocations] = useState<Map<string, OptimisticTimelineOverride>>(
() => new Map(),
);
const [optimisticAllocations, setOptimisticAllocations] = useState<
Map<string, OptimisticTimelineOverride>
>(() => new Map());
const updateAllocMutation = trpc.timeline.updateAllocationInline.useMutation({
onSuccess: () => {
invalidateTimeline();
void invalidatePlanningViews();
const snap = pendingSnapshotRef.current;
if (snap) {
onAllocationMovedRef.current?.(snap);
@@ -457,7 +494,8 @@ export function useTimelineDrag({
},
onError: (error) => {
console.error("[timeline] updateAllocationInline failed:", error);
const message = (error as { message?: string }).message ?? "Zuweisung konnte nicht verschoben werden.";
const message =
(error as { message?: string }).message ?? "Zuweisung konnte nicht verschoben werden.";
onMutationErrorRef.current?.(message);
clearPendingOptimisticAllocation();
},
@@ -465,7 +503,7 @@ export function useTimelineDrag({
const extractAllocFragmentMutation = trpc.timeline.extractAllocationFragment.useMutation({
onSuccess: () => {
invalidateTimeline();
void invalidatePlanningViews();
},
});
@@ -486,13 +524,20 @@ export function useTimelineDrag({
pendingOptimisticAllocationIdRef.current = null;
}, []);
const reconcileOptimisticAllocations = useCallback((entries: readonly OptimisticTimelineEntry[]) => {
setOptimisticAllocations((prev) => {
const result = reconcileOptimisticEntries(prev, entries, pendingOptimisticAllocationIdRef.current);
pendingOptimisticAllocationIdRef.current = result.pendingOptimisticAllocationId;
return result.changed ? result.optimisticAllocations : prev;
});
}, []);
const reconcileOptimisticAllocations = useCallback(
(entries: readonly OptimisticTimelineEntry[]) => {
setOptimisticAllocations((prev) => {
const result = reconcileOptimisticEntries(
prev,
entries,
pendingOptimisticAllocationIdRef.current,
);
pendingOptimisticAllocationIdRef.current = result.pendingOptimisticAllocationId;
return result.changed ? result.optimisticAllocations : prev;
});
},
[],
);
// ── Project-bar drag (shifts all allocations) ──────────────────────────────
@@ -740,7 +785,12 @@ export function useTimelineDrag({
}
// Range select
const release = resolveRangeSelectionRelease(rangeStateRef.current, e.clientX, e.clientY, INITIAL_RANGE_STATE);
const release = resolveRangeSelectionRelease(
rangeStateRef.current,
e.clientX,
e.clientY,
INITIAL_RANGE_STATE,
);
if (release.kind !== "complete") return;
onRangeSelected?.(release.selection);