diff --git a/apps/web/src/hooks/timelineDragCleanup.test.ts b/apps/web/src/hooks/timelineDragCleanup.test.ts new file mode 100644 index 0000000..9aa0cf9 --- /dev/null +++ b/apps/web/src/hooks/timelineDragCleanup.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it, vi } from "vitest"; +import { cleanupTimelineDragState } from "./timelineDragCleanup.js"; + +describe("timelineDragCleanup", () => { + it("runs registered cleanup callbacks, clears previews, and resets refs", () => { + const projectCleanup = vi.fn(); + const allocCleanup = vi.fn(); + const multiCleanup = vi.fn(); + const projectPreview = { id: "project-preview" }; + const allocPreview = { id: "alloc-preview" }; + const clearPreview = vi.fn(); + + const projectDragCleanupRef = { current: projectCleanup }; + const allocDragCleanupRef = { current: allocCleanup }; + const multiSelectCleanupRef = { current: multiCleanup }; + const projectPreviewRef = { current: projectPreview }; + const allocPreviewRef = { current: allocPreview }; + const dragStateRef = { current: { drag: true } }; + const allocDragRef = { current: { alloc: true } }; + const rangeStateRef = { current: { range: true } }; + const multiSelectRef = { current: { multi: true } }; + + const initialDragState = { drag: false }; + const initialAllocDragState = { alloc: false }; + const initialRangeState = { range: false }; + const initialMultiSelectState = { multi: false }; + + cleanupTimelineDragState({ + projectDragCleanupRef, + allocDragCleanupRef, + multiSelectCleanupRef, + projectPreviewRef, + allocPreviewRef, + dragStateRef, + allocDragRef, + rangeStateRef, + multiSelectRef, + initialDragState, + initialAllocDragState, + initialRangeState, + initialMultiSelectState, + clearPreview, + }); + + expect(projectCleanup).toHaveBeenCalledOnce(); + expect(allocCleanup).toHaveBeenCalledOnce(); + expect(multiCleanup).toHaveBeenCalledOnce(); + expect(clearPreview).toHaveBeenNthCalledWith(1, projectPreview); + expect(clearPreview).toHaveBeenNthCalledWith(2, allocPreview); + expect(projectDragCleanupRef.current).toBeNull(); + expect(allocDragCleanupRef.current).toBeNull(); + expect(multiSelectCleanupRef.current).toBeNull(); + expect(projectPreviewRef.current).toBeNull(); + expect(allocPreviewRef.current).toBeNull(); + expect(dragStateRef.current).toBe(initialDragState); + expect(allocDragRef.current).toBe(initialAllocDragState); + expect(rangeStateRef.current).toBe(initialRangeState); + expect(multiSelectRef.current).toBe(initialMultiSelectState); + }); + + it("tolerates missing cleanups and preview sessions", () => { + const clearPreview = vi.fn(); + + cleanupTimelineDragState({ + projectDragCleanupRef: { current: null }, + allocDragCleanupRef: { current: null }, + multiSelectCleanupRef: { current: null }, + projectPreviewRef: { current: null }, + allocPreviewRef: { current: null }, + dragStateRef: { current: { drag: true } }, + allocDragRef: { current: { alloc: true } }, + rangeStateRef: { current: { range: true } }, + multiSelectRef: { current: { multi: true } }, + initialDragState: { drag: false }, + initialAllocDragState: { alloc: false }, + initialRangeState: { range: false }, + initialMultiSelectState: { multi: false }, + clearPreview, + }); + + expect(clearPreview).toHaveBeenCalledTimes(2); + expect(clearPreview).toHaveBeenNthCalledWith(1, null); + expect(clearPreview).toHaveBeenNthCalledWith(2, null); + }); +}); diff --git a/apps/web/src/hooks/timelineDragCleanup.ts b/apps/web/src/hooks/timelineDragCleanup.ts new file mode 100644 index 0000000..ed08d87 --- /dev/null +++ b/apps/web/src/hooks/timelineDragCleanup.ts @@ -0,0 +1,74 @@ +type MutableCurrent = { + current: T; +}; + +type TimelineDragCleanupParams< + DragState, + AllocDragState, + RangeState, + MultiSelectState, + PreviewSession, +> = { + projectDragCleanupRef: MutableCurrent<(() => void) | null>; + allocDragCleanupRef: MutableCurrent<(() => void) | null>; + multiSelectCleanupRef: MutableCurrent<(() => void) | null>; + projectPreviewRef: MutableCurrent; + allocPreviewRef: MutableCurrent; + dragStateRef: MutableCurrent; + allocDragRef: MutableCurrent; + rangeStateRef: MutableCurrent; + multiSelectRef: MutableCurrent; + initialDragState: DragState; + initialAllocDragState: AllocDragState; + initialRangeState: RangeState; + initialMultiSelectState: MultiSelectState; + clearPreview: (session: PreviewSession | null) => void; +}; + +export function cleanupTimelineDragState< + DragState, + AllocDragState, + RangeState, + MultiSelectState, + PreviewSession, +>({ + projectDragCleanupRef, + allocDragCleanupRef, + multiSelectCleanupRef, + projectPreviewRef, + allocPreviewRef, + dragStateRef, + allocDragRef, + rangeStateRef, + multiSelectRef, + initialDragState, + initialAllocDragState, + initialRangeState, + initialMultiSelectState, + clearPreview, +}: TimelineDragCleanupParams< + DragState, + AllocDragState, + RangeState, + MultiSelectState, + PreviewSession +>) { + projectDragCleanupRef.current?.(); + allocDragCleanupRef.current?.(); + multiSelectCleanupRef.current?.(); + + projectDragCleanupRef.current = null; + allocDragCleanupRef.current = null; + multiSelectCleanupRef.current = null; + + clearPreview(projectPreviewRef.current); + clearPreview(allocPreviewRef.current); + + projectPreviewRef.current = null; + allocPreviewRef.current = null; + + dragStateRef.current = initialDragState; + allocDragRef.current = initialAllocDragState; + rangeStateRef.current = initialRangeState; + multiSelectRef.current = initialMultiSelectState; +} diff --git a/apps/web/src/hooks/useTimelineDrag.ts b/apps/web/src/hooks/useTimelineDrag.ts index fee84bd..53253cf 100644 --- a/apps/web/src/hooks/useTimelineDrag.ts +++ b/apps/web/src/hooks/useTimelineDrag.ts @@ -20,6 +20,7 @@ import { } from "./timelineAllocationMultiDrag.js"; import { resolveAllocationRelease } from "./timelineAllocationRelease.js"; import { createAllocationDragState } from "./timelineAllocationDragState.js"; +import { cleanupTimelineDragState } from "./timelineDragCleanup.js"; import { attachDocumentMouseDrag } from "./timelineDocumentDrag.js"; import { buildProjectShiftMutationInput, createProjectDragState } from "./timelineProjectDrag.js"; import { @@ -1005,20 +1006,22 @@ export function useTimelineDrag({ useEffect(() => { return () => { - projectDragCleanupRef.current?.(); - allocDragCleanupRef.current?.(); - multiSelectCleanupRef.current?.(); - projectDragCleanupRef.current = null; - allocDragCleanupRef.current = null; - multiSelectCleanupRef.current = null; - clearLivePreview(projectPreviewRef.current); - clearLivePreview(allocPreviewRef.current); - projectPreviewRef.current = null; - allocPreviewRef.current = null; - dragStateRef.current = INITIAL_DRAG_STATE; - allocDragRef.current = INITIAL_ALLOC_DRAG; - rangeStateRef.current = INITIAL_RANGE_STATE; - multiSelectRef.current = INITIAL_MULTI_SELECT; + cleanupTimelineDragState({ + projectDragCleanupRef, + allocDragCleanupRef, + multiSelectCleanupRef, + projectPreviewRef, + allocPreviewRef, + dragStateRef, + allocDragRef, + rangeStateRef, + multiSelectRef, + initialDragState: INITIAL_DRAG_STATE, + initialAllocDragState: INITIAL_ALLOC_DRAG, + initialRangeState: INITIAL_RANGE_STATE, + initialMultiSelectState: INITIAL_MULTI_SELECT, + clearPreview: clearLivePreview, + }); }; }, []); diff --git a/scripts/check-architecture-guardrails.mjs b/scripts/check-architecture-guardrails.mjs index 9b27470..b454423 100644 --- a/scripts/check-architecture-guardrails.mjs +++ b/scripts/check-architecture-guardrails.mjs @@ -270,6 +270,17 @@ export const rules = [ ], forbidden: [], }, + { + file: "apps/web/src/hooks/timelineDragCleanup.ts", + maxLines: 80, + required: [ + { + pattern: /\bexport function cleanupTimelineDragState\b/, + message: "timeline drag cleanup helpers must keep unmount teardown centralized", + }, + ], + forbidden: [], + }, { file: "apps/web/src/hooks/timelineDocumentDrag.ts", maxLines: 50, @@ -342,6 +353,10 @@ export const rules = [ pattern: /from "\.\/timelineAllocationRelease\.js"/, message: "timeline drag must keep allocation release classification delegated to the extracted helper module", }, + { + pattern: /from "\.\/timelineDragCleanup\.js"/, + message: "timeline drag must keep unmount teardown delegated to the extracted helper module", + }, { pattern: /from "\.\/timelineDocumentDrag\.js"/, message: "timeline drag must keep document mouse listener lifecycle delegated to the extracted helper module", diff --git a/scripts/check-architecture-guardrails.test.mjs b/scripts/check-architecture-guardrails.test.mjs index 3fc74d1..353ce5e 100644 --- a/scripts/check-architecture-guardrails.test.mjs +++ b/scripts/check-architecture-guardrails.test.mjs @@ -80,6 +80,7 @@ describe("architecture guardrails", () => { const allocationMultiDragRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineAllocationMultiDrag.ts"); const allocationActionsRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineAllocationActions.ts"); const allocationReleaseRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineAllocationRelease.ts"); + const cleanupRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineDragCleanup.ts"); const documentDragRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineDocumentDrag.ts"); const allocationDragStateRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineAllocationDragState.ts"); const projectDragRule = rules.find((rule) => rule.file === "apps/web/src/hooks/timelineProjectDrag.ts"); @@ -95,6 +96,7 @@ describe("architecture guardrails", () => { assert.ok(allocationMultiDragRule); assert.ok(allocationActionsRule); assert.ok(allocationReleaseRule); + assert.ok(cleanupRule); assert.ok(documentDragRule); assert.ok(allocationDragStateRule); assert.ok(projectDragRule); @@ -108,6 +110,7 @@ describe("architecture guardrails", () => { "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep optimistic allocation reconciliation delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation drag completion rules delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation release classification delegated to the extracted helper module", + "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep unmount teardown delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep document mouse listener lifecycle delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation multi-drag rules delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation drag bootstrap delegated to the extracted helper module", @@ -163,6 +166,10 @@ describe("architecture guardrails", () => { "apps/web/src/hooks/timelineAllocationRelease.ts: missing guardrail anchor: timeline allocation release helpers must keep click and mutation plan derivation delegated to allocation action helpers", ]); + assert.deepEqual(evaluateRule(cleanupRule, ""), [ + "apps/web/src/hooks/timelineDragCleanup.ts: missing guardrail anchor: timeline drag cleanup helpers must keep unmount teardown centralized", + ]); + assert.deepEqual(evaluateRule(documentDragRule, ""), [ "apps/web/src/hooks/timelineDocumentDrag.ts: missing guardrail anchor: timeline document drag helpers must keep document mouse listener wiring centralized", ]); @@ -188,6 +195,7 @@ describe("architecture guardrails", () => { "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep optimistic allocation reconciliation delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation drag completion rules delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation release classification delegated to the extracted helper module", + "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep unmount teardown delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep document mouse listener lifecycle delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation multi-drag rules delegated to the extracted helper module", "apps/web/src/hooks/useTimelineDrag.ts: missing guardrail anchor: timeline drag must keep allocation drag bootstrap delegated to the extracted helper module",