diff --git a/apps/client/src/features/base/hooks/__tests__/resolve-card-drop.spec.ts b/apps/client/src/features/base/hooks/__tests__/resolve-card-drop.spec.ts index f93fee353..4785d4161 100644 --- a/apps/client/src/features/base/hooks/__tests__/resolve-card-drop.spec.ts +++ b/apps/client/src/features/base/hooks/__tests__/resolve-card-drop.spec.ts @@ -1,8 +1,14 @@ import { describe, it, expect, vi } from "vitest"; +let throwOnNextCall = false; vi.mock("fractional-indexing-jittered", () => ({ - generateJitteredKeyBetween: (a: string | null, b: string | null) => - `${a ?? "START"}|${b ?? "END"}`, + generateJitteredKeyBetween: (a: string | null, b: string | null) => { + if (throwOnNextCall) { + throwOnNextCall = false; + throw new Error("lower >= upper"); + } + return `${a ?? "START"}|${b ?? "END"}`; + }, })); import { resolveCardDrop } from "../resolve-card-drop"; @@ -105,4 +111,22 @@ describe("resolveCardDrop", () => { expect(result.cells).toBeUndefined(); expect(result.position).toBeUndefined(); }); + + it("falls back to append-after-lower when generateJitteredKeyBetween throws", () => { + throwOnNextCall = true; + const result = resolveCardDrop({ + draggedCardId: "r1", + targetCardId: "r2", + edge: "top", + sourceColumnKey: "c1", + targetColumnKey: "c1", + groupByPropertyId: "prop-status", + columnRows: [mkRow("r2", "b"), mkRow("r3", "d")], + sortsActive: false, + }); + // First call (lower=null, upper="b") threw → second call (lower=null, + // upper=null) succeeds. The mock returns "START|END" in that case. + expect(result.cells).toBeUndefined(); + expect(result.position).toBe("START|END"); + }); }); diff --git a/apps/client/src/features/base/hooks/resolve-card-drop.ts b/apps/client/src/features/base/hooks/resolve-card-drop.ts index 7ea53531a..8bd7f0d68 100644 --- a/apps/client/src/features/base/hooks/resolve-card-drop.ts +++ b/apps/client/src/features/base/hooks/resolve-card-drop.ts @@ -80,8 +80,12 @@ export function resolveCardDrop( try { position = generateJitteredKeyBetween(lower, upper); } catch { - // Identical keys (rare; happens when two rows briefly share a position - // during a concurrent edit). Fall back to insert-after-lower. + // Throws whenever `lower >= upper` (the row ordering in `columnRows` + // briefly diverged from position ordering — typically during a + // concurrent reorder). Fall back to insert-after-lower. The card lands + // at the end of the column rather than at the dropped slot; surprising + // but better than rejecting the drop. The reconciliation arrives via + // the realtime patch. position = generateJitteredKeyBetween(lower, null); } return { cells, position };