diff --git a/apps/marketing/src/app/global.css b/apps/marketing/src/app/global.css index 6c05e405c..903f57d8b 100644 --- a/apps/marketing/src/app/global.css +++ b/apps/marketing/src/app/global.css @@ -6,6 +6,13 @@ --breakpoint-nav: 1140px; } +@layer base { + button:not(:disabled), + [role="button"]:not(:disabled) { + cursor: pointer; + } +} + @source "../**/*.{html,tsx,ts,json,mdx}"; .simple-table-root { diff --git a/packages/core/src/core/SimpleTableVanilla.ts b/packages/core/src/core/SimpleTableVanilla.ts index 398593874..920414351 100644 --- a/packages/core/src/core/SimpleTableVanilla.ts +++ b/packages/core/src/core/SimpleTableVanilla.ts @@ -353,33 +353,23 @@ export class SimpleTableVanilla { } /** - * All cell-bearing containers — body sections AND header sections — that the - * animation coordinator needs to inspect. Headers participate in FLIP for - * column reorder so their cells slide to their new slot rather than - * teleporting. + * Containers the animation coordinator inspects for FLIP (sort / accordion). + * Column-drag uses {@link AnimationCoordinator.beginColumnReorder} instead. */ private getAnimatableContainers(): HTMLElement[] { return [...this.getBodyContainers(), ...this.getHeaderContainers()]; } - /** - * Capture pre-change cell positions for the FLIP animation, including - * conceptual positions for cells outside the virtualization viewport so - * incoming cells can animate from off-screen on column reorder/sort. The - * `play` step that runs at the end of the next render consumes this - * snapshot to inverse-transform cells from their old visual positions and - * tween them to their new ones. - * - * Called on every layout-affecting state change — including the chain of - * mid-drag `setHeaders` calls that fire on each `dragover` swap — so that - * displaced columns slide smoothly out of the dragged column's way rather - * than snapping into place. - */ /** * Build a key summarizing the leaf columns that will paint (accessor + * pinned section). Hidden leaves and excluded subtrees drop out; nested * children are flattened so a parent collapse/expand counts as a * visibility change at the leaf level too. + * + * Parts are sorted so sibling reorder does not look like a visibility + * change — otherwise mid-drag `setHeaders` opens the horizontal accordion + * path, and the accordion interrupt `cancel()` snaps in-flight FLIP + * animations (flicker). */ private buildVisibilityKey(headers: ColumnDef[]): string { const parts: string[] = []; @@ -393,6 +383,7 @@ export class SimpleTableVanilla { } }; for (const header of headers) walk(header, undefined); + parts.sort(); return parts.join("|"); } @@ -414,10 +405,22 @@ export class SimpleTableVanilla { return this.lastRenderedVisibilityKey !== null && nextKey !== this.lastRenderedVisibilityKey; } + /** + * Capture pre-change cell positions for the FLIP animation, including + * conceptual positions for cells outside the virtualization viewport so + * incoming cells can animate from off-screen on sort/accordion. The `play` + * step that runs at the end of the next render consumes this snapshot to + * inverse-transform cells from their old visual positions and tween them + * to their new ones. + * + * Column-drag reorders use {@link AnimationCoordinator.beginColumnReorder} + * instead of this path. + */ private captureAnimationSnapshot(): void { // Skip the (potentially large) full-section pre-layout build when // animations are disabled — captureSnapshot would discard the result // anyway, but the argument is evaluated eagerly before the bail-out. + // Mid-drag column reorder uses beginColumnReorder instead of this path. const preLayouts = this.animationCoordinator.isEnabled() ? this.renderOrchestrator.getCurrentBodyLayouts() : undefined; @@ -426,8 +429,9 @@ export class SimpleTableVanilla { // Feed the real visible viewport (the same metrics that drive // virtualization) so sort slides stay bounded to the on-screen area. this.updateAnimationVerticalScroll(); + const containers = this.getAnimatableContainers(); this.animationCoordinator.captureSnapshot({ - containers: this.getAnimatableContainers(), + containers, preLayouts, }); } @@ -1344,6 +1348,7 @@ export class SimpleTableVanilla { rowSelectionManager: this.rowSelectionManager, rowStateMap: this.rowStateMap, positionOnlyBody: this._positionOnlyBody, + columnDragging: Boolean(this.draggedHeaderRef.current), // Drives the virtualization window (calculateContentHeight) in external // scroll mode. Gate purely on a positive cached viewport — NOT on // `resolvedScrollParent` — so the provisional viewport seeded before a @@ -1381,6 +1386,14 @@ export class SimpleTableVanilla { const visibilityChanged = this.didColumnVisibilityChange(headers); if (visibilityChanged) { this.beginAccordionAnimation("horizontal"); + } else if ( + this.draggedHeaderRef.current || + this.animationCoordinator.isColumnReordering() + ) { + // Column-drag: dedicated animator snapshots visuals; skip FLIP capture. + const root = + this.domManager.getElements()?.rootElement ?? this.container; + this.animationCoordinator.beginColumnReorder(root); } else { this.captureAnimationSnapshot(); } @@ -1685,8 +1698,12 @@ export class SimpleTableVanilla { return; } - // During scroll use position-only body updates; full update on scroll-end or other triggers - this._positionOnlyBody = source === "scroll-raf" && this.isScrolling === true; + // During scroll use position-only body updates; full update on scroll-end or other triggers. + // Mid column-drag: same fast path — only left/top change; full body content + // refresh on every dragover was a major main-thread stall (~300ms clock-leaps). + const columnDragging = Boolean(this.draggedHeaderRef.current); + this._positionOnlyBody = + (source === "scroll-raf" && this.isScrolling === true) || columnDragging; const elements = this.domManager.getElements(); const refs = this.domManager.getRefs(); @@ -1723,11 +1740,15 @@ export class SimpleTableVanilla { // in-coming cells aren't FLIP-tweened during vertical scrolls. Live-sort // reorders (from updateData) also skip play so they don't interrupt an // in-flight user sort or thrash retained-cell cleanup every tick. - // Every other render — including the chain of mid-drag `setHeaders` renders - // that fire on each `dragover` swap — runs play so columns being - // displaced by the drag slide smoothly to their new slots. + // Column-drag uses ColumnReorderAnimator (commit after left writes) instead + // of the general capture/play FLIP path. if (source !== "scroll-raf" && source !== "live-sort") { - this.animationCoordinator.play({ containers: this.getAnimatableContainers() }); + if (columnDragging || this.animationCoordinator.isColumnReordering()) { + const root = elements.rootElement ?? this.container; + this.animationCoordinator.commitColumnReorder(root); + } else { + this.animationCoordinator.play({ containers: this.getAnimatableContainers() }); + } } this.maybeScheduleUnvirtualizedRowsWarning(); diff --git a/packages/core/src/core/rendering/RenderOrchestrator.ts b/packages/core/src/core/rendering/RenderOrchestrator.ts index 6cdf15fed..1cdce4092 100644 --- a/packages/core/src/core/rendering/RenderOrchestrator.ts +++ b/packages/core/src/core/rendering/RenderOrchestrator.ts @@ -108,6 +108,11 @@ export interface RenderContext { sortManager: SortManager | null; /** When true, body cells that stay visible get only position updates (no content/selection recalc). Used during vertical scroll for performance. */ positionOnlyBody?: boolean; + /** + * Mid column-header drag. Row model is unchanged — reuse last flatten/process + * results and only repaint header/body lefts. + */ + columnDragging?: boolean; /** * Visible portion of the table inside an external scroll parent (in pixels). * Set by {@link SimpleTableVanilla} per render when `config.scrollParent` is @@ -251,10 +256,17 @@ export class RenderOrchestrator { maxHeaderDepth: number; flattenResult: FlattenRowsResult; processedResult: ProcessRowsResult; + headersUnchangedForScrollBailout: boolean; } | null { if (this.lastHeadersRef !== context.headers) { this.invalidateCache("header"); - this.invalidateCache("context"); + // Mid column-drag only changes sibling order — wiping row-model caches + // forces flatten/processRows on every dragover (~50–90ms). Keep them. + if (!context.columnDragging) { + this.invalidateCache("context"); + } else { + this.scrollRafHeadersMemo = null; + } this.lastHeadersRef = context.headers; } @@ -270,11 +282,16 @@ export class RenderOrchestrator { : [...context.collapsedHeaders].map(String).sort().join("\0"); let effectiveHeaders: ColumnDef[]; + // Capture before memo refresh — column-drag reuses positionOnlyBody but + // must still paint (header order / cell lefts changed). The scroll + // unchanged-range bailout below is only safe when headers are identical. + const headersUnchangedForScrollBailout = + this.scrollRafHeadersMemo?.headersRef === context.headers; if ( context.positionOnlyBody && context.config.autoExpandColumns !== true && this.scrollRafHeadersMemo && - this.scrollRafHeadersMemo.headersRef === context.headers && + headersUnchangedForScrollBailout && this.scrollRafHeadersMemo.containerWidth === containerWidth && this.scrollRafHeadersMemo.collapsedKey === collapsedKey ) { @@ -476,7 +493,7 @@ export class RenderOrchestrator { : `${canUseCache ? 1 : 0}|${contentHeight}|${state.currentPage}|${rowsPerPage}|${enablePagination}|${serverSidePagination}|${context.customTheme.rowHeight}|${calculatedHeaderHeight}|${totalRowCountForHeight}|${enableStickyParents}|${rowGroupingKey}|${flattenResult.flattenedRows.length}|${heightOffsetsLen}|${heightOffsetsChecksum}`; const scrollReuseEligible = - Boolean(context.positionOnlyBody) && + (Boolean(context.positionOnlyBody) || Boolean(context.columnDragging)) && contentHeight !== undefined && this.processRowsScrollReuseKey !== null && this.processRowsScrollReuseBase !== null && @@ -535,6 +552,7 @@ export class RenderOrchestrator { maxHeaderDepth, flattenResult, processedResult, + headersUnchangedForScrollBailout, }; } @@ -570,6 +588,7 @@ export class RenderOrchestrator { maxHeaderDepth, flattenResult, processedResult, + headersUnchangedForScrollBailout, } = snapshot; this.lastProcessedResult = processedResult; @@ -577,6 +596,8 @@ export class RenderOrchestrator { if ( verticalScrollFastPath && + !context.columnDragging && + headersUnchangedForScrollBailout && this.lastScrollRafPaintedRange !== null && processedResult.renderedStartIndex === this.lastScrollRafPaintedRange.start && processedResult.renderedEndIndex === this.lastScrollRafPaintedRange.end @@ -656,6 +677,17 @@ export class RenderOrchestrator { effectiveHeaders, context, ); + } else if (context.columnDragging) { + // Column-drag reuses the body position-only fast path for perf, but must + // still repaint headers — otherwise setHeaders updates leaf order in state + // while header style.left stays put (no FLIP, continuity order never moves). + this.renderHeader( + elements.headerContainer, + calculatedHeaderHeight, + maxHeaderDepth, + effectiveHeaders, + context, + ); } this.renderBody(elements.bodyContainer, processedResult, effectiveHeaders, context, state); diff --git a/packages/core/src/core/rendering/SectionRenderer.ts b/packages/core/src/core/rendering/SectionRenderer.ts index 0230e121a..b7eda0138 100644 --- a/packages/core/src/core/rendering/SectionRenderer.ts +++ b/packages/core/src/core/rendering/SectionRenderer.ts @@ -85,6 +85,8 @@ interface BodyCellsCacheEntry { cells: AbsoluteBodyCell[]; deps: { headersHash: string; + /** Order-independent leaf signature (accessor+width+pin+hide). */ + headersStructureHash?: string; rowsRef: TableRow[]; collapsedHeadersSize: number; rowHeight: number; @@ -1058,6 +1060,24 @@ export class SectionRenderer { return headers.map(hashHeader).join("|"); } + /** + * Order-independent leaf signature. Used to detect pure sibling reorders so + * body cell geometry can remap `left` without a full AbsoluteBodyCell rebuild. + */ + private createHeadersStructureHash( + headers: ColumnDef[], + collapsedHeaders: Set = new Set(), + ): string { + const leaves = this.getLeafHeaders(headers, collapsedHeaders); + return leaves + .map( + (h) => + `${h.accessor}:${h.width}:${h.pinned || ""}:${h.hide || ""}:${h.excludeFromRender || ""}`, + ) + .sort() + .join("|"); + } + private createHeightOffsetsHash( heightOffsets?: Array<[number, number]>, ): string { @@ -1213,6 +1233,7 @@ export class SectionRenderer { renderedEndIndex?: number, ): AbsoluteBodyCell[] { const headersHash = this.createHeadersHash(headers); + const headersStructureHash = this.createHeadersStructureHash(headers, collapsedHeaders); const heightOffsetsHash = this.createHeightOffsetsHash(heightOffsets); const useRangeCache = fullTableRows != null && @@ -1224,18 +1245,21 @@ export class SectionRenderer { const bandCoversViewport = (bandStart: number, bandEnd: number) => bandStart <= renderedStartIndex! && bandEnd >= renderedEndIndex!; + const rowsMatch = useRangeCache + ? cached && + cached.deps.fullTableRowsRef === fullTableRows && + cached.deps.bandStart !== undefined && + cached.deps.bandEnd !== undefined && + bandCoversViewport(cached.deps.bandStart, cached.deps.bandEnd) + : cached && cached.deps.rowsRef === rows; + const cacheHit = cached && cached.deps.headersHash === headersHash && cached.deps.collapsedHeadersSize === collapsedHeaders.size && cached.deps.rowHeight === rowHeight && cached.deps.heightOffsetsHash === heightOffsetsHash && - (useRangeCache - ? cached.deps.fullTableRowsRef === fullTableRows && - cached.deps.bandStart !== undefined && - cached.deps.bandEnd !== undefined && - bandCoversViewport(cached.deps.bandStart, cached.deps.bandEnd) - : cached.deps.rowsRef === rows); + rowsMatch; if (cacheHit && cached) { if (!useRangeCache) { @@ -1260,6 +1284,65 @@ export class SectionRenderer { return out; } + // Column reorder: same leaves/widths/rows, only sibling order changed. + // Remap `left`/`colIndex` from the new header positions instead of + // rebuilding AbsoluteBodyCell[] for every visible row (50–90ms stalls). + if ( + cached && + cached.deps.headersStructureHash === headersStructureHash && + cached.deps.collapsedHeadersSize === collapsedHeaders.size && + cached.deps.rowHeight === rowHeight && + cached.deps.heightOffsetsHash === heightOffsetsHash && + rowsMatch + ) { + const leafHeaders = this.getLeafHeaders(headers, collapsedHeaders); + const headerPositions = new Map(); + let currentLeft = 0; + leafHeaders.forEach((header, leafIndex) => { + const width = typeof header.width === "number" ? header.width : 150; + headerPositions.set(String(header.accessor), { left: currentLeft, width, leafIndex }); + currentLeft += width; + }); + const remapped: AbsoluteBodyCell[] = []; + for (const c of cached.cells) { + const pos = headerPositions.get(String(c.header.accessor)); + if (!pos) continue; + remapped.push({ + ...c, + header: leafHeaders[pos.leafIndex] ?? c.header, + left: pos.left, + width: pos.width, + colIndex: startColIndex + pos.leafIndex, + }); + } + this.bodyCellsCache.set(sectionKey, { + cells: remapped, + deps: { + ...cached.deps, + headersHash, + headersStructureHash, + }, + }); + if (!useRangeCache) { + return remapped; + } + const positionToVisualIndex = new Map(); + rows.forEach((r, i) => { + positionToVisualIndex.set(r.position, i); + }); + const out: AbsoluteBodyCell[] = []; + for (const c of remapped) { + const ri = positionToVisualIndex.get(c.tableRow.position); + if (ri === undefined) continue; + if (c.rowIndex !== ri) { + out.push({ ...c, rowIndex: ri }); + } else { + out.push(c); + } + } + return out; + } + let bandSlice: TableRow[]; let bandStart: number | undefined; let bandEnd: number | undefined; @@ -1286,6 +1369,7 @@ export class SectionRenderer { cells, deps: { headersHash, + headersStructureHash, rowsRef: bandSlice, collapsedHeadersSize: collapsedHeaders.size, rowHeight, diff --git a/packages/core/src/managers/AnimationCoordinator.ts b/packages/core/src/managers/AnimationCoordinator.ts index 45bc0105c..dbba5e910 100644 --- a/packages/core/src/managers/AnimationCoordinator.ts +++ b/packages/core/src/managers/AnimationCoordinator.ts @@ -1,5 +1,10 @@ import { getRenderedCells as getBodyRenderedCells } from "../utils/bodyCell/eventTracking"; import { getRenderedCells as getHeaderRenderedCells } from "../utils/headerCell/eventTracking"; +import { + parseCssTranslate, + setFlipCompensationEnabled, +} from "../utils/setAbsoluteCellPosition"; +import { ColumnReorderAnimator } from "./ColumnReorderAnimator"; const DEFAULT_DURATION = 400; /** @@ -23,6 +28,8 @@ const MIN_DELTA = 0.5; const SAFETY_TIMEOUT_SLACK = 80; const RETAINED_CLASS = "st-cell-animating-out"; const RETAINED_ATTR = "data-animating-out"; +/** Marks a cell mid-FLIP so CSS can drop opaque fills (headers pass through). */ +const FLIP_ACTIVE_CLASS = "st-flip-active"; /** * Marker on retained ghost cells whose only animation is a CSS-driven * width/height shrink (no FLIP transform). The `play()` per-cell loop must @@ -229,8 +236,11 @@ export class AnimationCoordinator { * uses) so the y-axis FLIP scaling matches the on-screen viewport. `null` * when external scroll is inactive — internal scroller metrics are used as-is. */ - private externalVerticalScroll: { clientHeight: number; scrollHeight: number; scrollTop: number } | null = - null; + private externalVerticalScroll: { + clientHeight: number; + scrollHeight: number; + scrollTop: number; + } | null = null; /** * The currently-scheduled (not-yet-started) FLIP frame. play() defers the @@ -244,7 +254,23 @@ export class AnimationCoordinator { * the pending frame lets a new play() cancel the prior cycle and reset the * transforms it left behind, so only the latest sort animates. */ - private scheduledFlip: { rafId: number; pending: Array<{ element: HTMLElement }> } | null = null; + private scheduledFlip: { + rafId: number; + pending: Array<{ cellId: string; element: HTMLElement; isRetained: boolean }>; + /** Monotonic id so a cancelled double-rAF callback can detect it is stale. */ + generation: number; + } | null = null; + private flipGeneration = 0; + + /** + * True while the user is mid column-header drag-reorder. Column-drag motion + * is owned by {@link ColumnReorderAnimator} (not capture/play FLIP). + */ + private columnReordering = false; + + /** Dedicated WAAPI retarget animator for live column-header drag. */ + private readonly columnReorderAnimator = new ColumnReorderAnimator(); + /** * Invoked immediately BEFORE a retained/ghost element is permanently removed @@ -260,6 +286,7 @@ export class AnimationCoordinator { this.duration = opts.duration ?? DEFAULT_DURATION; this.easing = opts.easing ?? DEFAULT_EASING; this.prefersReducedMotion = readPrefersReducedMotion(); + this.columnReorderAnimator.setDuration(this.duration); } /** @@ -281,6 +308,7 @@ export class AnimationCoordinator { setDuration(duration: number): void { if (Number.isFinite(duration) && duration > 0) { this.duration = duration; + this.columnReorderAnimator.setDuration(duration); } } @@ -294,13 +322,47 @@ export class AnimationCoordinator { return this.enabled && !this.prefersReducedMotion; } + /** + * Enter/leave column-header drag-reorder mode. Motion is owned by + * {@link ColumnReorderAnimator}. Flip compensation is OFF so left writes + * stay plain; the animator applies hold+tween after those writes. + */ + setColumnReordering(active: boolean): void { + if (this.columnReordering === active) return; + this.columnReordering = active; + this.columnReorderAnimator.setActive(active); + setFlipCompensationEnabled(!active); + } + + isColumnReordering(): boolean { + return this.columnReordering; + } + + /** + * Snapshot header visuals before mid-drag style.left rewrites. + * Call instead of {@link captureSnapshot} while column-dragging. + */ + beginColumnReorder(root: ParentNode): void { + if (!this.isEnabled() || !this.columnReordering) return; + this.columnReorderAnimator.beginOrderChange(root); + } + + /** + * Retarget WAAPI after style.left rewrites (same task, before paint). + * Call instead of {@link play} while column-dragging. + */ + commitColumnReorder(root: ParentNode): void { + if (!this.isEnabled() || !this.columnReordering) return; + this.columnReorderAnimator.commitOrderChange(root); + } + isInFlight(cellId: string): boolean { return this.inFlight.has(cellId); } - /** True while any FLIP / retained-cell transition is still running. */ + /** True while any FLIP / retained-cell / column-reorder transition is running. */ hasInFlight(): boolean { - return this.inFlight.size > 0; + return this.inFlight.size > 0 || this.columnReorderAnimator.hasInFlight(); } getDuration(): number { @@ -635,21 +697,9 @@ export class AnimationCoordinator { // between the two is NOT required — a row can enter the band at the // same absolute `top` after a sort (stable/equal keys) and still needs // its DOM cell; skipping mount left the first visible slot empty. - const wasVisibleY = isRowTopInVerticalViewport( - entry.styleTop, - args.cellHeight, - metrics, - ); - const willBeVisibleY = isRowTopInVerticalViewport( - args.afterTop, - args.cellHeight, - metrics, - ); - const wasVisibleX = isColumnLeftInHorizontalViewport( - entry.styleLeft, - args.cellWidth, - metrics, - ); + const wasVisibleY = isRowTopInVerticalViewport(entry.styleTop, args.cellHeight, metrics); + const willBeVisibleY = isRowTopInVerticalViewport(args.afterTop, args.cellHeight, metrics); + const wasVisibleX = isColumnLeftInHorizontalViewport(entry.styleLeft, args.cellWidth, metrics); const willBeVisibleX = isColumnLeftInHorizontalViewport( args.afterLeft, args.cellWidth, @@ -702,8 +752,7 @@ export class AnimationCoordinator { const metrics = this.getScrollerMetrics(container); if (metrics.scrollHeight <= metrics.clientHeight) return false; - const atBottom = - metrics.scrollTop + metrics.clientHeight >= metrics.scrollHeight - 1; + const atBottom = metrics.scrollTop + metrics.clientHeight >= metrics.scrollHeight - 1; const atTop = metrics.scrollTop <= 1; if (!atBottom && !atTop) return false; @@ -931,6 +980,11 @@ export class AnimationCoordinator { * retained cell). Clears the snapshot. */ play(args: { containers: Array }): void { + // Column-drag uses {@link commitColumnReorder} — never the general FLIP path. + if (this.columnReordering) { + this.snapshot = null; + return; + } const snapshot = this.snapshot; const incomingOrigins = this.incomingOrigins; this.snapshot = null; @@ -970,6 +1024,8 @@ export class AnimationCoordinator { dx: number; dy: number; isRetained: boolean; + /** True when style.left/top matches the capture snapshot (same logical slot). */ + destUnchanged: boolean; }; const pending: Pending[] = []; const seen = new Set(); @@ -1053,9 +1109,7 @@ export class AnimationCoordinator { // leave the in-flight transition running. Restarting it would freeze the // cell for 2 rAFs, reset the easing curve back to its fast start, and // produce a visible velocity discontinuity — exactly the "jump" users see - // when triggering a sort while another sort is mid-animation. The new - // FLIP transform would be identical to the live computed transform - // anyway, so the cancel + restart adds nothing but a stutter. + // when triggering a sort while another sort is mid-animation. if ( !isRetained && this.inFlight.has(cellId) && @@ -1093,6 +1147,7 @@ export class AnimationCoordinator { // as preLayout entries. For these we need the cell's own size; // prefer the inline style (no layout) over offsetHeight/offsetWidth // (forces layout). + // const skipScale = isRetained || before.fromDom; const cellHeight = skipScale ? 0 : parsePx(element.style.height) || element.offsetHeight || 0; const cellWidth = skipScale ? 0 : parsePx(element.style.width) || element.offsetWidth || 0; @@ -1115,39 +1170,26 @@ export class AnimationCoordinator { parsePx(element.style.height) || element.offsetHeight || cellHeight || 0; let beforeTopForFlip = beforeTopClipped; const willBeVisibleYForClamp = vpMetricsForClamp - ? isRowTopInVerticalViewport( - currentTop, - vpCellHeightForClamp, - vpMetricsForClamp, - ) + ? isRowTopInVerticalViewport(currentTop, vpCellHeightForClamp, vpMetricsForClamp) : false; // PreLayout snapshot entries (sourceContainer === null) describe conceptual // positions for rows that were NOT in the DOM — even when that position // falls inside the viewport band. Treat them as incoming slide-ins. - const isPreLayoutIncoming = - !isRetained && before.sourceContainer === null && !before.fromDom; + const isPreLayoutIncoming = !isRetained && before.sourceContainer === null && !before.fromDom; if (!isRetained && vpMetricsForClamp && willBeVisibleYForClamp) { const vpTop = vpMetricsForClamp.scrollTop; const vpBottom = vpMetricsForClamp.scrollTop + vpMetricsForClamp.clientHeight; if ( isPreLayoutIncoming && (Math.abs(beforeTopClipped - currentTop) < MIN_DELTA || - isRowTopInVerticalViewport( - beforeTopClipped, - vpCellHeightForClamp, - vpMetricsForClamp, - )) + isRowTopInVerticalViewport(beforeTopClipped, vpCellHeightForClamp, vpMetricsForClamp)) ) { // Band entry without a real prior DOM position — slide from the // nearest viewport edge so the first visible row animates like peers. beforeTopForFlip = currentTop >= beforeTopClipped ? vpTop - vpCellHeightForClamp : vpBottom; } else if ( - !isRowTopInVerticalViewport( - beforeTopClipped, - vpCellHeightForClamp, - vpMetricsForClamp, - ) + !isRowTopInVerticalViewport(beforeTopClipped, vpCellHeightForClamp, vpMetricsForClamp) ) { beforeTopForFlip = currentTop >= beforeTopClipped ? vpTop - vpCellHeightForClamp : vpBottom; @@ -1203,6 +1245,19 @@ export class AnimationCoordinator { const dxRaw = beforeLeftClipped - currentLeft; const dyRaw = beforeTopForFlip - currentTop; + // If the cell did not move in style-space, do not invent a FLIP from + // containerShift alone. That animates every stationary header/body cell + // whenever a sibling section's width changes (pin/unpin, scrollbar), + // which reads as "columns that aren't involved are jumping". + if (Math.abs(dxRaw) < MIN_DELTA && Math.abs(dyRaw) < MIN_DELTA) { + if (isRetained) { + this.cancelInFlight(cellId); + this.retainedCells.get(container)?.delete(cellId); + this.onHostDiscard?.(element); + element.remove(); + } + return; + } let dx = dxRaw - containerShiftX; let dy = dyRaw - containerShiftY; @@ -1223,7 +1278,11 @@ export class AnimationCoordinator { return; } - pending.push({ cellId, element, dx, dy, isRetained }); + const destUnchanged = + Math.abs(before.styleLeft - currentLeft) < MIN_DELTA && + Math.abs(before.styleTop - currentTop) < MIN_DELTA; + + pending.push({ cellId, element, dx, dy, isRetained, destUnchanged }); seen.add(cellId); }; @@ -1246,19 +1305,43 @@ export class AnimationCoordinator { } // Coalesce overlapping FLIP cycles. If a previous play() scheduled a - // transition start that hasn't run yet (spam-clicking sort fires a new - // render + play within the two-frame defer window), cancel it and reset - // the inverted transforms it left on its cells. The invert loop below - // re-applies the transform for any cell still being animated this cycle; - // cells that were only in the stale cycle snap to their current - // (already-updated) position instead of being clobbered or stranded with - // a leftover transform. + // transition start that hasn't run yet (spam-clicking sort / rapid + // header-drag reorders fire a new render + play within the two-frame + // defer window), cancel it. Cells still carrying an invert from the + // cancelled cycle are promoted into this cycle's pending set so they + // get a fresh double-rAF → startTransition (calling startTransition + // synchronously here would write identity in the same frame as an + // unpainted invert and snap the cell to its finished slot). if (this.scheduledFlip) { cancelAnimationFrame(this.scheduledFlip.rafId); - for (const { element } of this.scheduledFlip.pending) { - element.style.transition = "none"; - element.style.transform = ""; - element.style.willChange = ""; + const nextPendingIds = new Set(pending.map((p) => p.cellId)); + for (const { cellId, element, isRetained } of this.scheduledFlip.pending) { + if (nextPendingIds.has(cellId) || seen.has(cellId)) { + continue; + } + // Mid-transition cells often already have style.transform at identity + // while the compositor matrix is still mid-slide — bake before deciding + // whether to promote or clear (clearing snaps to style.left). + this.bakeLiveTransform(element); + const live = parseCssTranslate(element.style.transform || ""); + if (live && hasNonIdentityTranslate(element.style.transform || "")) { + pending.push({ + cellId, + element, + dx: live.x, + dy: live.y, + isRetained, + destUnchanged: true, + }); + seen.add(cellId); + nextPendingIds.add(cellId); + } else { + element.style.transition = "none"; + element.style.transform = ""; + element.style.willChange = ""; + element.style.pointerEvents = ""; + element.classList.remove(FLIP_ACTIVE_CLASS); + } } this.scheduledFlip = null; } @@ -1269,13 +1352,61 @@ export class AnimationCoordinator { // both the inverted write and the identity write happen before the same // paint, the browser only ever paints the identity state, and the // transition fires from identity → identity (no visual movement). - for (const { cellId, element, dx, dy } of pending) { - this.cancelInFlight(cellId); - element.style.transition = "none"; + for (const item of pending) { + const { cellId, element } = item; + let { dx, dy } = item; + const wasInFlight = this.inFlight.has(cellId); + // Freeze the live matrix BEFORE cancelInFlight → Animation.cancel(). + // Cancelling a running/paused CSS transition drops the effect and falls + // back to style.transform (often already identity mid-transition), which + // snaps the cell to its finished slot for a frame — the continuity + // "teleport" (~½ leaf width) on interrupt reorders. + if (wasInFlight) { + element.style.transition = "none"; + // Prefer already-frozen style (no layout). Only read computed when + // style is identity while the compositor may still be mid-slide. + if (!hasNonIdentityTranslate(element.style.transform || "")) { + const computed = getComputedStyle(element).transform; + if (computed && computed !== "none") { + element.style.transform = computed; + } + } + } else { + element.style.transition = "none"; + } + this.cancelInFlight(cellId, { skipBake: true }); + // After freeze (or left-write compensation / settled pin), the live + // translate holds the painted offset relative to style.left/top *as of + // the freeze*. Prefer it over a capture-time dx only when the logical + // destination did not change — otherwise (rapid column-drag swaps) + // style.left has already been rewritten and a pre-compensation freeze + // would be relative to the *previous* slot. Reusing that would park the + // cell at newLeft+oldTranslate (a one-slot jump) instead of the snapshot + // visual. When compensation/pin ran, live translate ≈ snapshot dx and + // either path agrees. + const priorTransform = element.style.transform || ""; + const liveTranslate = parseCssTranslate(priorTransform); + if (liveTranslate && hasNonIdentityTranslate(priorTransform)) { + const matchesSnapshot = + Math.abs(liveTranslate.x - dx) <= 1 && Math.abs(liveTranslate.y - dy) <= 1; + // Same destination mid-flight: keep frozen visual (no velocity snap). + // Stranded invert: keep live when it already matches the snapshot. + // Retargeted mid-flight: keep snapshot dx/dy (computed above). + if ((wasInFlight && item.destUnchanged) || (!wasInFlight && matchesSnapshot)) { + dx = liveTranslate.x; + dy = liveTranslate.y; + } + } element.style.transform = `translate3d(${dx}px, ${dy}px, 0)`; element.style.willChange = "transform"; + element.classList.add(FLIP_ACTIVE_CLASS); } + // One layout flush for the whole invert batch — per-cell offsetWidth was + // thrashing style/layout (Chrome "rAF handler took Nms") and letting the + // compositor race ahead between cells (~1–2px hitches on interrupt). + this.flushLayoutOnce(); + if (pending.length === 0) return; // Double RAF: rAF #1 callback runs BEFORE the next paint, so the browser @@ -1284,34 +1415,39 @@ export class AnimationCoordinator { // so by the time `startTransition` runs, the browser's last painted // computed transform is `translate3d(dx, dy, 0)` and the new write to // `translate3d(0, 0, 0)` triggers a real interpolation. + const generation = ++this.flipGeneration; + const pendingForRaf = pending; const rafOuter = requestAnimationFrame(() => { const rafInner = requestAnimationFrame(() => { this.scheduledFlip = null; - for (const { cellId, element, isRetained } of pending) { - if (!element.isConnected) continue; - this.startTransition(cellId, element, isRetained); - } + this.startTransitionsBatch(pendingForRaf); }); // The outer frame has run; the pending transition start is now the // inner frame. Point the coalesce handle at it so a play() that lands // between the two frames cancels the correct callback. - if (this.scheduledFlip) this.scheduledFlip.rafId = rafInner; + if (this.scheduledFlip && this.scheduledFlip.generation === generation) { + this.scheduledFlip.rafId = rafInner; + } }); - this.scheduledFlip = { rafId: rafOuter, pending }; + this.scheduledFlip = { rafId: rafOuter, pending: pendingForRaf, generation }; } /** - * Cancel every in-flight transition and clear any armed snapshot. Active - * cells snap to their final positions; retained cells are removed from the - * DOM so we don't leak nodes. + * Snap scheduled + in-flight FLIPs to their destinations without clearing + * an armed snapshot. Used between rapid column-drag swaps so each swap + * starts from settled style.left (grid-aligned) instead of compounding + * mid-flight visual dx. */ - cancel(): void { - this.snapshot = null; - this.incomingOrigins = null; - this.accordionPreVisibleAccessors = null; - this.clearScrollerMetricsCache(); + private settleInFlight(): void { if (this.scheduledFlip) { cancelAnimationFrame(this.scheduledFlip.rafId); + for (const { element } of this.scheduledFlip.pending) { + element.style.transition = "none"; + element.style.transform = ""; + element.style.willChange = ""; + element.style.pointerEvents = ""; + element.classList.remove(FLIP_ACTIVE_CLASS); + } this.scheduledFlip = null; } const entries = Array.from(this.inFlight.entries()); @@ -1321,6 +1457,19 @@ export class AnimationCoordinator { entry.element.removeEventListener("transitionend", entry.transitionEndHandler); this.finishElement(cellId, entry.element, entry.isRetained); } + } + + /** + * Cancel every in-flight transition and clear any armed snapshot. Active + * cells snap to their final positions; retained cells are removed from the + * DOM so we don't leak nodes. + */ + cancel(): void { + this.snapshot = null; + this.incomingOrigins = null; + this.accordionPreVisibleAccessors = null; + this.clearScrollerMetricsCache(); + this.settleInFlight(); // Clean up any retained cells that weren't in flight (e.g. cell was // retained but never reached the play step). this.retainedCells.forEach((map) => { @@ -1334,9 +1483,46 @@ export class AnimationCoordinator { } destroy(): void { + this.setColumnReordering(false); + this.columnReorderAnimator.destroy(); this.cancel(); } + private readVisualPosition( + element: HTMLElement, + sourceContainer: HTMLElement, + sourceContainerLeft: number, + sourceContainerTop: number, + styleTop: number, + styleLeft: number, + ): CellSnapshot { + const rect = element.getBoundingClientRect(); + const parent = element.offsetParent as HTMLElement | null; + if (parent) { + const parentRect = parent.getBoundingClientRect(); + return { + sourceContainer, + sourceContainerLeft, + sourceContainerTop, + left: rect.left - parentRect.left + parent.scrollLeft, + top: rect.top - parentRect.top + parent.scrollTop, + styleTop, + styleLeft, + fromDom: true, + }; + } + return { + sourceContainer, + sourceContainerLeft, + sourceContainerTop, + left: rect.left, + top: rect.top, + styleTop, + styleLeft, + fromDom: true, + }; + } + private readPosition( cellId: string, element: HTMLElement, @@ -1346,39 +1532,72 @@ export class AnimationCoordinator { ): CellSnapshot { const styleTop = parsePx(element.style.top); const styleLeft = parsePx(element.style.left); - const inFlight = this.inFlight.get(cellId); - if (inFlight) { - const rect = element.getBoundingClientRect(); - const parent = element.offsetParent as HTMLElement | null; - if (parent) { - const parentRect = parent.getBoundingClientRect(); + // Use the live visual position whenever a FLIP transform is still on the + // element — including the double-rAF gap where invert is applied but + // `inFlight` is not set yet, and stranded-invert cases where scheduledFlip + // was cleared without clearing transforms. Capturing logical style.left + // here is what makes rapid reorders "jump then animate". + // + // During an active CSS transition, `style.transform` is already identity + // while the *computed* matrix is mid-slide. Prefer computed / `.st-flip-active` + // so a recycled-or-missed inFlight entry cannot fall through to style.left. + const markedFlipping = element.classList.contains(FLIP_ACTIVE_CLASS); + const styleTransform = element.style.transform || ""; + const hasStyleTranslate = hasNonIdentityTranslate(styleTransform); + let computedTranslate: { x: number; y: number } | null = null; + if ( + !hasStyleTranslate && + (markedFlipping || this.inFlight.has(cellId)) && + typeof getComputedStyle !== "undefined" + ) { + computedTranslate = parseCssTranslate(getComputedStyle(element).transform); + } + if ( + this.inFlight.has(cellId) || + markedFlipping || + hasStyleTranslate || + (computedTranslate && + (Math.abs(computedTranslate.x) > MIN_DELTA || Math.abs(computedTranslate.y) > MIN_DELTA)) + ) { + if (hasStyleTranslate) { + const live = parseCssTranslate(styleTransform); + if (live) { + return { + sourceContainer, + sourceContainerLeft, + sourceContainerTop, + left: styleLeft + live.x, + top: styleTop + live.y, + styleTop, + styleLeft, + fromDom: true, + }; + } + } + if ( + computedTranslate && + (Math.abs(computedTranslate.x) > MIN_DELTA || Math.abs(computedTranslate.y) > MIN_DELTA) + ) { return { sourceContainer, sourceContainerLeft, sourceContainerTop, - left: rect.left - parentRect.left + parent.scrollLeft, - top: rect.top - parentRect.top + parent.scrollTop, + left: styleLeft + computedTranslate.x, + top: styleTop + computedTranslate.y, styleTop, styleLeft, fromDom: true, }; } - return { + return this.readVisualPosition( + element, sourceContainer, sourceContainerLeft, sourceContainerTop, - left: rect.left, - top: rect.top, styleTop, styleLeft, - fromDom: true, - }; + ); } - // Non-in-flight branch: style.top/left is the cell's *logical* - // destination, not a viewport-bounded visual position. For columns far - // off-screen this can be tens of thousands of pixels away from the - // current viewport — same regime as a preLayout entry — so we leave - // fromDom=false and let play() compress the FLIP via scaleFlipDistance. return { sourceContainer, sourceContainerLeft, @@ -1392,52 +1611,286 @@ export class AnimationCoordinator { } private startTransition(cellId: string, element: HTMLElement, isRetained: boolean): void { - // Outgoing (retained) cells use an ease-in curve so the visible portion - // of their slide (cell at its old visible position → viewport edge) is - // back-loaded in time. Incoming + persistent cells stay on the - // configured easing (defaults to a punchy ease-out that decelerates them - // smoothly into their final visible position). - const easing = isRetained ? OUTGOING_EASING : this.easing; - element.style.transition = `transform ${this.duration}ms ${easing}`; - element.style.transform = "translate3d(0, 0, 0)"; - // Suppress hit-testing on cells that are mid-slide. Without this, an - // animating header sliding under a dragging cursor will keep firing - // dragover events on whichever animating cell the cursor is currently - // intersecting, causing rapid back-and-forth swaps (visible flicker - // during drag-and-drop reorder). Restored in finishElement once the - // transition resolves. Retained (outgoing) cells already had pointer - // events suppressed in retainCell. - if (!isRetained) { - element.style.pointerEvents = "none"; + this.startTransitionsBatch([{ cellId, element, isRetained }]); + } + + /** + * Start FLIP transitions for many cells in one turn. Freezes compositor + * matrices first, flushes layout once, then writes identity — avoids the + * per-cell `offsetWidth` thrash that made Chrome log + * `[Violation] requestAnimationFrame handler took Nms` and produced the + * ~1–2px hitch on every column-drag interrupt. + */ + private startTransitionsBatch( + items: Array<{ cellId: string; element: HTMLElement; isRetained: boolean }>, + ): void { + const prepared: Array<{ + cellId: string; + element: HTMLElement; + isRetained: boolean; + duration: number; + easing: string; + }> = []; + + for (const { cellId, element, isRetained } of items) { + if (!element.isConnected) continue; + + // Drop any prior in-flight bookkeeping/listeners first. Coalesce can call + // startTransition on a cell that already has a listener from an earlier + // cycle; leaving that listener attached lets a stale transitionend clear + // the transform mid-slide (continuity teleports). + const prior = this.inFlight.get(cellId); + if (prior) { + window.clearTimeout(prior.cleanupTimeout); + prior.element.removeEventListener("transitionend", prior.transitionEndHandler); + this.inFlight.delete(cellId); + } + + prepared.push({ cellId, element, isRetained, duration: this.duration, easing: this.easing }); } - const transitionEndHandler = (event: TransitionEvent) => { - if (event.propertyName !== "transform") return; - this.finalizeCell(cellId, element); - }; - element.addEventListener("transitionend", transitionEndHandler); + // Batch READ computed transforms when style is identity (one reflow), then + // WRITE freezes — interleaved getComputedStyle was a Forced-reflow storm. + if (typeof getComputedStyle !== "undefined") { + const needsCompute: number[] = []; + for (let i = 0; i < prepared.length; i++) { + const styleTransform = prepared[i].element.style.transform || ""; + if (!hasNonIdentityTranslate(styleTransform)) { + needsCompute.push(i); + } + } + const computed: string[] = needsCompute.map((i) => + getComputedStyle(prepared[i].element).transform, + ); + for (let j = 0; j < needsCompute.length; j++) { + const i = needsCompute[j]; + const value = computed[j]; + if (hasNonIdentityTranslate(value)) { + const el = prepared[i].element; + el.style.transition = "none"; + const parsed = parseCssTranslate(value); + el.style.transform = parsed + ? `translate3d(${parsed.x}px, ${parsed.y}px, 0)` + : value; + } + } + } - const cleanupTimeout = window.setTimeout(() => { - this.finalizeCell(cellId, element); - }, this.duration + SAFETY_TIMEOUT_SLACK); + for (const item of prepared) { + const { isRetained } = item; + item.easing = isRetained ? OUTGOING_EASING : this.easing; + } - this.inFlight.set(cellId, { - element, - cleanupTimeout, - transitionEndHandler, - isRetained, - }); + // Single flush so every freeze is committed before any identity write. + this.flushLayoutOnce(); + + for (const { cellId, element, isRetained, duration, easing } of prepared) { + if (!element.isConnected) continue; + + element.style.transition = `transform ${duration}ms ${easing}`; + element.style.transform = "translate3d(0, 0, 0)"; + // Suppress hit-testing on BODY cells mid-slide so they don't steal + // clicks. Headers keep pointer events (needed for dragover targeting). + // Retained (outgoing) cells already had pointer events suppressed in + // retainCell. + if (!isRetained) { + const isHeaderCell = + cellId.startsWith("header-") || cellId.includes(":header") || cellId.endsWith("-header"); + if (!isHeaderCell) { + element.style.pointerEvents = "none"; + } + } + + const transitionEndHandler = (event: TransitionEvent) => { + // `transitionend` bubbles. Header/body cells contain icons that also + // transition `transform` (collapse chevrons, expand arrows, selects). + // Those bubbled events used to finalize the FLIP early — clearing the + // cell's transform and producing a jump-to-finished when the next + // reorder started. Only the cell's own transform transition counts. + if (event.target !== element) { + return; + } + if (event.propertyName !== "transform") return; + const entry = this.inFlight.get(cellId); + // Stale listener from a superseded startTransition — ignore. + if (!entry || entry.transitionEndHandler !== transitionEndHandler) return; + // Spurious transitionend while still mid-slide. Prefer the animation + // clock / painted offset over getComputedStyle: pausing a CSS transition + // can make getComputedStyle report identity while paint is still mid-way, + // and finalizing then teleports the cell to style.left. + if (this.isFlipStillInProgress(element)) return; + this.finalizeCell(cellId, element, "transitionend"); + }; + element.addEventListener("transitionend", transitionEndHandler); + + const cleanupTimeout = window.setTimeout(() => { + // Same mid-slide guard as transitionend — a wall-clock timeout can fire + // while the transition is paused during a heavy mid-drag render. + const tryFinalize = () => { + if (this.isFlipStillInProgress(element)) { + const entry = this.inFlight.get(cellId); + if (entry && entry.transitionEndHandler === transitionEndHandler) { + entry.cleanupTimeout = window.setTimeout(tryFinalize, SAFETY_TIMEOUT_SLACK); + } + return; + } + this.finalizeCell(cellId, element, "timeout"); + }; + tryFinalize(); + }, duration + SAFETY_TIMEOUT_SLACK); + + this.inFlight.set(cellId, { + element, + cleanupTimeout, + transitionEndHandler, + isRetained, + }); + } + } + + /** + * True when a FLIP cell still has a running/paused transform animation or a + * painted offset from its layout box. Used to ignore spurious transitionend + * / timeout finalization that would clear the transform mid-slide. + */ + private isFlipStillInProgress(element: HTMLElement): boolean { + // Animation clock first — getComputedStyle can report identity for a frame + // while paint/WAAPI still have remain (observed as 4–13px teleports). + if (typeof element.getAnimations === "function") { + for (const anim of element.getAnimations()) { + if (anim.playState === "paused") return true; + if (anim.playState !== "running") continue; + const timing = anim.effect?.getComputedTiming?.(); + const duration = timing?.duration; + const current = anim.currentTime; + if ( + typeof duration === "number" && + Number.isFinite(duration) && + typeof current === "number" && + Number.isFinite(current) && + current < duration - 0.5 + ) { + return true; + } + } + } + + if (typeof getComputedStyle !== "undefined") { + const computed = getComputedStyle(element).transform; + const parsed = parseCssTranslate(computed); + if (parsed && (Math.abs(parsed.x) > 0.5 || Math.abs(parsed.y) > 0.5)) { + return true; + } + } + + if (element.classList.contains(FLIP_ACTIVE_CLASS)) { + const styleTransform = element.style.transform || ""; + if (hasNonIdentityTranslate(styleTransform)) return true; + } + return false; + } + + /** + * Write the painted translate into `style.transform` (transition:none) so + * the visual position survives animation cancel/pause and left/top writes. + * + * Prefer the computed matrix over getBoundingClientRect/offsetParent math: + * the matrix is already in style.left/top space (what FLIP compensation + * expects). Rect−offsetParent often disagrees by ~1–2px (borders, scroll, + * subpixels) and that error shows up as a hitch on every interrupt reorder. + * + * Does NOT force layout (`offsetWidth`). Callers that need a flush after a + * batch of bakes should use {@link flushLayoutOnce} once. + */ + private bakeLiveTransform(element: HTMLElement): void { + if (typeof getComputedStyle !== "undefined") { + const computed = getComputedStyle(element).transform; + const parsed = parseCssTranslate(computed); + if (parsed && (Math.abs(parsed.x) > MIN_DELTA || Math.abs(parsed.y) > MIN_DELTA)) { + element.style.transition = "none"; + // Normalize to translate3d so later compensation/parsers stay consistent + // (getComputedStyle returns matrix(...)). + element.style.transform = `translate3d(${parsed.x}px, ${parsed.y}px, 0)`; + element.style.willChange = "transform"; + element.classList.add(FLIP_ACTIVE_CLASS); + return; + } + } + + const parent = element.offsetParent as HTMLElement | null; + if (!parent || typeof element.getBoundingClientRect !== "function") return; + + const rect = element.getBoundingClientRect(); + const parentRect = parent.getBoundingClientRect(); + const visualLeft = rect.left - parentRect.left + parent.scrollLeft; + const visualTop = rect.top - parentRect.top + parent.scrollTop; + const dx = visualLeft - parsePx(element.style.left); + const dy = visualTop - parsePx(element.style.top); + if (Math.abs(dx) < MIN_DELTA && Math.abs(dy) < MIN_DELTA) return; + element.style.transition = "none"; + element.style.transform = `translate3d(${dx}px, ${dy}px, 0)`; + element.style.willChange = "transform"; + element.classList.add(FLIP_ACTIVE_CLASS); } - private cancelInFlight(cellId: string): void { + /** One forced layout after a batch of transform writes (never per-cell). */ + private flushLayoutOnce(): void { + if (typeof document === "undefined") return; + void document.documentElement.offsetHeight; + } + + + private cancelInFlight(cellId: string, options?: { skipBake?: boolean }): void { const entry = this.inFlight.get(cellId); if (!entry) return; window.clearTimeout(entry.cleanupTimeout); entry.element.removeEventListener("transitionend", entry.transitionEndHandler); + // Skip re-bake when capture/play already froze a non-identity translate + // into style (transition:none). A second bake via rect/offsetParent was + // introducing a ~1–2px hitch on every interrupt reorder. + const styleTransform = entry.element.style.transform || ""; + const alreadyFrozen = + (entry.element.style.transition === "none" || + entry.element.style.transition === "") && + hasNonIdentityTranslate(styleTransform); + if (!options?.skipBake && !alreadyFrozen) { + this.bakeLiveTransform(entry.element); + } + const el = entry.element; + if (typeof el.getAnimations === "function") { + for (const anim of el.getAnimations()) { + try { + anim.cancel(); + } catch { + // ignore + } + } + } this.inFlight.delete(cellId); } - private finalizeCell(cellId: string, element: HTMLElement): void { + private finalizeCell(cellId: string, element: HTMLElement, reason = "unknown"): void { + // Last-chance guard: never clear a mid-slide matrix (continuity teleports). + if (typeof getComputedStyle !== "undefined") { + const parsed = parseCssTranslate(getComputedStyle(element).transform); + const remain = parsed ? Math.hypot(parsed.x, parsed.y) : 0; + if (remain > 0.5) { + const entry = this.inFlight.get(cellId); + const isRetained = entry?.isRetained ?? this.isCellRetained(element); + element.style.transition = "none"; + element.style.transform = `translate3d(${parsed!.x}px, ${parsed!.y}px, 0)`; + element.style.willChange = "transform"; + element.classList.add(FLIP_ACTIVE_CLASS); + if (entry) { + window.clearTimeout(entry.cleanupTimeout); + entry.element.removeEventListener("transitionend", entry.transitionEndHandler); + this.inFlight.delete(cellId); + } + this.startTransition(cellId, element, isRetained); + return; + } + } + const entry = this.inFlight.get(cellId); const isRetained = entry?.isRetained ?? this.isCellRetained(element); if (entry) { @@ -1460,9 +1913,48 @@ export class AnimationCoordinator { element.style.transition = ""; element.style.transform = ""; element.style.willChange = ""; + element.classList.remove(FLIP_ACTIVE_CLASS); // Re-enable hit-testing now that the cell has settled. See // startTransition for the rationale. element.style.pointerEvents = ""; + if ( + element.classList.contains("st-header-cell") || + element.classList.contains("st-header-cell-container") + ) { + // Clear matching body cells even after dragend (residual FLIPs). + this.syncColumnBodyTransform(element, "", ""); + } + } + + /** + * Clear residual transforms on body cells for a finished header column + * (e.g. after a programmatic horizontal FLIP). Column-drag bodies are + * owned by {@link ColumnReorderAnimator} and clear themselves. + */ + private syncColumnBodyTransform( + headerEl: HTMLElement, + transform: string, + transition: string, + ): void { + const accessor = headerEl.getAttribute("data-accessor"); + if (!accessor) return; + const root = headerEl.closest(".simple-table-root") ?? headerEl.ownerDocument; + if (!root) return; + const nodes = root.querySelectorAll(".st-cell[data-accessor]"); + for (let i = 0; i < nodes.length; i++) { + const el = nodes[i]; + if (el.getAttribute("data-accessor") !== accessor) continue; + if (el.classList.contains("st-header-cell")) continue; + el.style.transition = transition; + el.style.transform = transform; + if (transform) { + el.style.willChange = "transform"; + el.classList.add(FLIP_ACTIVE_CLASS); + } else { + el.style.willChange = ""; + el.classList.remove(FLIP_ACTIVE_CLASS); + } + } } private isCellRetained(element: HTMLElement): boolean { @@ -1476,6 +1968,17 @@ const parsePx = (value: string): number => { return Number.isFinite(parsed) ? parsed : 0; }; +/** True when an inline transform is a non-zero translate (active FLIP invert / mid-slide). */ +const hasNonIdentityTranslate = (transform: string): boolean => { + if (!transform || transform === "none") return false; + if (transform.includes("translate3d(0px, 0px, 0px)")) return false; + if (transform.includes("translate3d(0, 0, 0)")) return false; + if (/translate3d?\(/i.test(transform)) return true; + // Freeze path writes getComputedStyle's matrix(...) form. + const parsed = parseCssTranslate(transform); + return Boolean(parsed && (Math.abs(parsed.x) > MIN_DELTA || Math.abs(parsed.y) > MIN_DELTA)); +}; + type FlipAxis = "x" | "y"; /** diff --git a/packages/core/src/managers/ColumnReorderAnimator.ts b/packages/core/src/managers/ColumnReorderAnimator.ts new file mode 100644 index 000000000..fe3a11dd2 --- /dev/null +++ b/packages/core/src/managers/ColumnReorderAnimator.ts @@ -0,0 +1,285 @@ +/** + * Dedicated column-drag reorder animator. + * + * Model (sortable-list retarget): + * 1. beginOrderChange — snapshot style-space visual per accessor + * 2. Render writes plain style.left (no invent / pinSettled) + * 3. commitOrderChange — hold = snapVisual − newLeft, then WAAPI → 0 + * + * Mid-flight retargets cancel and replace from the snap remain. Same-dest + * accessors are left alone. Bodies get the same transform as headers. + */ + +import { parseCssTranslate } from "../utils/setAbsoluteCellPosition"; + +const MIN_DELTA = 0.5; +const FLIP_ACTIVE_CLASS = "st-flip-active"; +/** Marks WAAPI instances owned by this animator so we can cancel selectively. */ +const ANIM_ID = "st-column-reorder"; + +const parsePx = (value: string): number => { + if (!value) return 0; + const parsed = parseFloat(value); + return Number.isFinite(parsed) ? parsed : 0; +}; + +export type ColumnReorderAnimatorOptions = { + duration?: number; +}; + +type VisualSnap = { + visualLeft: number; + styleLeft: number; +}; + +/** + * Style-space visual X: style.left + live translate X. + * Prefer getComputedStyle so mid-flight WAAPI remains are accurate. + */ +const readVisualStyleLeft = (el: HTMLElement): number => { + const styleLeft = parsePx(el.style.left); + let tx = 0; + if (typeof getComputedStyle !== "undefined") { + const parsed = parseCssTranslate(getComputedStyle(el).transform); + if (parsed) tx = parsed.x; + } else { + const parsed = parseCssTranslate(el.style.transform || ""); + if (parsed) tx = parsed.x; + } + return styleLeft + tx; +}; + +const cancelColumnReorderAnims = (el: HTMLElement): void => { + if (typeof el.getAnimations !== "function") return; + for (const anim of el.getAnimations()) { + if ((anim as Animation & { id?: string }).id === ANIM_ID) { + try { + anim.cancel(); + } catch { + // ignore + } + } + } +}; + +const clearTransform = (el: HTMLElement): void => { + el.style.transition = ""; + el.style.transform = ""; + el.style.willChange = ""; + el.classList.remove(FLIP_ACTIVE_CLASS); +}; + +const isNearHorizontalViewport = ( + left: number, + width: number, + scrollLeft: number, + clientWidth: number, +): boolean => { + const buffer = Math.max(120, clientWidth * 0.25); + return left + width >= scrollLeft - buffer && left <= scrollLeft + clientWidth + buffer; +}; + +export class ColumnReorderAnimator { + private active = false; + private duration: number; + /** Snapshot taken at beginOrderChange — visual before style.left rewrites. */ + private pendingSnap: Map | null = null; + private running = new Set(); + + constructor(opts: ColumnReorderAnimatorOptions = {}) { + this.duration = opts.duration ?? 400; + } + + setDuration(duration: number): void { + this.duration = duration; + } + + setActive(active: boolean): void { + this.active = active; + if (!active) { + this.pendingSnap = null; + // Leave in-flight WAAPIs running through dragend / handoff. + } + } + + isActive(): boolean { + return this.active; + } + + hasInFlight(): boolean { + return this.running.size > 0; + } + + /** + * Call before header/body style.left rewrites for a mid-drag reorder. + * Captures style-space visuals for every header leaf currently in the DOM. + */ + beginOrderChange(root: ParentNode): void { + if (!this.active) return; + const snap = new Map(); + const headers = root.querySelectorAll(".st-header-cell[data-accessor]"); + for (let i = 0; i < headers.length; i++) { + const el = headers[i]; + const accessor = el.getAttribute("data-accessor"); + if (!accessor || snap.has(accessor)) continue; + snap.set(accessor, { + visualLeft: readVisualStyleLeft(el), + styleLeft: parsePx(el.style.left), + }); + } + this.pendingSnap = snap; + } + + /** + * Call after style.left rewrites in the same task (before paint). + * Hold = pre-write visual − newLeft (never trust post-write live remain — + * a naked left write has already shifted paint by the slot delta). + */ + commitOrderChange(root: ParentNode): void { + if (!this.active) { + this.pendingSnap = null; + return; + } + const snap = this.pendingSnap; + this.pendingSnap = null; + if (!snap || snap.size === 0) return; + + const scrollHost = + (root as Element).querySelector?.(".st-body-main") ?? + (root as Element).querySelector?.(".st-header-main") ?? + null; + const scrollLeft = scrollHost ? (scrollHost as HTMLElement).scrollLeft : 0; + const clientWidth = scrollHost + ? (scrollHost as HTMLElement).clientWidth + : typeof window !== "undefined" + ? window.innerWidth + : 2000; + + const headers = root.querySelectorAll(".st-header-cell[data-accessor]"); + const remains = new Map(); + const headerByAccessor = new Map(); + + for (let i = 0; i < headers.length; i++) { + const el = headers[i]; + const accessor = el.getAttribute("data-accessor"); + if (!accessor || headerByAccessor.has(accessor)) continue; + headerByAccessor.set(accessor, el); + + const prev = snap.get(accessor); + const newLeft = parsePx(el.style.left); + if (!prev) continue; + + if (Math.abs(newLeft - prev.styleLeft) < MIN_DELTA) { + // Same logical slot — do not restart a running slide. + continue; + } + + // Authoritative hold from pre-write snapshot only. + const remain = prev.visualLeft - newLeft; + const width = parsePx(el.style.width) || 120; + const nearNow = isNearHorizontalViewport(newLeft, width, scrollLeft, clientWidth); + const nearBefore = isNearHorizontalViewport(prev.styleLeft, width, scrollLeft, clientWidth); + if (!nearNow && !nearBefore) { + remains.set(accessor, 0); + continue; + } + if (Math.abs(remain) < MIN_DELTA) { + remains.set(accessor, 0); + continue; + } + remains.set(accessor, remain); + } + + if (remains.size === 0) return; + + for (const [accessor, remain] of remains) { + const header = headerByAccessor.get(accessor); + if (!header) continue; + this.animateElement(header, remain, accessor); + } + + const bodyCells = root.querySelectorAll(".st-cell[data-accessor]"); + for (let i = 0; i < bodyCells.length; i++) { + const el = bodyCells[i]; + if (el.classList.contains("st-header-cell")) continue; + const accessor = el.getAttribute("data-accessor"); + if (!accessor || !remains.has(accessor)) continue; + this.animateElement(el, remains.get(accessor)!, accessor); + } + } + + destroy(): void { + this.active = false; + this.pendingSnap = null; + this.running.clear(); + } + + private animateElement(el: HTMLElement, remainX: number, accessor: string): void { + cancelColumnReorderAnims(el); + el.style.transition = "none"; + + const isHeader = + el.classList.contains("st-header-cell") || el.classList.contains("st-header-cell-container"); + + if (Math.abs(remainX) < MIN_DELTA) { + clearTransform(el); + if (isHeader) this.running.delete(accessor); + return; + } + + if (typeof el.animate !== "function") { + el.style.transform = `translate3d(${remainX}px, 0, 0)`; + el.classList.add(FLIP_ACTIVE_CLASS); + return; + } + + const duration = Math.max( + this.duration, + Math.min(2500, Math.round(Math.abs(remainX) * 3)), + ); + + // Hold paint at the pre-write visual, then tween to identity in-turn. + el.style.transform = `translate3d(${remainX}px, 0, 0)`; + el.style.willChange = "transform"; + el.classList.add(FLIP_ACTIVE_CLASS); + if (isHeader) this.running.add(accessor); + + const anim = el.animate( + [ + { transform: `translate3d(${remainX}px, 0, 0)` }, + { transform: "translate3d(0px, 0px, 0)" }, + ], + { + duration, + easing: "linear", + fill: "forwards", + }, + ); + anim.id = ANIM_ID; + + const finish = () => { + const current = el + .getAnimations?.() + .find((a) => (a as Animation & { id?: string }).id === ANIM_ID); + if (current && current !== anim) return; + try { + // Write the end state into style before dropping the effect. + anim.commitStyles?.(); + } catch { + // ignore + } + clearTransform(el); + try { + anim.cancel(); + } catch { + // ignore + } + if (isHeader) this.running.delete(accessor); + }; + + anim.onfinish = finish; + anim.finished.then(finish).catch(() => { + // Cancelled by a later retarget. + }); + } +} diff --git a/packages/core/src/managers/DragHandlerManager.ts b/packages/core/src/managers/DragHandlerManager.ts index cfadc54e1..19846ec53 100644 --- a/packages/core/src/managers/DragHandlerManager.ts +++ b/packages/core/src/managers/DragHandlerManager.ts @@ -7,6 +7,9 @@ import { findParentHeader } from "../utils/collapseUtils"; const REVERT_TO_PREVIOUS_HEADERS_DELAY = 1500; +/** Cleared on the next dragstart so a rapid A→B handoff isn't interrupted by A's dragend commit. */ +let dragEndCommitTimeoutId: ReturnType | null = null; + export const getHeaderIndexPath = ( headers: ColumnDef[], targetAccessor: Accessor, @@ -84,44 +87,50 @@ export const updateHeaderPinnedProperty = ( return updatedHeader; }; +/** + * Reorder siblings by moving the dragged header to the hovered index + * (remove + insert), shifting everything in between by one slot. + * + * Historically this pairwise-swapped the two headers. That made the hovered + * (non-dragged) column fly to the dragged slot while intermediates stayed put — + * which reads as "weird animations on columns that aren't being dragged" when + * the cursor jumps across several columns. + */ export function swapHeaders( headers: ColumnDef[], draggedPath: number[], hoveredPath: number[], ): { newHeaders: ColumnDef[]; emergencyBreak: boolean } { const newHeaders = deepClone(headers); - let emergencyBreak = false; - function getHeaderAtPath(headers: ColumnDef[], path: number[]): ColumnDef { - let current = headers; - let header: ColumnDef | undefined; - for (let i = 0; i < path.length - 1; i++) { - current = current[path[i]].children!; - } - header = current[path[path.length - 1]]; - return header; + if (draggedPath.length !== hoveredPath.length) { + return { newHeaders, emergencyBreak: true }; } - - function setHeaderAtPath(headers: ColumnDef[], path: number[], value: ColumnDef): void { - let current = headers; - for (let i = 0; i < path.length - 1; i++) { - if (current[path[i]].children) { - current = current[path[i]].children!; - } else { - emergencyBreak = true; - break; - } + for (let i = 0; i < draggedPath.length - 1; i++) { + if (draggedPath[i] !== hoveredPath[i]) { + return { newHeaders, emergencyBreak: true }; } - current[path[path.length - 1]] = value; } - const draggedHeader = getHeaderAtPath(newHeaders, draggedPath); - const hoveredHeader = getHeaderAtPath(newHeaders, hoveredPath); + const fromIndex = draggedPath[draggedPath.length - 1]; + const toIndex = hoveredPath[hoveredPath.length - 1]; + if (fromIndex === toIndex) { + return { newHeaders, emergencyBreak: false }; + } - setHeaderAtPath(newHeaders, draggedPath, hoveredHeader); - setHeaderAtPath(newHeaders, hoveredPath, draggedHeader); + const siblings = getSiblingArray(newHeaders, draggedPath); + if ( + fromIndex < 0 || + toIndex < 0 || + fromIndex >= siblings.length || + toIndex >= siblings.length + ) { + return { newHeaders, emergencyBreak: true }; + } - return { newHeaders, emergencyBreak }; + const [removed] = siblings.splice(fromIndex, 1); + siblings.splice(toIndex, 0, removed); + return { newHeaders: setSiblingArray(newHeaders, draggedPath, siblings), emergencyBreak: false }; } export function insertHeaderAcrossSections({ @@ -204,6 +213,10 @@ export class DragHandlerManager { } handleDragStart(header: ColumnDef): void { + if (dragEndCommitTimeoutId !== null) { + clearTimeout(dragEndCommitTimeoutId); + dragEndCommitTimeoutId = null; + } this.draggedHeader = header; this.prevUpdateTime = Date.now(); } @@ -319,7 +332,13 @@ export class DragHandlerManager { this.draggedHeader = null; this.hoveredHeader = null; - setTimeout(() => { + if (dragEndCommitTimeoutId !== null) { + clearTimeout(dragEndCommitTimeoutId); + } + dragEndCommitTimeoutId = setTimeout(() => { + dragEndCommitTimeoutId = null; + // Skip if a new drag already started (rapid column handoff mid-FLIP). + if (this.draggedHeader) return; if (this.config.onHeadersChange) { this.config.onHeadersChange([...this.config.headers]); } diff --git a/packages/core/src/styles/base.css b/packages/core/src/styles/base.css index a53b3230f..64181a3cf 100644 --- a/packages/core/src/styles/base.css +++ b/packages/core/src/styles/base.css @@ -774,6 +774,40 @@ input { .st-dragging.st-sub-header { background-color: var(--st-dragging-sub-header-background-color); } +/* Keep the dragged header above neighbors while they slide past (DOM order + would otherwise flip who paints on top mid-animation). */ +.st-header-cell.st-dragging { + z-index: 2; +} + +/* + * Column-drag / FLIP pass-through paint. + * + * Body cells use `background-color: transparent` so a shared row fill shows + * through — when two cells slide past each other you see labels overlap, not + * opaque rectangles stacking. Neighboring headers do the same during reorder + * (the header strip already paints `--st-header-background-color`). + * `.st-flip-active` covers slides that continue after dragend. + * + * The dragged header keeps `--st-dragging-background-color` (see below) so the + * active column stays visually marked. + */ +.simple-table-root.st-column-reordering .st-header-cell, +.simple-table-root.st-column-reordering .st-header-cell.st-sub-header, +.st-header-cell.st-flip-active, +.st-header-cell.st-flip-active.st-sub-header { + background-color: transparent; +} + +/* Dragged header fill wins over the pass-through rule above. */ +.simple-table-root.st-column-reordering .st-header-cell.st-dragging:not(.st-sub-header), +.st-header-cell.st-flip-active.st-dragging:not(.st-sub-header) { + background-color: var(--st-dragging-background-color); +} +.simple-table-root.st-column-reordering .st-header-cell.st-dragging.st-sub-header, +.st-header-cell.st-flip-active.st-dragging.st-sub-header { + background-color: var(--st-dragging-sub-header-background-color); +} /* Loading skeleton styles */ .st-loading-skeleton { diff --git a/packages/core/src/utils/bodyCell/styling.ts b/packages/core/src/utils/bodyCell/styling.ts index fe61a05b0..bf168b675 100644 --- a/packages/core/src/utils/bodyCell/styling.ts +++ b/packages/core/src/utils/bodyCell/styling.ts @@ -7,6 +7,7 @@ import { addTrackedEventListener } from "./eventTracking"; import { createEditor } from "./editing"; import { createCellContent } from "./content"; import { CellLiveRef, cellLiveRefMap } from "./cellLiveRef"; +import { setAbsoluteCellPosition } from "../setAbsoluteCellPosition"; // Re-exported for backwards compatibility with existing import sites. export { cellLiveRefMap }; @@ -272,8 +273,7 @@ export const createBodyCellElement = ( // Apply absolute positioning like headers cellElement.style.position = "absolute"; - cellElement.style.left = `${cell.left}px`; - cellElement.style.top = `${cell.top}px`; + setAbsoluteCellPosition(cellElement, cell.left, cell.top); cellElement.style.width = `${cell.width}px`; cellElement.style.height = `${cell.height}px`; @@ -550,8 +550,7 @@ export const createBodyCellElement = ( // snap back to the final value during scroll-RAF position updates that // happen to fire mid-animation. export const updateBodyCellPosition = (cellElement: HTMLElement, cell: AbsoluteBodyCell): void => { - cellElement.style.left = `${cell.left}px`; - cellElement.style.top = `${cell.top}px`; + setAbsoluteCellPosition(cellElement, cell.left, cell.top); const accordionGrowAxis = cellElement.dataset.stAccordionGrow; if (accordionGrowAxis !== "horizontal") { cellElement.style.width = `${cell.width}px`; @@ -583,8 +582,7 @@ export const updateBodyCellElement = ( // for the active axis so subsequent same-tick renders (e.g. the // microtask-batched onRender after a chevron toggle) don't trample the // inline 0 before the CSS transition can pick it up. - cellElement.style.left = `${cell.left}px`; - cellElement.style.top = `${cell.top}px`; + setAbsoluteCellPosition(cellElement, cell.left, cell.top); const accordionGrowAxis = cellElement.dataset.stAccordionGrow; if (accordionGrowAxis !== "horizontal") { cellElement.style.width = `${cell.width}px`; diff --git a/packages/core/src/utils/headerCell/dragging.ts b/packages/core/src/utils/headerCell/dragging.ts index 7a5ba4a75..268c4ecc3 100644 --- a/packages/core/src/utils/headerCell/dragging.ts +++ b/packages/core/src/utils/headerCell/dragging.ts @@ -24,6 +24,25 @@ import { setPrevHeaders, } from "./eventTracking"; +/** Cleared on the next dragstart so a rapid A→B handoff isn't interrupted by A's dragend commit. */ +let dragEndCommitTimeoutId: ReturnType | null = null; + +/** Cheap order fingerprint — avoids JSON.stringify of the full header tree on every dragover. */ +const headerOrderKey = (headers: ColumnDef[]): string => { + const parts: string[] = []; + const walk = (list: ColumnDef[]) => { + for (const h of list) { + if (h.children && h.children.length > 0) { + walk(h.children); + } else { + parts.push(String(h.accessor)); + } + } + }; + walk(headers); + return parts.join(">"); +}; + export const handleColumnHeaderClick = ( event: MouseEvent, header: ColumnDef, @@ -134,9 +153,22 @@ export const attachDragHandlers = ( labelElement.setAttribute("draggable", "true"); const handleDragStart = (event: Event) => { + if (dragEndCommitTimeoutId !== null) { + clearTimeout(dragEndCommitTimeoutId); + dragEndCommitTimeoutId = null; + } draggedHeaderRef.current = header; setPrevUpdateTime(Date.now()); cellElement.classList.add("st-dragging"); + // Resolve root at event time — handlers attach before the cell is in the DOM, + // so a create-time closest() would be null and never add the reorder class. + const root = cellElement.closest(".simple-table-root"); + // Pass-through fills on neighboring headers while columns slide (see + // `.st-column-reordering` in base.css). Dragged header keeps its fill. + root?.classList.add("st-column-reordering"); + // Column-drag FLIP mode (no settle — mid-flight slides keep going if the + // user grabs a different column before prior swaps finish). + context.animationCoordinator?.setColumnReordering(true); }; addTrackedEventListener(labelElement, "dragstart", handleDragStart); @@ -146,8 +178,31 @@ export const attachDragHandlers = ( draggedHeaderRef.current = null; hoveredHeaderRef.current = null; cellElement.classList.remove("st-dragging"); - - setTimeout(() => { + context.animationCoordinator?.setColumnReordering(false); + + // Keep pass-through header paint until in-flight FLIPs finish; individual + // cells also carry `.st-flip-active` as a belt-and-suspenders. If the user + // grab-starts another column before settle, leave the class alone. + const root = cellElement.closest(".simple-table-root"); + const clearReorderClass = () => { + if (context.animationCoordinator?.isColumnReordering()) return; + if (context.animationCoordinator?.hasInFlight()) { + requestAnimationFrame(clearReorderClass); + return; + } + root?.classList.remove("st-column-reordering"); + }; + requestAnimationFrame(clearReorderClass); + + // Notify order change after the browser finishes drag teardown. Skip if the + // user already grab-started another column — that re-render would interrupt + // leftover FLIPs from this drag that the new session is allowed to keep. + if (dragEndCommitTimeoutId !== null) { + clearTimeout(dragEndCommitTimeoutId); + } + dragEndCommitTimeoutId = setTimeout(() => { + dragEndCommitTimeoutId = null; + if (draggedHeaderRef.current) return; context.setHeaders((prev) => [...prev]); if (context.onColumnOrderChange) { context.onColumnOrderChange(deepClone(context.getHeaders())); @@ -180,6 +235,20 @@ export const attachDragHandlers = ( const draggedHeader = draggedHeaderRef.current; if (!draggedHeader) return; + if (header.accessor === draggedHeader.accessor) return; + + // Hit-testing follows the transformed (visual) box. Mid-slide neighbors can + // sit under the pointer and look like a new drop target — swapping with them + // often reverts the previous order once the short revert guard expires. + const hoverFlipActive = cellElement.classList.contains("st-flip-active"); + const hoverHasReorderAnim = + typeof cellElement.getAnimations === "function" && + cellElement + .getAnimations() + .some((a) => (a as Animation & { id?: string }).id === "st-column-reorder"); + if (hoverFlipActive || hoverHasReorderAnim) { + return; + } const draggedSection = getHeaderSection(draggedHeader, liveHeaders); const hoveredSection = getHeaderSection(header, liveHeaders); @@ -200,7 +269,9 @@ export const attachDragHandlers = ( const draggedHeaderIndexPath = getHeaderIndexPath(liveHeaders, draggedHeader.accessor); const hoveredHeaderIndexPath = getHeaderIndexPath(liveHeaders, header.accessor); - if (!draggedHeaderIndexPath || !hoveredHeaderIndexPath) return; + if (!draggedHeaderIndexPath || !hoveredHeaderIndexPath) { + return; + } const draggedHeaderDepth = draggedHeaderIndexPath.length; const hoveredHeaderDepth = hoveredHeaderIndexPath.length; @@ -229,12 +300,13 @@ export const attachDragHandlers = ( emergencyBreak = result.emergencyBreak; } - if ( - header.accessor === draggedHeader.accessor || - distance < 10 || - JSON.stringify(newHeaders) === JSON.stringify(liveHeaders) || - emergencyBreak - ) { + if (distance < 10) { + return; + } + if (headerOrderKey(newHeaders) === headerOrderKey(liveHeaders)) { + return; + } + if (emergencyBreak) { return; } @@ -249,7 +321,7 @@ export const attachDragHandlers = ( const now = Date.now(); const arePreviousHeadersAndNewHeadersTheSame = - JSON.stringify(newHeaders) === JSON.stringify(prevHeaders); + prevHeaders != null && headerOrderKey(newHeaders) === headerOrderKey(prevHeaders); const shouldRevertToPreviousHeaders = now - prevUpdateTime < REVERT_TO_PREVIOUS_HEADERS_DELAY; if ( @@ -263,6 +335,7 @@ export const attachDragHandlers = ( setPrevDraggingPosition({ screenX, screenY }); setPrevHeaders(liveHeaders); + context.onTableHeaderDragEnd(newHeaders); }, DRAG_THROTTLE_LIMIT); }; diff --git a/packages/core/src/utils/headerCell/styling.ts b/packages/core/src/utils/headerCell/styling.ts index 7ee71e757..38473a781 100644 --- a/packages/core/src/utils/headerCell/styling.ts +++ b/packages/core/src/utils/headerCell/styling.ts @@ -14,6 +14,7 @@ import { attachDragHandlers, } from "./dragging"; import { addTrackedEventListener, removeFloatingHeaderTooltips } from "./eventTracking"; +import { setAbsoluteCellPosition } from "../setAbsoluteCellPosition"; // Calculate header cell class names based on current state export const calculateHeaderCellClasses = ( @@ -212,8 +213,7 @@ export const createHeaderCellElement = ( } cellElement.style.position = "absolute"; - cellElement.style.left = `${cell.left}px`; - cellElement.style.top = `${cell.top}px`; + setAbsoluteCellPosition(cellElement, cell.left, cell.top); cellElement.style.width = `${cell.width}px`; cellElement.style.height = `${cell.height}px`; @@ -392,8 +392,7 @@ export const updateHeaderCellElement = ( cellElement.className = calculateHeaderCellClasses(cell, context); - cellElement.style.left = `${cell.left}px`; - cellElement.style.top = `${cell.top}px`; + setAbsoluteCellPosition(cellElement, cell.left, cell.top); cellElement.setAttribute("aria-colindex", String(colIndex + 1)); // Honor the in-flight accordion grow marker (see body-cell counterpart in // ./styling/updateBodyCellElement). Without this, a same-tick re-render diff --git a/packages/core/src/utils/headerCellRenderer.ts b/packages/core/src/utils/headerCellRenderer.ts index 15933174f..5f0c21947 100644 --- a/packages/core/src/utils/headerCellRenderer.ts +++ b/packages/core/src/utils/headerCellRenderer.ts @@ -18,6 +18,7 @@ import { updateHeaderSelectionCheckbox } from "./headerCell/selection"; import { updateHeaderCollapseIconState } from "./headerCell/collapsing"; import { hasCollapsibleChildren, getHeaderColspan } from "./collapseUtils"; import { getOrCreateRowElement, reconcileRowElements } from "./ariaRowOwnership"; +import { setAbsoluteCellPosition } from "./setAbsoluteCellPosition"; import type ColumnDef from "../types/ColumnDef"; // Re-export types for backward compatibility @@ -214,8 +215,7 @@ export const renderHeaderCells = ( cached.height !== cell.height; if (positionChanged) { - cellElement.style.left = `${cell.left}px`; - cellElement.style.top = `${cell.top}px`; + setAbsoluteCellPosition(cellElement, cell.left, cell.top); // Honor the accordion grow marker so a same-tick re-render after a // column collapse/expand toggle doesn't snap the cell to its final // size before the CSS transition picks up the 0 → final tween. diff --git a/packages/core/src/utils/setAbsoluteCellPosition.ts b/packages/core/src/utils/setAbsoluteCellPosition.ts new file mode 100644 index 000000000..5c41525f4 --- /dev/null +++ b/packages/core/src/utils/setAbsoluteCellPosition.ts @@ -0,0 +1,128 @@ +/** + * Write absolute `left`/`top` while preserving an in-flight FLIP visual position. + * + * FLIP inverts use `transform: translate3d(...)` relative to `style.left/top`. + * Updating left/top without adjusting that translate moves the painted cell by + * the same delta — then `play()` "corrects" it with a new invert, which reads + * as a jump during rapid reorders. + * + * Column-drag does NOT compensate here: {@link ColumnReorderAnimator} snapshots + * visuals before left writes and applies the hold+tween after. + */ + +/** When false, left/top writes do not counter-shift FLIP translates. */ +let flipCompensationEnabled = true; + +export const setFlipCompensationEnabled = (enabled: boolean): void => { + flipCompensationEnabled = enabled; +}; + +const parsePx = (value: string): number => { + if (!value) return 0; + const parsed = parseFloat(value); + return Number.isFinite(parsed) ? parsed : 0; +}; + +/** Parse translate/matrix CSS into tx/ty. */ +export const parseCssTranslate = (transform: string): { x: number; y: number } | null => { + if (!transform || transform === "none") return null; + const t3 = transform.match(/translate3d\(\s*([^,]+),\s*([^,]+)/i); + if (t3) { + const x = parseFloat(t3[1]); + const y = parseFloat(t3[2]); + if (Number.isFinite(x) && Number.isFinite(y)) return { x, y }; + } + const t2 = transform.match(/translate\(\s*([^,\s]+)(?:\s*,\s*([^)]+))?/i); + if (t2) { + const x = parseFloat(t2[1]); + const y = parseFloat(t2[2] || "0"); + if (Number.isFinite(x) && Number.isFinite(y)) return { x, y }; + } + const m = transform.match(/^matrix\(\s*([^)]+)\)/i); + if (m) { + const parts = m[1].split(",").map((s) => parseFloat(s.trim())); + if (parts.length >= 6 && parts.every(Number.isFinite)) { + return { x: parts[4], y: parts[5] }; + } + } + const m3 = transform.match(/^matrix3d\(\s*([^)]+)\)/i); + if (m3) { + const parts = m3[1].split(",").map((s) => parseFloat(s.trim())); + if (parts.length >= 16 && Number.isFinite(parts[12]) && Number.isFinite(parts[13])) { + return { x: parts[12], y: parts[13] }; + } + } + return null; +}; + +const looksLikeActiveFlip = (element: HTMLElement, styleTransform: string): boolean => { + if (styleTransform && styleTransform !== "none") return true; + return element.style.willChange === "transform"; +}; + +/** + * When `left`/`top` change under an active FLIP, counter-shift the translate so + * the painted position stays put until the next `play()` invert/transition. + */ +const compensateFlipTransform = ( + element: HTMLElement, + dLeft: number, + dTop: number, +): boolean => { + if (dLeft === 0 && dTop === 0) return false; + + const styleTransform = element.style.transform || ""; + if (!looksLikeActiveFlip(element, styleTransform)) { + return false; + } + + let tx = 0; + let ty = 0; + let found = false; + + const styleParsed = parseCssTranslate(styleTransform); + if (styleParsed && (Math.abs(styleParsed.x) > 0.5 || Math.abs(styleParsed.y) > 0.5)) { + tx = styleParsed.x; + ty = styleParsed.y; + found = true; + } + + if (!found) { + const computed = + typeof getComputedStyle !== "undefined" ? getComputedStyle(element).transform : ""; + const computedParsed = parseCssTranslate(computed); + if (computedParsed) { + tx = computedParsed.x; + ty = computedParsed.y; + found = true; + element.style.transition = "none"; + } + } + + if (!found) return false; + + element.style.transform = `translate3d(${tx - dLeft}px, ${ty - dTop}px, 0)`; + return true; +}; + +/** + * Set absolute cell coordinates, compensating any active FLIP translate so the + * visual position does not drift when the logical slot moves. + */ +export const setAbsoluteCellPosition = ( + element: HTMLElement, + nextLeft: number, + nextTop: number, +): void => { + const prevLeft = parsePx(element.style.left); + const prevTop = parsePx(element.style.top); + const dLeft = nextLeft - prevLeft; + const dTop = nextTop - prevTop; + + if (flipCompensationEnabled) { + compensateFlipTransform(element, dLeft, dTop); + } + + element.style.left = `${nextLeft}px`; + element.style.top = `${nextTop}px`; +}; diff --git a/packages/core/stories/tests/41-CellAnimationsTests.stories.ts b/packages/core/stories/tests/41-CellAnimationsTests.stories.ts index d69a366f5..e9cd15fa2 100644 --- a/packages/core/stories/tests/41-CellAnimationsTests.stories.ts +++ b/packages/core/stories/tests/41-CellAnimationsTests.stories.ts @@ -12,8 +12,10 @@ * overflow clip turns those long off-screen translates into "appears to * slide in from the viewport edge" visually. * - * Animations default to `true`. Live drag reorder is intentionally not - * animated (we don't want to fight the user's pointer mid-drag). + * Animations default to `true`. Live drag-and-drop column reorder also FLIPs + * on each dragover swap (see HeaderCellsAnimateDuringDragReorder / + * DragAndDropColumnReorderShouldAnimate). Use a long `animations.duration` + * (SLOW_DURATION) so the motion is easy to follow in Storybook. */ import { ColumnDef, Row, SimpleTableVanilla } from "../../src/index"; diff --git a/packages/core/stories/tests/52-ColumnEditorHeavyClickReproTests.stories.ts b/packages/core/stories/tests/52-ColumnEditorHeavyClickReproTests.stories.ts index 06fd14233..f5917dae9 100644 --- a/packages/core/stories/tests/52-ColumnEditorHeavyClickReproTests.stories.ts +++ b/packages/core/stories/tests/52-ColumnEditorHeavyClickReproTests.stories.ts @@ -1,17 +1,17 @@ /** * COLUMN EDITOR HEAVY-CLICK / HEADER-REORDER REPRO * - * Chartmetric-style Track List stress case for two client-reported issues: + * Chartmetric-style Track List stress case for: * 1. Column editor checkboxes sometimes need multiple clicks (esp. nested columns * on a heavy table) — suspected cause: setHeaders → full header re-render + * column-editor popout rebuild (twice) destroying the checkbox mid-interaction. - * 2. Header drag reorder can feel sticky; final animation sometimes settles at the - * previous position rather than the new one. + * 2. Header drag reorder animation quality under deep nested groups + * (spotify_7d_* leaves under Spotify → 7d, etc.). * * Manual: * - Open Storybook → Tests/52 - Column Editor Heavy Click Repro * - Rapidly toggle nested checkboxes in the column editor (groups + leafs) - * - Drag column headers left/right and watch settle animation + * - Open "Track List drag playground (slow)", set Duration, drag Spotify 7d leaves * * Light vs Heavy stories isolate whether render cost correlates with missed clicks * (customer could repro on Track List but not lighter Influencer List). @@ -27,6 +27,22 @@ import { } from "../../src/index"; import { waitForTable, waitUntil } from "./testUtils"; +/** Slow default so mid-drag FLIP is easy to follow in the playground / continuity play. */ +const SLOW_DURATION = 1500; +/** + * TEMP fast-feedback knobs for TrackListTenInterruptContinuity. + * Flip back to the slow values when validating the full play. + */ +const CONTINUITY_FAST_FEEDBACK = true; +const CONTINUITY_DURATION = CONTINUITY_FAST_FEEDBACK ? 450 : SLOW_DURATION; +/** Streams handoff phase — walk the sibling band many times under dense sampling. */ +const HANDOFF_SWAPS = 120; +/** + * Storybook Interactions / test-runner budget for the long continuity play. + * Dense per-frame sampling + many interrupt swaps can run ~10–20 minutes. + */ +const CONTINUITY_PLAY_TIMEOUT_MS = 20 * 60 * 1000; + const meta: Meta = { title: "Tests/52 - Column Editor Heavy Click Repro", // Helpers like resetClickRepro must not become blank CSF stories. @@ -37,7 +53,7 @@ const meta: Meta = { docs: { description: { component: - "Track-List-style nested columns + expensive cells to reproduce column-editor multi-click and header-reorder settle glitches.", + "Track-List-style nested columns + expensive cells for column-editor multi-click and header-drag animation QA (slow duration control on the playground story).", }, }, }, @@ -145,9 +161,7 @@ const expensiveCell = ({ row, accessor }: CellRendererProps): HTMLElement => { const text = document.createElement("span"); text.style.fontVariantNumeric = "tabular-nums"; text.style.fontSize = "12px"; - text.textContent = Number.isFinite(Number(value)) - ? Number(value).toLocaleString() - : value; + text.textContent = Number.isFinite(Number(value)) ? Number(value).toLocaleString() : value; top.appendChild(spark); top.appendChild(text); @@ -175,7 +189,7 @@ const createTrackHeaders = (): ColumnDef[] => { { accessor: "track", label: "Track", - width: 220, + width: "auto", type: "string", pinned: "left", sortable: true, @@ -186,6 +200,7 @@ const createTrackHeaders = (): ColumnDef[] => { width: 160, type: "string", pinned: "left", + hide: true, }, { accessor: "meta", @@ -194,7 +209,7 @@ const createTrackHeaders = (): ColumnDef[] => { type: "string", children: [ { accessor: "album", label: "Album", width: 160, type: "string" }, - { accessor: "genre", label: "Genre", width: 120, type: "string" }, + { accessor: "genre", label: "Genre", width: 120, type: "string", hide: true }, ], }, ]; @@ -273,11 +288,62 @@ const createLightRows = (count: number): Row[] => // --------------------------------------------------------------------------- interface LayoutOptions { - mode: "heavy" | "light"; + mode: "heavy" | "light" | "spotify7d"; rowCount: number; enableReorder: boolean; + /** When false, hide the column editor so drag QA is unobstructed. Default true. */ + enableColumnEditor?: boolean; + /** Open the editor on mount. Default true when editor is enabled. */ + enableColumnEditorInitOpen?: boolean; + /** Default true. Continuity tests turn this off so all leaves stay mounted at scroll 0. */ + enableVirtualization?: boolean; + animations?: { enabled: boolean; duration: number }; + /** Optional banner above the table (playground instructions). */ + banner?: string; } +/** Lean Track List: identity + Spotify → 7d leaves only (fast continuity fixture). */ +const createSpotify7dHeaders = (): ColumnDef[] => [ + { + accessor: "id", + label: "#", + width: 64, + type: "number", + pinned: "left", + sortable: true, + }, + { + accessor: "track", + label: "Track", + width: 180, + type: "string", + pinned: "left", + sortable: true, + }, + { + accessor: "spotify_group", + label: "Spotify", + width: 960, + type: "string", + children: [ + { + accessor: "spotify_7d_group", + label: "7D", + width: 960, + type: "string", + children: METRIC_LEAVES.map((metric) => ({ + accessor: `spotify_7d_${metric}`, + label: metric.charAt(0).toUpperCase() + metric.slice(1), + width: 120, + type: "number" as const, + align: "right" as const, + sortable: true, + })), + }, + ], + }, +]; + function buildReproLayout(options: LayoutOptions): HTMLDivElement { resetClickRepro(); @@ -290,6 +356,16 @@ function buildReproLayout(options: LayoutOptions): HTMLDivElement { root.style.background = "#f8fafc"; root.style.fontFamily = "system-ui, sans-serif"; + if (options.banner) { + const banner = document.createElement("p"); + banner.style.margin = "0 0 10px"; + banner.style.fontSize = "13px"; + banner.style.lineHeight = "1.45"; + banner.style.color = "#334155"; + banner.textContent = options.banner; + root.appendChild(banner); + } + const tableHost = document.createElement("div"); tableHost.dataset.testid = "table-host"; tableHost.style.flex = "1"; @@ -297,9 +373,13 @@ function buildReproLayout(options: LayoutOptions): HTMLDivElement { root.appendChild(tableHost); const headers = - options.mode === "heavy" ? createTrackHeaders() : createLightHeaders(); - const rows = options.mode === "heavy" + ? createTrackHeaders() + : options.mode === "spotify7d" + ? createSpotify7dHeaders() + : createLightHeaders(); + const rows = + options.mode === "heavy" || options.mode === "spotify7d" ? createTrackRows(options.rowCount) : createLightRows(options.rowCount); @@ -320,6 +400,8 @@ function buildReproLayout(options: LayoutOptions): HTMLDivElement { true, ); + const enableColumnEditor = options.enableColumnEditor !== false; + const table = new SimpleTableVanilla(tableHost, { columns: headers, rows, @@ -328,11 +410,15 @@ function buildReproLayout(options: LayoutOptions): HTMLDivElement { theme: "modern-light", columnResizing: true, columnReordering: options.enableReorder, - enableColumnEditor: true, - enableColumnEditorInitOpen: true, - columnEditorConfig: { - searchEnabled: true, - }, + enableVirtualization: options.enableVirtualization, + enableColumnEditor, + enableColumnEditorInitOpen: enableColumnEditor && options.enableColumnEditorInitOpen !== false, + columnEditorConfig: enableColumnEditor + ? { + searchEnabled: true, + } + : undefined, + animations: options.animations, onColumnVisibilityChange: () => { getSnapshot().visibilityChangeCount += 1; }, @@ -344,6 +430,1014 @@ function buildReproLayout(options: LayoutOptions): HTMLDivElement { return root; } +// --------------------------------------------------------------------------- +// Drag helpers (Track List leaf reorder) +// --------------------------------------------------------------------------- + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +/** + * Column virtualization culls off-screen leaves. Scroll the main pane until + * every accessor has a header cell in the DOM (or attempts are exhausted). + */ +const ensureLeavesInView = async ( + canvasElement: HTMLElement, + accessors: readonly string[], +): Promise => { + await waitUntil(() => !!canvasElement.querySelector(".st-body-main"), { + timeoutMs: 10_000, + }); + const bodyMain = canvasElement.querySelector(".st-body-main"); + if (!bodyMain) throw new Error(".st-body-main not found"); + + const allPresent = () => + accessors.every((a) => !!canvasElement.querySelector(`.st-header-cell[data-accessor="${a}"]`)); + + if (allPresent()) return; + + // Spotify 7d band sits just after the Metadata group — a modest scroll + // usually brings the full 8-leaf set into the virtualized window. + const candidates = [0, 120, 200, 280, 360, 480, 600, 800]; + for (const scrollLeft of candidates) { + bodyMain.scrollLeft = scrollLeft; + bodyMain.dispatchEvent(new Event("scroll", { bubbles: true })); + await sleep(80); + await new Promise((r) => requestAnimationFrame(() => r(undefined))); + if (allPresent()) return; + } + + throw new Error( + `Could not bring leaves into view: missing ${accessors + .filter((a) => !canvasElement.querySelector(`.st-header-cell[data-accessor="${a}"]`)) + .join(", ")}`, + ); +}; + +const findHeaderCell = (canvasElement: HTMLElement, accessor: string): HTMLElement | null => + canvasElement.querySelector(`.st-header-cell[data-accessor="${accessor}"]`); + +const findHeaderLabel = (canvasElement: HTMLElement, accessor: string): HTMLElement => { + const cell = findHeaderCell(canvasElement, accessor); + const label = cell?.querySelector(".st-header-label"); + if (!label) throw new Error(`Header label for "${accessor}" not found`); + return label; +}; + +const parseTranslateX = (transform: string): number => { + if (!transform || transform === "none") return 0; + const t3 = transform.match(/translate3d\(\s*(-?[\d.]+)px/); + if (t3) return parseFloat(t3[1]); + const m = transform.match(/matrix\(\s*([^)]+)\)/); + if (m) { + const parts = m[1].split(",").map((p) => parseFloat(p.trim())); + if (parts.length >= 6) return parts[4]; + } + return 0; +}; + +const leafLeftOrder = (canvasElement: HTMLElement, accessors: readonly string[]): string => + accessors + .slice() + .sort((a, b) => { + const aL = parseFloat(findHeaderCell(canvasElement, a)?.style.left || "0"); + const bL = parseFloat(findHeaderCell(canvasElement, b)?.style.left || "0"); + return aL - bL; + }) + .join(","); + +const SPOTIFY_7D_LEAVES = [ + "spotify_7d_streams", + "spotify_7d_listeners", + "spotify_7d_followers", + "spotify_7d_saves", + "spotify_7d_shares", + "spotify_7d_playlists", + "spotify_7d_skipRate", + "spotify_7d_completion", +] as const; + +const styleLeftOf = (canvasElement: HTMLElement, accessor: string): number => + parseFloat(findHeaderCell(canvasElement, accessor)?.style.left || "0"); + +/** Painted X (page coords) — includes FLIP translate. */ +const visualLeftOf = (canvasElement: HTMLElement, accessor: string): number => { + const cell = findHeaderCell(canvasElement, accessor); + if (!cell) return NaN; + return cell.getBoundingClientRect().left; +}; + +/** + * Page-space X of the element's layout box (style.left), stripping FLIP translate. + */ +const styleBoxLeftOf = (canvasElement: HTMLElement, accessor: string): number => { + const cell = findHeaderCell(canvasElement, accessor); + if (!cell) return NaN; + return ( + cell.getBoundingClientRect().left - parseTranslateX(window.getComputedStyle(cell).transform) + ); +}; + +const orderedLeaves = (canvasElement: HTMLElement, accessors: readonly string[]): string[] => + accessors.slice().sort((a, b) => styleLeftOf(canvasElement, a) - styleLeftOf(canvasElement, b)); + +/** Slot X positions currently occupied by the leaf set (sorted ascending). */ +const slotLefts = (canvasElement: HTMLElement, accessors: readonly string[]): number[] => + orderedLeaves(canvasElement, accessors).map((a) => styleLeftOf(canvasElement, a)); + +/** Insert-style sibling reorder (matches DragHandlerManager.swapHeaders). */ +const applyInsertReorder = (order: string[], fromAcc: string, toAcc: string): string[] => { + const next = order.slice(); + const from = next.indexOf(fromAcc); + const to = next.indexOf(toAcc); + if (from < 0 || to < 0 || from === to) return next; + const [removed] = next.splice(from, 1); + next.splice(to, 0, removed); + return next; +}; + +const expectedLeftMap = (order: string[], slots: number[]): Map => { + const map = new Map(); + order.forEach((accessor, index) => { + map.set(accessor, slots[index] ?? NaN); + }); + return map; +}; + +const hasActiveFlip = (canvasElement: HTMLElement, accessor: string): boolean => { + const cell = findHeaderCell(canvasElement, accessor); + if (!cell) return false; + // Prefer computed matrix — WAAPI fill:forwards can leave a stale start + // translate on style.transform while paint is already at identity. + const computed = window.getComputedStyle(cell).transform; + if (computed && computed !== "none" && Math.abs(parseTranslateX(computed)) > 0.5) { + return true; + } + // Running/paused WAAPI still counts even near identity for one frame. + if (typeof cell.getAnimations === "function") { + for (const anim of cell.getAnimations()) { + if (anim.playState !== "running" && anim.playState !== "paused") continue; + const timing = anim.effect?.getComputedTiming?.(); + const duration = timing?.duration; + const current = anim.currentTime; + if ( + typeof duration === "number" && + Number.isFinite(duration) && + duration > 0 && + typeof current === "number" && + Number.isFinite(current) && + current < duration - 0.5 + ) { + return true; + } + } + } + return false; +}; + +type LeafMotion = { + accessor: string; + /** Expected style.left destination after the swap that created/updated this motion */ + destLeft: number; + /** Painted X when we last sampled */ + visualAtSample: number; + /** style.left before the swap that last retargeted this motion */ + originLeft: number; + updatedAtStep: number; +}; + +/** Discrete event slack (release / dragstart) — one leaf is 120px. */ +const VISUAL_JUMP_PX = 90; +/** + * Max paint discontinuity (px). Any |Δvisual| ≥ 1 on retarget / hold / clock + * drift must fail — the visible per-hover hitch is ~1–2px. + */ +const MAX_DISCONTINUITY_PX = 0.99; +/** + * Fallback per-frame ceiling when no CSS animation clock is available + * (holding invert before transition start, or settled). Real mid-FLIP + * samples use {@link MAX_DISCONTINUITY_PX} against the predicted visual instead. + * + * Note: Chrome `[Violation] requestAnimationFrame handler took Nms` during + * column-drag usually means main-thread FLIP bake/start thrash — compositor + * peers advance while JS is busy, which shows up as the ~1–2px hover hitch + * these budgets are meant to catch. + */ +const FRAME_JUMP_PX = 12; +/** How far a sample may stray from the FLIP corridor (visual ↔ dest). */ +const PATH_SLACK_PX = 8; +/** + * Max paint drift when style.left retargets (FLIP invert must hold the pixel). + */ +const RETARGET_JUMP_PX = MAX_DISCONTINUITY_PX; +/** + * Max |painted − clock-predicted| while a linear transform transition runs. + */ +const CLOCK_DRIFT_PX = MAX_DISCONTINUITY_PX; +/** + * Holding-invert / baked (no WAAPI clock): paint must stay put across frames. + */ +const HOLD_JUMP_PX = MAX_DISCONTINUITY_PX; +/** Header vs first body cell for the same leaf should paint together. */ +/** Mirror-loop / compositor lag budget between header WAAPI and body copy. */ +const HEADER_BODY_SYNC_PX = 20; +/** Just clears REVERT_TO_PREVIOUS_HEADERS_DELAY (150ms); keep swaps aggressive. */ +const BETWEEN_SWAP_MS = CONTINUITY_FAST_FEEDBACK ? 160 : 155; +/** Short post-swap sample window so the next interrupt lands while peers are mid-FLIP. */ +const POST_SWAP_WATCH_MS = CONTINUITY_FAST_FEEDBACK + ? 80 + : Math.min(220, Math.floor(SLOW_DURATION * 0.15)); +/** Pointer steps for dragover→reorder (fewer = faster commit). */ +const DRAGOVER_STEPS = CONTINUITY_FAST_FEEDBACK ? 3 : 8; +/** + * rAF samples between dragover pointer steps. + * Fast mode samples harder on the commit frame so a second-reorder teleport + * cannot hide between dragover and the next pointer step. + */ +const DRAGOVER_FRAMES_PER_STEP = CONTINUITY_FAST_FEEDBACK ? 2 : 1; + +const nextFrame = (): Promise => + new Promise((r) => requestAnimationFrame(() => r(undefined))); + +/** First painted body cell for a leaf (row 0 band) — catches header/body desync. */ +const bodyVisualLeftOf = (canvasElement: HTMLElement, accessor: string): number => { + const cell = canvasElement.querySelector( + `.st-body-main .st-cell[data-accessor="${accessor}"]`, + ); + if (!cell) return NaN; + return cell.getBoundingClientRect().left; +}; + +type FlipClock = { + /** Eased progress 0..1 from getComputedTiming().progress */ + progress: number; + duration: number; + current: number; +}; + +/** Read the running/paused transform transition clock on a header cell. */ +const readFlipClock = (element: HTMLElement | null): FlipClock | null => { + if (!element || typeof element.getAnimations !== "function") return null; + for (const anim of element.getAnimations()) { + if (anim.playState !== "running" && anim.playState !== "paused") continue; + const timing = anim.effect?.getComputedTiming?.(); + if (!timing) continue; + const { duration } = timing; + const current = anim.currentTime; + if ( + typeof duration !== "number" || + !Number.isFinite(duration) || + duration <= 0 || + typeof current !== "number" || + !Number.isFinite(current) + ) { + continue; + } + // Prefer transformed progress (respects easing). Fall back to linear + // current/duration — column-reorder FLIPs are linear, so this matches. + let progress = + typeof timing.progress === "number" && Number.isFinite(timing.progress) + ? timing.progress + : current / duration; + progress = Math.min(1, Math.max(0, progress)); + return { progress, duration, current }; + } + return null; +}; + +/** + * Infer the transition's starting remain (visual−dest at progress 0) from a + * mid-flight sample. Linear / eased progress both satisfy + * remain = startRemain × (1 − progress). + */ +const inferStartRemain = (remainX: number, progress: number): number => { + if (progress <= 0.001) return remainX; + if (progress >= 0.999) return remainX; + return remainX / (1 - progress); +}; + +type LeafSample = { + visual: number; + destPage: number; + styleLeft: number; + bodyVisual: number; + flipping: boolean; + /** Signed paint offset from layout box (≈ live translate X). */ + remainX: number; + flip: FlipClock | null; + /** performance.now() at sample time — pairs with flip.current for hitch detection. */ + sampleAt: number; +}; + +const sampleLeaf = (canvasElement: HTMLElement, accessor: string): LeafSample => { + const cell = findHeaderCell(canvasElement, accessor); + const visual = cell ? cell.getBoundingClientRect().left : NaN; + const destPage = cell + ? visual - parseTranslateX(window.getComputedStyle(cell).transform) + : NaN; + const remainX = visual - destPage; + return { + visual, + destPage, + styleLeft: styleLeftOf(canvasElement, accessor), + bodyVisual: bodyVisualLeftOf(canvasElement, accessor), + flipping: hasActiveFlip(canvasElement, accessor), + remainX, + flip: readFlipClock(cell), + sampleAt: performance.now(), + }; +}; + +/** Sync assert for the hot rAF path — instrumented `await expect` is too slow + * and lets many real animation frames elapse between samples. */ +const logContinuityFail = ( + message: string, + detail?: Record, +): void => { + console.error(`[continuity:fail] ${message}`); + if (detail) { + try { + console.error(`[continuity:fail:json] ${JSON.stringify(detail)}`); + } catch { + console.error(`[continuity:fail:detail]`, detail); + } + } +}; + +const assertTrue = ( + condition: boolean, + message: string, + detail?: Record, +): void => { + if (!condition) { + logContinuityFail(message, detail); + throw new Error(message); + } +}; + +/** + * Fallback frame-jump budget when no animation clock is available. + * Baked/holding invert must stay put ({@link HOLD_JUMP_PX}). + * Never allow a ≥1px discontinuity through this path. + */ +const maxAllowedFrameJump = (prev: LeafSample): number => { + if (prev.flipping) { + return HOLD_JUMP_PX; + } + return FRAME_JUMP_PX; +}; + +const sampleDetail = (accessor: string, s: LeafSample, isDragged: boolean) => ({ + accessor, + isDragged, + visual: Number(s.visual.toFixed(3)), + destPage: Number(s.destPage.toFixed(3)), + styleLeft: s.styleLeft, + remainX: Number(s.remainX.toFixed(3)), + bodyVisual: Number.isFinite(s.bodyVisual) ? Number(s.bodyVisual.toFixed(3)) : null, + flipping: s.flipping, + flip: s.flip + ? { + progress: Number(s.flip.progress.toFixed(4)), + duration: s.flip.duration, + current: Number(s.flip.current.toFixed(2)), + } + : null, + sampleAt: Number(s.sampleAt.toFixed(2)), +}); + +/** + * When both samples have a transform clock, painted X must match + * destPage + startRemain×(1−progress) within {@link CLOCK_DRIFT_PX}. + */ +const assertClockPredictedVisual = ( + accessor: string, + prev: LeafSample, + next: LeafSample, + label: string, + isDragged: boolean, +): boolean => { + if (!prev.flip || !next.flip) return false; + // Dest rewrite is handled by the retarget assert; clock model assumes a fixed box. + if (Math.abs(next.destPage - prev.destPage) > 1.5) return false; + + const startRemain = inferStartRemain(prev.remainX, prev.flip.progress); + const expectedRemain = startRemain * (1 - next.flip.progress); + const expectedVisual = next.destPage + expectedRemain; + const drift = Math.abs(next.visual - expectedVisual); + const frameJump = Math.abs(next.visual - prev.visual); + + assertTrue( + drift < 1, + `${label}: ${accessor} drifted from FLIP clock prediction ` + + `(visual=${next.visual.toFixed(1)} expected=${expectedVisual.toFixed(1)}, ` + + `Δ=${drift.toFixed(1)}, max=${CLOCK_DRIFT_PX}, ` + + `progress ${prev.flip.progress.toFixed(3)}→${next.flip.progress.toFixed(3)}, ` + + `remain ${prev.remainX.toFixed(1)}→${next.remainX.toFixed(1)})`, + { + kind: "clock-drift", + label, + drift, + expectedVisual, + expectedRemain, + startRemain, + frameJump, + prev: sampleDetail(accessor, prev, isDragged), + next: sampleDetail(accessor, next, isDragged), + }, + ); + + // Progress should not run backward on the same transition. + assertTrue( + next.flip.progress + 0.02 >= prev.flip.progress, + `${label}: ${accessor} FLIP progress went backward ` + + `(${prev.flip.progress.toFixed(3)} → ${next.flip.progress.toFixed(3)})`, + { + kind: "progress-backward", + label, + prev: sampleDetail(accessor, prev, isDragged), + next: sampleDetail(accessor, next, isDragged), + }, + ); + + // NOTE: Do NOT assert animDt vs wallDt (clock-leap / clock-stall). + // Those caught soft-pause stop-start on the old CSS-transition FLIP path. + // ColumnReorderAnimator uses compositor WAAPI; when Storybook Interactions + // instruments expects, main-thread sampling gaps make animDt≫wallDt without + // a painted hitch (false FAIL around interaction ~265 on re-hit/post-swap). + // Painted continuity is enforced by drift + travel checks below / callers. + const animDt = next.flip.current - prev.flip.current; + if (animDt > 0 && next.flip.duration === prev.flip.duration) { + const expectedJump = Math.abs(startRemain) * (animDt / next.flip.duration); + assertTrue( + Math.abs(frameJump - expectedJump) < 1, + `${label}: ${accessor} frame travel ≠ clock-predicted travel ` + + `(Δvisual=${frameJump.toFixed(1)} expected=${expectedJump.toFixed(1)}, ` + + `animΔ=${animDt.toFixed(1)}ms)`, + { + kind: "travel-mismatch", + label, + frameJump, + expectedJump, + animDt, + startRemain, + prev: sampleDetail(accessor, prev, isDragged), + next: sampleDetail(accessor, next, isDragged), + }, + ); + } + + return true; +}; + +const assertLeafFrameContinuity = ( + accessor: string, + prev: LeafSample, + next: LeafSample, + label: string, + motion: LeafMotion | undefined, + opts: { isDragged?: boolean } = {}, +): void => { + const isDragged = opts.isDragged === true; + const frameJump = Math.abs(next.visual - prev.visual); + const destChanged = Math.abs(next.destPage - prev.destPage) > 1.5; + // Retarget: invert must pin paint. Dragged column included — that opening + // jump on reorder is exactly what we want to catch. + const allowedJump = destChanged ? RETARGET_JUMP_PX : maxAllowedFrameJump(prev); + + // Surface discontinuous motion (≥0.75px) on retarget/hold paths. + if ( + frameJump >= 0.75 && + (destChanged || !prev.flip || !next.flip) + ) { + console.warn( + `[continuity:microjump] ${label} ${accessor}` + + `${isDragged ? " (dragged)" : ""}${destChanged ? " retarget" : ""} ` + + `Δ=${frameJump.toFixed(2)} allowed=${allowedJump.toFixed(2)} ` + + `visual ${prev.visual.toFixed(2)}→${next.visual.toFixed(2)} ` + + `remain ${prev.remainX.toFixed(2)}→${next.remainX.toFixed(2)} ` + + `dest ${prev.destPage.toFixed(2)}→${next.destPage.toFixed(2)} ` + + `flip=${Boolean(prev.flip)}→${Boolean(next.flip)}`, + ); + } + + if (destChanged) { + assertTrue( + frameJump < 1, + `${label}: ${accessor}${isDragged ? " (dragged)" : ""} jumped at reorder start ` + + `(${prev.visual.toFixed(1)} → ${next.visual.toFixed(1)}, Δ=${frameJump.toFixed(1)}, ` + + `dest ${prev.destPage.toFixed(1)} → ${next.destPage.toFixed(1)}, ` + + `max=${RETARGET_JUMP_PX})`, + { + kind: "retarget-jump", + label, + frameJump, + allowedJump, + prev: sampleDetail(accessor, prev, isDragged), + next: sampleDetail(accessor, next, isDragged), + motion: motion + ? { destLeft: motion.destLeft, originLeft: motion.originLeft, step: motion.updatedAtStep } + : null, + }, + ); + } else { + const usedClock = assertClockPredictedVisual(accessor, prev, next, label, isDragged); + if (!usedClock) { + // End-of-FLIP: samples can straddle completion (remain Npx → 0), especially + // when Storybook Interactions makes rAF sampling sparse. Landing on the + // dest box with travel ≤ prior remain is completion, not a hitch. + let settlingToDest = false; + if ( + prev.flipping && + !next.flipping && + Math.abs(next.visual - next.destPage) < 0.5 && + frameJump <= Math.abs(prev.remainX) + 0.5 + ) { + settlingToDest = true; + } + + if (!settlingToDest && next.flip && Math.abs(prev.remainX) > 0.5) { + const startRemain = inferStartRemain(next.remainX, next.flip.progress); + const startDrift = Math.abs(startRemain - prev.remainX); + assertTrue( + startDrift < 1, + `${label}: ${accessor} FLIP start remain jumped at transition start ` + + `(held=${prev.remainX.toFixed(1)} inferred=${startRemain.toFixed(1)}, ` + + `Δ=${startDrift.toFixed(1)})`, + { + kind: "start-remain-jump", + label, + startRemain, + startDrift, + prev: sampleDetail(accessor, prev, isDragged), + next: sampleDetail(accessor, next, isDragged), + }, + ); + } + if (!settlingToDest) { + assertTrue( + frameJump < 1, + `${label}: ${accessor} teleported between frames ` + + `(${prev.visual.toFixed(1)} → ${next.visual.toFixed(1)}, Δ=${frameJump.toFixed(1)}, ` + + `allowed=${allowedJump.toFixed(1)})`, + { + kind: "frame-teleport", + label, + frameJump, + allowedJump, + prev: sampleDetail(accessor, prev, isDragged), + next: sampleDetail(accessor, next, isDragged), + }, + ); + } + } + } + + if (motion && !destChanged) { + assertTrue( + Math.abs(next.styleLeft - motion.destLeft) < 1.5, + `${label}: ${accessor} style.left drifted from expected dest ` + + `(${next.styleLeft} vs ${motion.destLeft})`, + ); + } else if (motion && destChanged) { + motion.destLeft = next.styleLeft; + } + + if (!destChanged && (next.flipping || motion)) { + const pathMin = Math.min(prev.visual, next.destPage) - PATH_SLACK_PX; + const pathMax = Math.max(prev.visual, next.destPage) + PATH_SLACK_PX; + assertTrue( + next.visual >= pathMin && next.visual <= pathMax, + `${label}: ${accessor} left FLIP path between frames ` + + `(${prev.visual.toFixed(1)} → ${next.visual.toFixed(1)}, ` + + `destPage=${next.destPage.toFixed(1)})`, + ); + + const distBefore = Math.abs(prev.visual - prev.destPage); + const distNow = Math.abs(next.visual - next.destPage); + assertTrue( + distNow <= distBefore + PATH_SLACK_PX, + `${label}: ${accessor} moved away from dest between frames. ` + + `dist ${distBefore.toFixed(1)} → ${distNow.toFixed(1)}`, + ); + } else if (!destChanged && !next.flipping && !motion) { + assertTrue( + Math.abs(next.visual - next.destPage) < 1.5, + `${label}: settled ${accessor} drifted from layout box ` + + `(visual=${next.visual.toFixed(1)} box=${next.destPage.toFixed(1)})`, + ); + } + + if (!isDragged && Number.isFinite(next.bodyVisual) && Number.isFinite(prev.bodyVisual)) { + const headerBodyGap = Math.abs(next.visual - next.bodyVisual); + assertTrue( + headerBodyGap <= HEADER_BODY_SYNC_PX, + `${label}: ${accessor} header/body desync ` + + `(header=${next.visual.toFixed(1)} body=${next.bodyVisual.toFixed(1)} ` + + `gap=${headerBodyGap.toFixed(1)})`, + ); + + const bodyJump = Math.abs(next.bodyVisual - prev.bodyVisual); + if (destChanged) { + assertTrue( + bodyJump <= RETARGET_JUMP_PX, + `${label}: ${accessor} body jumped at reorder start ` + + `(${prev.bodyVisual.toFixed(1)} → ${next.bodyVisual.toFixed(1)}, Δ=${bodyJump.toFixed(1)}, ` + + `max=${RETARGET_JUMP_PX})`, + ); + } else { + // Body must track the header's step — not a separate loose distance budget. + assertTrue( + bodyJump <= frameJump + CLOCK_DRIFT_PX || + (!prev.flipping && !next.flipping && bodyJump < 1.5), + `${label}: ${accessor} body teleported between frames ` + + `(${prev.bodyVisual.toFixed(1)} → ${next.bodyVisual.toFixed(1)}, Δ=${bodyJump.toFixed(1)}, ` + + `headerΔ=${frameJump.toFixed(1)})`, + ); + } + } +}; + +/** + * Sample every Spotify 7d leaf on every animation frame until duration elapses + * and/or `until` returns true. Updates motion.visualAtSample as it goes. + * Returns frames sampled (for density assertions / HUD). + */ +const watchLeafContinuity = async ( + canvasElement: HTMLElement, + motions: Map, + label: string, + opts: { + durationMs?: number; + until?: () => boolean; + /** When true, also assert settled leaves stay glued (default true). */ + watchAllLeaves?: boolean; + /** Active drag source — native drag paint needs looser per-frame limits. */ + dragged?: string; + } = {}, +): Promise => { + const watchAll = opts.watchAllLeaves !== false; + const last = new Map(); + for (const accessor of SPOTIFY_7D_LEAVES) { + last.set(accessor, sampleLeaf(canvasElement, accessor)); + } + + const deadline = + opts.durationMs !== undefined ? Date.now() + opts.durationMs : Number.POSITIVE_INFINITY; + let frames = 0; + + while (Date.now() < deadline) { + if (opts.until?.()) break; + await nextFrame(); + frames += 1; + + // Read every leaf synchronously first so samples share one paint, then + // assert (also sync). Instrumented awaits between reads were letting + // ~100ms of FLIP elapse and looking like teleports. + const round = new Map(); + for (const accessor of SPOTIFY_7D_LEAVES) { + const motion = motions.get(accessor); + if (!watchAll && !motion && !hasActiveFlip(canvasElement, accessor)) continue; + round.set(accessor, sampleLeaf(canvasElement, accessor)); + } + for (const [accessor, next] of round) { + const prev = last.get(accessor)!; + assertLeafFrameContinuity( + accessor, + prev, + next, + `${label}#f${frames}`, + motions.get(accessor), + { + isDragged: accessor === opts.dragged, + }, + ); + last.set(accessor, next); + const motion = motions.get(accessor); + if (motion) motion.visualAtSample = next.visual; + } + } + + return frames; +}; + +type DragSession = { + dataTransfer: DataTransfer; + sourceAccessor: string; + lastClientX: number; + lastClientY: number; +}; + +const beginLeafDrag = (canvasElement: HTMLElement, sourceAccessor: string): DragSession => { + const sourceLabel = findHeaderLabel(canvasElement, sourceAccessor); + const rect = sourceLabel.getBoundingClientRect(); + const clientX = rect.left + rect.width / 2; + const clientY = rect.top + rect.height / 2; + const dataTransfer = new DataTransfer(); + dataTransfer.setData("text/plain", "column-drag"); + dataTransfer.effectAllowed = "move"; + sourceLabel.dispatchEvent( + new DragEvent("dragstart", { + bubbles: true, + cancelable: true, + clientX, + clientY, + screenX: clientX, + screenY: clientY, + dataTransfer, + }), + ); + return { dataTransfer, sourceAccessor, lastClientX: clientX, lastClientY: clientY }; +}; + +const endLeafDrag = (session: DragSession, canvasElement: HTMLElement): void => { + const sourceLabel = findHeaderLabel(canvasElement, session.sourceAccessor); + const { lastClientX: clientX, lastClientY: clientY, dataTransfer } = session; + sourceLabel.dispatchEvent( + new DragEvent("drop", { + bubbles: true, + cancelable: true, + clientX, + clientY, + screenX: clientX, + screenY: clientY, + dataTransfer, + }), + ); + sourceLabel.dispatchEvent( + new DragEvent("dragend", { + bubbles: true, + cancelable: true, + clientX, + clientY, + screenX: clientX, + screenY: clientY, + dataTransfer, + }), + ); +}; + +const snapshotLeafVisuals = (canvasElement: HTMLElement): Map => { + const map = new Map(); + for (const accessor of SPOTIFY_7D_LEAVES) { + map.set(accessor, visualLeftOf(canvasElement, accessor)); + } + return map; +}; + +const snapshotLeafStyleLefts = (canvasElement: HTMLElement): Map => { + const map = new Map(); + for (const accessor of SPOTIFY_7D_LEAVES) { + map.set(accessor, styleLeftOf(canvasElement, accessor)); + } + return map; +}; + +/** + * Fire dragovers from the last pointer position onto target until style order + * changes (or attempts exhausted). Stays inside an open drag session. + * + * Starts at least 50px away from the target so dragging.ts distance gates + * (`distance < 10` and anti-ping-pong `distance < 40`) can clear. + * + * Returns visuals sampled immediately before the dragover that changed order — + * prior FLIPs may progress during the long pointer travel, so continuity + * asserts must compare against that moment (not against the pre-travel sample). + * + * When `motions` is provided, every animation frame during travel is checked + * so mid-drag teleports cannot hide between pointer steps. + */ +const dragOverUntilReorder = async ( + canvasElement: HTMLElement, + session: DragSession, + targetAccessor: string, + opts?: { + expectOrder?: string; + motions?: Map; + watchLabel?: string; + dragged?: string; + }, +): Promise<{ + ok: boolean; + visualsBeforeReorder: Map; + visualsAtCommit: Map; +}> => { + const targetLabel = findHeaderLabel(canvasElement, targetAccessor); + const targetCell = targetLabel.closest(".st-header-cell") ?? targetLabel; + const targetRect = targetLabel.getBoundingClientRect(); + const endX = targetRect.left + targetRect.width / 2; + const endY = targetRect.top + targetRect.height / 2; + + // Guarantee a long enough pointer travel for the distance gates. + let startX = session.lastClientX; + let startY = session.lastClientY; + const travel = Math.hypot(endX - startX, endY - startY); + if (travel < 50) { + startX = endX - 60; + startY = endY; + } + + const orderBefore = leafLeftOrder(canvasElement, SPOTIFY_7D_LEAVES); + let visualsBeforeReorder = snapshotLeafVisuals(canvasElement); + let styleLeftsBeforeReorder = snapshotLeafStyleLefts(canvasElement); + const lastSamples = new Map(); + for (const accessor of SPOTIFY_7D_LEAVES) { + lastSamples.set(accessor, sampleLeaf(canvasElement, accessor)); + } + let frame = 0; + + const watchFrames = async (count: number) => { + if (!opts?.motions) { + for (let i = 0; i < count; i++) await nextFrame(); + return; + } + for (let i = 0; i < count; i++) { + await nextFrame(); + frame += 1; + const round = new Map(); + for (const accessor of SPOTIFY_7D_LEAVES) { + round.set(accessor, sampleLeaf(canvasElement, accessor)); + } + for (const [accessor, next] of round) { + const prev = lastSamples.get(accessor)!; + assertLeafFrameContinuity( + accessor, + prev, + next, + `${opts.watchLabel ?? "dragover"}#f${frame}`, + opts.motions.get(accessor), + { isDragged: accessor === opts.dragged }, + ); + lastSamples.set(accessor, next); + const motion = opts.motions.get(accessor); + if (motion) motion.visualAtSample = next.visual; + } + } + }; + + const attempts = 2; + for (let attempt = 0; attempt < attempts; attempt++) { + if (attempt > 0) { + if (opts?.motions) { + await watchLeafContinuity( + canvasElement, + opts.motions, + `${opts.watchLabel ?? "dragover"} retry`, + { + durationMs: BETWEEN_SWAP_MS, + watchAllLeaves: true, + dragged: opts.dragged, + }, + ); + } else { + await sleep(BETWEEN_SWAP_MS); + } + startX = endX - 80 * (attempt % 2 === 0 ? 1 : -1); + startY = endY; + } + const steps = DRAGOVER_STEPS; + for (let i = 0; i <= steps; i++) { + const progress = i / steps; + const x = startX + (endX - startX) * progress; + const y = startY + (endY - startY) * progress; + session.lastClientX = x; + session.lastClientY = y; + // Sample before the event so we still have pre-reorder painted positions + // even if this dragover commits the swap synchronously. + visualsBeforeReorder = snapshotLeafVisuals(canvasElement); + styleLeftsBeforeReorder = snapshotLeafStyleLefts(canvasElement); + targetCell.dispatchEvent( + new DragEvent("dragover", { + bubbles: true, + cancelable: true, + clientX: x, + clientY: y, + screenX: x, + screenY: y, + dataTransfer: session.dataTransfer, + }), + ); + // Assert paint continuity in the same turn as the reorder commit — + // waiting for rAF first lets FLIP travel (or a hitch) hide between samples. + const orderNow = leafLeftOrder(canvasElement, SPOTIFY_7D_LEAVES); + if (orderNow !== orderBefore) { + for (const accessor of SPOTIFY_7D_LEAVES) { + const prevVisual = visualsBeforeReorder.get(accessor); + if (prevVisual === undefined) continue; + const prevStyleLeft = styleLeftsBeforeReorder.get(accessor); + const styleLeftNow = styleLeftOf(canvasElement, accessor); + const destChanged = + prevStyleLeft === undefined || Math.abs(styleLeftNow - prevStyleLeft) > 1.5; + const visual = visualLeftOf(canvasElement, accessor); + const jump = Math.abs(visual - prevVisual); + const isDraggedLeaf = accessor === opts?.dragged; + const flipping = hasActiveFlip(canvasElement, accessor); + const remainX = visual - styleBoxLeftOf(canvasElement, accessor); + if (jump >= 0.75) { + console.warn( + `[continuity:microjump] ${opts?.watchLabel ?? "dragover"} commit-sync ${accessor}` + + `${isDraggedLeaf ? " (dragged)" : ""}${destChanged ? " retarget" : ""} ` + + `Δ=${jump.toFixed(2)} ` + + `visual ${prevVisual.toFixed(2)}→${visual.toFixed(2)} ` + + `styleLeft ${prevStyleLeft ?? "?"}→${styleLeftNow} remain=${remainX.toFixed(2)}`, + ); + } + assertTrue( + jump < 1, + `${opts?.watchLabel ?? "dragover"}: ${accessor}` + + `${isDraggedLeaf ? " (dragged)" : ""} jumped at reorder commit ` + + `(${prevVisual.toFixed(1)} → ${visual.toFixed(1)}, Δ=${jump.toFixed(1)}, ` + + `max=0.99${destChanged ? ", retarget" : ""})`, + { + kind: "reorder-commit-jump", + label: opts?.watchLabel ?? "dragover", + accessor, + isDragged: isDraggedLeaf, + destChanged, + jump, + prevVisual, + visual, + prevStyleLeft, + styleLeftNow, + remainX, + flipping, + }, + ); + } + // Capture hold visuals NOW — any await (watchFrames / expect) lets WAAPI + // advance and would falsely fail a post-await jump check. + const visualsAtCommit = snapshotLeafVisuals(canvasElement); + const ok = opts?.expectOrder ? orderNow === opts.expectOrder : true; + await watchFrames(DRAGOVER_FRAMES_PER_STEP); + return { ok, visualsBeforeReorder, visualsAtCommit }; + } + await watchFrames(DRAGOVER_FRAMES_PER_STEP); + } + } + return { ok: false, visualsBeforeReorder, visualsAtCommit: visualsBeforeReorder }; +}; + +/** + * Drag source leaf onto target leaf with enough distance to clear the + * drag throttle / distance gates in dragging.ts. + */ +const dragLeafOntoLeaf = async ( + canvasElement: HTMLElement, + sourceAccessor: string, + targetAccessor: string, + opts?: { sampleFlip?: (saw: boolean) => void }, +): Promise => { + const session = beginLeafDrag(canvasElement, sourceAccessor); + let sawFlip = false; + const pollFlip = () => { + if (sawFlip) return; + for (const accessor of [sourceAccessor, targetAccessor]) { + if (hasActiveFlip(canvasElement, accessor)) { + sawFlip = true; + opts?.sampleFlip?.(true); + return; + } + } + }; + + const steps = 10; + const targetLabel = findHeaderLabel(canvasElement, targetAccessor); + const targetCell = targetLabel.closest(".st-header-cell") ?? targetLabel; + const startX = session.lastClientX; + const startY = session.lastClientY; + const targetRect = targetLabel.getBoundingClientRect(); + const endX = targetRect.left + targetRect.width / 2; + const endY = targetRect.top + targetRect.height / 2; + + for (let i = 0; i <= steps; i++) { + const progress = i / steps; + const x = startX + (endX - startX) * progress; + const y = startY + (endY - startY) * progress; + session.lastClientX = x; + session.lastClientY = y; + targetCell.dispatchEvent( + new DragEvent("dragover", { + bubbles: true, + cancelable: true, + clientX: x, + clientY: y, + screenX: x, + screenY: y, + dataTransfer: session.dataTransfer, + }), + ); + for (let frame = 0; frame < 4; frame++) { + await new Promise((r) => requestAnimationFrame(() => r(undefined))); + pollFlip(); + if (sawFlip) break; + } + } + + const sawBeforeDragEnd = sawFlip; + endLeafDrag(session, canvasElement); + await sleep(120); + return sawBeforeDragEnd; +}; + // --------------------------------------------------------------------------- // Stories // --------------------------------------------------------------------------- @@ -360,9 +1454,7 @@ export const HeavyTrackListColumnEditor = { await waitForTable(canvasElement); await waitUntil( () => - !!canvasElement.querySelector( - ".st-column-editor-popout.open, .st-column-editor-popout", - ), + !!canvasElement.querySelector(".st-column-editor-popout.open, .st-column-editor-popout"), { timeoutMs: 5000 }, ); @@ -371,8 +1463,7 @@ export const HeavyTrackListColumnEditor = { canvasElement.querySelector(".st-column-editor-popout"); expect(popout).toBeTruthy(); - const items = () => - Array.from(canvasElement.querySelectorAll(".st-header-checkbox-item")); + const items = () => Array.from(canvasElement.querySelectorAll(".st-header-checkbox-item")); // Prefer nested leaf rows (indented) — these are the ones that felt sticky. const nestedLeaves = items().filter((item) => { @@ -394,10 +1485,9 @@ export const HeavyTrackListColumnEditor = { const input = leaves[i]?.querySelector(".st-checkbox-input") as HTMLInputElement | null; expect(input, `missing nested checkbox at index ${i}`).toBeTruthy(); input!.click(); - await waitUntil( - () => getSnapshot().visibilityChangeCount > beforeVisibility + i, - { timeoutMs: 3000 }, - ); + await waitUntil(() => getSnapshot().visibilityChangeCount > beforeVisibility + i, { + timeoutMs: 3000, + }); } const after = getSnapshot(); @@ -415,10 +1505,9 @@ export const LightNestedColumnEditorControl = { }), play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { await waitForTable(); - await waitUntil( - () => !!canvasElement.querySelector(".st-header-checkbox-item"), - { timeoutMs: 3000 }, - ); + await waitUntil(() => !!canvasElement.querySelector(".st-header-checkbox-item"), { + timeoutMs: 3000, + }); const items = canvasElement.querySelectorAll(".st-header-checkbox-item"); expect(items.length).toBeGreaterThan(2); }, @@ -438,3 +1527,805 @@ export const HeavyHeaderReorderSettle = { expect(labels.length).toBeGreaterThan(3); }, }; + +type DragPlaygroundArgs = { + duration: number; +}; + +/** + * Manual QA surface for the exact Track List fixture from client repros. + * Use the Duration control to slow FLIP so header + body slides are visible. + */ +export const TrackListDragPlaygroundSlow = { + name: "Track List drag playground (slow)", + args: { + duration: SLOW_DURATION, + } satisfies DragPlaygroundArgs, + argTypes: { + duration: { + name: "Duration (ms)", + control: { type: "range", min: 400, max: 3000, step: 100 }, + description: "animations.duration — slow down to watch mid-drag FLIP", + }, + }, + render: (args: DragPlaygroundArgs) => + buildReproLayout({ + mode: "heavy", + rowCount: 40, + enableReorder: true, + enableColumnEditor: false, + animations: { enabled: true, duration: args.duration ?? SLOW_DURATION }, + banner: + `Drag Spotify → 7d leaves (e.g. Completion onto Shares). ` + + `FLIP duration: ${args.duration ?? SLOW_DURATION}ms. ` + + `Headers and body cells should slide on each dragover swap.`, + }), +}; + +/** + * Scripted drag of two Spotify 7d siblings; asserts mid-drag FLIP + order change. + */ +export const TrackListDragAnimatesMidSwap = { + name: "Track List drag animates mid-swap", + render: () => + buildReproLayout({ + mode: "heavy", + rowCount: 24, + enableReorder: true, + enableColumnEditor: false, + animations: { enabled: true, duration: SLOW_DURATION }, + banner: + `Automated: drag spotify_7d_completion → spotify_7d_shares ` + + `(${SLOW_DURATION}ms). Expect FLIP during dragover and swapped left order.`, + }), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(canvasElement); + await sleep(400); + + const source = "spotify_7d_completion"; + const target = "spotify_7d_shares"; + const siblings = [...SPOTIFY_7D_LEAVES]; + await ensureLeavesInView(canvasElement, siblings); + + for (const accessor of [source, target]) { + expect(findHeaderCell(canvasElement, accessor), `missing ${accessor}`).toBeTruthy(); + } + + const orderBefore = leafLeftOrder(canvasElement, siblings); + const sourceLeftBefore = parseFloat(findHeaderCell(canvasElement, source)!.style.left || "0"); + const targetLeftBefore = parseFloat(findHeaderCell(canvasElement, target)!.style.left || "0"); + expect(sourceLeftBefore).toBeGreaterThan(targetLeftBefore); + + const sawFlipBeforeDragEnd = await dragLeafOntoLeaf(canvasElement, source, target); + + const orderAfter = leafLeftOrder(canvasElement, siblings); + expect( + orderAfter !== orderBefore, + `Expected Spotify 7d leaf order to change after drag. before=${orderBefore} after=${orderAfter}`, + ).toBe(true); + + expect( + sawFlipBeforeDragEnd, + "Expected a non-zero header FLIP transform/transition during dragover " + + "(before dragend) when reordering Track List leaves.", + ).toBe(true); + + // Body cells for the moved columns should also have participated (or settled). + const bodySample = canvasElement.querySelector( + `.st-body-main .st-cell[data-accessor="${source}"]`, + ); + expect(bodySample, "missing body cell for dragged leaf").toBeTruthy(); + }, +}; + +/** + * Slow leftward crawl: each neighbor touch starts a reorder while earlier + * slides are still mid-flight. Asserts mid-flight clocks do not stall + * (soft-pause stop-start jitter). + */ +export const TrackListSlowLeftwardNoJitter = { + name: "Track List slow leftward no jitter", + parameters: { + test: { timeout: 120_000 }, + }, + render: () => + buildReproLayout({ + mode: "heavy", + rowCount: 16, + enableReorder: true, + enableColumnEditor: false, + enableVirtualization: false, + animations: { enabled: true, duration: CONTINUITY_DURATION }, + banner: + `Automated: drag completion slowly left across Spotify 7d leaves. ` + + `Mid-flight siblings must keep sliding (no soft-pause stop-start).`, + }), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(canvasElement); + await sleep(120); + + const dragged = "spotify_7d_completion"; + await ensureLeavesInView(canvasElement, SPOTIFY_7D_LEAVES); + const bodyMain = canvasElement.querySelector(".st-body-main"); + if (bodyMain) { + bodyMain.scrollLeft = 0; + bodyMain.dispatchEvent(new Event("scroll", { bubbles: true })); + await sleep(40); + } + + for (const accessor of SPOTIFY_7D_LEAVES) { + await expect(findHeaderCell(canvasElement, accessor), `missing ${accessor}`).toBeTruthy(); + } + + const slots = slotLefts(canvasElement, SPOTIFY_7D_LEAVES); + let order = orderedLeaves(canvasElement, SPOTIFY_7D_LEAVES); + await expect(order[order.length - 1]).toBe(dragged); + + const motions = new Map(); + let totalWatchFrames = 0; + let session = beginLeafDrag(canvasElement, dragged); + const unfreezeScroll = freezeMainScroll(canvasElement); + + // Walk left through neighbors in visual order (right→left excluding dragged). + const leftwardTargets = [...SPOTIFY_7D_LEAVES].filter((a) => a !== dragged).reverse(); + + try { + let step = 0; + for (let i = 0; i < leftwardTargets.length; i++) { + const forceTarget = leftwardTargets[i]; + // Clear anti-ping-pong, but keep watching so mid-flight stalls fail. + totalWatchFrames += await watchLeafContinuity(canvasElement, motions, `crawl gap ${i + 1}`, { + durationMs: BETWEEN_SWAP_MS, + watchAllLeaves: true, + dragged, + }); + + const nextOrder = applyInsertReorder(order, dragged, forceTarget); + if (nextOrder.join(",") === order.join(",")) continue; + + const result = await runInterruptSwap( + canvasElement, + session, + dragged, + order, + slots, + motions, + step, + `slow leftward crawl ${i + 1}/${leftwardTargets.length} → ${forceTarget}`, + { forceTarget }, + ); + order = result.order; + totalWatchFrames += result.watchFrames; + step += 1; + } + + endLeafDrag(session, canvasElement); + totalWatchFrames += await watchLeafContinuity(canvasElement, motions, "crawl settle", { + durationMs: CONTINUITY_DURATION + 100, + watchAllLeaves: true, + }); + + await expect( + totalWatchFrames > 80, + `expected dense crawl sampling; got ${totalWatchFrames}`, + ).toBe(true); + console.log( + `[continuity] slow-leftward crawl steps=${step} watchFrames=${totalWatchFrames}`, + ); + } finally { + unfreezeScroll(); + } + }, +}; + +/** + * Pick a settled leaf target that changes insert order. + * + * Production ignores dragover on mid-FLIP headers (visual hit-testing would + * otherwise ping-pong / flip back). Continuity plays still exercise mid-flight + * motion — they just drop on settled siblings while others are sliding. + */ +const pickReorderTarget = ( + canvasElement: HTMLElement, + order: string[], + dragged: string, + fallbackIndex: number, + opts: { settledOnly?: boolean } = {}, +): string | null => { + const others = order.filter((a) => a !== dragged); + const settledOnly = opts.settledOnly !== false; + const candidates = settledOnly + ? others.filter((a) => !hasActiveFlip(canvasElement, a)) + : others; + + // Rotate fallback so we walk around the band instead of always picking the first. + if (candidates.length === 0) return null; + const rotated = [ + ...candidates.slice(fallbackIndex % candidates.length), + ...candidates.slice(0, fallbackIndex % candidates.length), + ]; + + for (const target of rotated) { + const next = applyInsertReorder(order, dragged, target); + if (next.join(",") !== order.join(",")) return target; + } + return null; +}; + +const isSettledLeaf = (canvasElement: HTMLElement, accessor: string): boolean => { + const cell = findHeaderCell(canvasElement, accessor); + if (!cell) return false; + // Prefer computed/paint over style.transform: WAAPI fill:forwards can leave a + // stale start translate on style while the painted matrix is already identity. + const computed = window.getComputedStyle(cell).transform; + if (computed && computed !== "none" && Math.abs(parseTranslateX(computed)) > 0.5) { + return false; + } + const visual = visualLeftOf(canvasElement, accessor); + const box = styleBoxLeftOf(canvasElement, accessor); + return Math.abs(visual - box) < 1.5; +}; + +/** Keep horizontal scroll fixed so viewport visuals aren't shifted by clamp/reflow. */ +const freezeMainScroll = (canvasElement: HTMLElement): (() => void) => { + const panes = [ + canvasElement.querySelector(".st-body-main"), + canvasElement.querySelector(".st-header-main"), + ].filter((el): el is HTMLElement => !!el); + if (panes.length === 0) return () => undefined; + const locked = panes[0].scrollLeft; + for (const pane of panes) pane.scrollLeft = locked; + const onScroll = (event: Event) => { + const target = event.target as HTMLElement; + if (target.scrollLeft !== locked) target.scrollLeft = locked; + }; + for (const pane of panes) pane.addEventListener("scroll", onScroll); + return () => { + for (const pane of panes) { + pane.removeEventListener("scroll", onScroll); + pane.scrollLeft = locked; + } + }; +}; + +/** Drop motions that have finished so later progress checks don't treat them as mid-flight. */ +const pruneSettledMotions = ( + canvasElement: HTMLElement, + motions: Map, +): string[] => { + const settled: string[] = []; + for (const accessor of [...motions.keys()]) { + if (isSettledLeaf(canvasElement, accessor)) { + motions.delete(accessor); + settled.push(accessor); + } + } + return settled; +}; + +const runInterruptSwap = async ( + canvasElement: HTMLElement, + session: DragSession, + dragged: string, + order: string[], + slots: number[], + motions: Map, + step: number, + label: string, + opts: { + /** When true, wait until some other leaf is mid-FLIP before picking a settled drop target. */ + requireOthersAnimating?: boolean; + forceTarget?: string; + } = {}, +): Promise<{ order: string[]; target: string; watchFrames: number }> => { + let target = opts.forceTarget ?? null; + if (target) { + const next = applyInsertReorder(order, dragged, target); + if (next.join(",") === order.join(",")) { + target = null; + } + } + + // Wait for a settled drop target (and optional mid-flight context). Mid-FLIP + // headers are not valid drop targets anymore. + const pickDeadline = Date.now() + CONTINUITY_DURATION + 500; + let waitFrames = 0; + while (Date.now() < pickDeadline) { + if (opts.requireOthersAnimating) { + const othersAnimating = SPOTIFY_7D_LEAVES.some( + (a) => a !== dragged && hasActiveFlip(canvasElement, a), + ); + if (!othersAnimating) { + // No live FLIPs yet — proceed with a settled target anyway. + } + } + + if (target) { + if (!hasActiveFlip(canvasElement, target)) break; + // Forced target still sliding — wait for it to settle. + } else { + target = pickReorderTarget(canvasElement, order, dragged, step, { settledOnly: true }); + if (target) { + if (!opts.requireOthersAnimating) break; + const othersAnimating = SPOTIFY_7D_LEAVES.some( + (a) => a !== dragged && a !== target && hasActiveFlip(canvasElement, a), + ); + // Prefer dropping while siblings are mid-flight; if the band has fully + // settled, still take the settled target so the play can continue. + if (othersAnimating || Date.now() > pickDeadline - 80) break; + } + } + + waitFrames += await watchLeafContinuity( + canvasElement, + motions, + `${label} wait-settled-target`, + { + durationMs: 60, + watchAllLeaves: true, + dragged, + }, + ); + if (!opts.forceTarget) target = null; + } + + if (!target) { + target = pickReorderTarget(canvasElement, order, dragged, step, { settledOnly: true }); + } + await expect(target, `${label}: no settled reorder target from ${order.join(",")}`).toBeTruthy(); + await expect( + !hasActiveFlip(canvasElement, target!), + `${label}: drop target ${target} is still mid-FLIP (production ignores these)`, + ).toBe(true); + + const originLefts = new Map(); + for (const accessor of SPOTIFY_7D_LEAVES) { + originLefts.set(accessor, styleLeftOf(canvasElement, accessor)); + } + + const expectedOrder = applyInsertReorder(order, dragged, target!); + const expectedDest = expectedLeftMap(expectedOrder, slots); + const expectOrderKey = expectedOrder.join(","); + + const { + ok: reordered, + visualsBeforeReorder, + visualsAtCommit, + } = await dragOverUntilReorder(canvasElement, session, target!, { + expectOrder: expectOrderKey, + motions, + watchLabel: `${label} dragover`, + dragged, + }); + await expect( + reordered, + `${label}: drag ${dragged} → ${target} should apply insert reorder. ` + + `before=${order.join(",")} expected=${expectOrderKey} ` + + `actual=${leafLeftOrder(canvasElement, SPOTIFY_7D_LEAVES)}`, + ).toBe(true); + + // visualsAtCommit was sampled in the same turn as the reorder hold + // (before watchFrames / this await). Do not re-snapshot here — WAAPI will + // have advanced and a <1px jump check against pre-reorder would flake. + + for (const accessor of SPOTIFY_7D_LEAVES) { + const actual = styleLeftOf(canvasElement, accessor); + const expected = expectedDest.get(accessor)!; + await expect( + Math.abs(actual - expected) < 1.5, + `${label}: ${accessor} style.left=${actual}, expected dest=${expected}`, + ).toBe(true); + } + + for (const accessor of SPOTIFY_7D_LEAVES) { + const destLeft = expectedDest.get(accessor)!; + const prevLeft = originLefts.get(accessor)!; + if (Math.abs(destLeft - prevLeft) < 1) { + const existing = motions.get(accessor); + if (existing) existing.destLeft = destLeft; + continue; + } + + const visual = visualsAtCommit.get(accessor)!; + const prevVisual = visualsBeforeReorder.get(accessor)!; + const destPage = styleBoxLeftOf(canvasElement, accessor); + const swapJump = Math.abs(visual - prevVisual); + const isDraggedLeaf = accessor === dragged; + + // Hold was already assertTrue'd sync in dragOverUntilReorder; keep this + // as a belt-and-suspenders check on the same captured map. + await expect( + swapJump < 1, + `${label}: ${accessor}${isDraggedLeaf ? " (dragged)" : ""} jumped at reorder start ` + + `(${prevVisual.toFixed(1)} → ${visual.toFixed(1)}, Δ=${swapJump.toFixed(1)}, ` + + `destPage=${destPage.toFixed(1)}, max=${RETARGET_JUMP_PX})`, + ).toBe(true); + + const pathMin = Math.min(prevVisual, destPage) - PATH_SLACK_PX; + const pathMax = Math.max(prevVisual, destPage) + PATH_SLACK_PX; + await expect( + visual >= pathMin && visual <= pathMax, + `${label}: ${accessor} visual left the FLIP path on swap ` + + `(${prevVisual.toFixed(1)} → ${visual.toFixed(1)}, destPage=${destPage.toFixed(1)}). ` + + `originLeft=${prevLeft} destLeft=${destLeft}`, + ).toBe(true); + + motions.set(accessor, { + accessor, + destLeft, + visualAtSample: visual, + originLeft: prevLeft, + updatedAtStep: step, + }); + } + + const watchFrames = + waitFrames + + (await watchLeafContinuity(canvasElement, motions, `${label} post-swap`, { + durationMs: POST_SWAP_WATCH_MS, + watchAllLeaves: true, + dragged, + })); + return { order: expectedOrder, target: target!, watchFrames }; +}; + +/** + * Mid-flight interrupt continuity on Spotify 7d leaves: + * 1. Rapid reorders via settled drop targets while other leaves are mid-FLIP + * (production ignores dragover on mid-FLIP headers) + * 2. Hold the drag until early targets settle, then drag over them again + * 3. Mid-flight burst, then release and *immediately* start dragging + * streams while those FLIPs are still flying + * 4. Streams keeps interrupting (and occasionally re-hitting settled leaves) + * + * Dense sampling: every animation frame checks every Spotify 7d leaf's painted + * header (+ matching body cell) for teleports / path breaks / header-body + * desync — during dragover travel, post-swap ease, between-swap gaps, and + * settle waits. Full Track List fixture + slow FLIP; play budget is 20 minutes. + */ +export const TrackListTenInterruptContinuity = { + name: "Track List 10× interrupt continuity", + parameters: { + // Storybook Interactions / test-runner: this play is intentionally long. + // Fast-feedback mode shortens the budget while iterating on teleports. + test: { + timeout: CONTINUITY_FAST_FEEDBACK ? 120_000 : CONTINUITY_PLAY_TIMEOUT_MS, + }, + }, + render: () => + buildReproLayout({ + mode: "heavy", + rowCount: CONTINUITY_FAST_FEEDBACK ? 16 : 40, + enableReorder: true, + enableColumnEditor: false, + enableVirtualization: false, + animations: { enabled: true, duration: CONTINUITY_DURATION }, + banner: + `Automated continuity (dense per-frame sampling` + + `${CONTINUITY_FAST_FEEDBACK ? ", FAST FEEDBACK (trimmed phases)" : ", ~20min budget"}) on full Track ` + + `List: settled-target reorders while others mid-FLIP, re-hit settled targets, hand off to streams mid-flight ` + + `for ${CONTINUITY_FAST_FEEDBACK ? 8 : HANDOFF_SWAPS} swaps (${CONTINUITY_DURATION}ms FLIP).`, + }), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(canvasElement); + await sleep(CONTINUITY_FAST_FEEDBACK ? 120 : 400); + + // Fast mode exercises every phase with trimmed counts (not burst-only). + const BURST_SWAPS = CONTINUITY_FAST_FEEDBACK ? 10 : 24; + const SETTLED_REHIT_SWAPS = CONTINUITY_FAST_FEEDBACK ? 4 : 16; + const PRE_HANDOFF_BURST = CONTINUITY_FAST_FEEDBACK ? 6 : 20; + const handoffSwaps = CONTINUITY_FAST_FEEDBACK ? 8 : HANDOFF_SWAPS; + const dragged = "spotify_7d_completion"; + const handoffDragged = "spotify_7d_streams"; + let totalWatchFrames = 0; + + await ensureLeavesInView(canvasElement, SPOTIFY_7D_LEAVES); + const bodyMain = canvasElement.querySelector(".st-body-main"); + if (bodyMain) { + bodyMain.scrollLeft = 0; + bodyMain.dispatchEvent(new Event("scroll", { bubbles: true })); + await sleep(40); + } + + for (const accessor of SPOTIFY_7D_LEAVES) { + await expect(findHeaderCell(canvasElement, accessor), `missing ${accessor}`).toBeTruthy(); + } + + const slots = slotLefts(canvasElement, SPOTIFY_7D_LEAVES); + await expect(slots.length).toBe(SPOTIFY_7D_LEAVES.length); + + let order = orderedLeaves(canvasElement, SPOTIFY_7D_LEAVES); + await expect(order[order.length - 1]).toBe(dragged); + + const motions = new Map(); + const targetsHit: string[] = []; + let step = 0; + let session = beginLeafDrag(canvasElement, dragged); + const unfreezeScroll = freezeMainScroll(canvasElement); + + const watchGap = async (label: string, durationMs: number, draggedCol: string = dragged) => { + totalWatchFrames += await watchLeafContinuity(canvasElement, motions, label, { + durationMs, + watchAllLeaves: true, + dragged: draggedCol, + }); + }; + + try { + for (let i = 0; i < BURST_SWAPS; i++) { + await watchGap(`burst gap ${i + 1}`, BETWEEN_SWAP_MS); + const result = await runInterruptSwap( + canvasElement, + session, + dragged, + order, + slots, + motions, + step, + `burst step ${i + 1}`, + { requireOthersAnimating: i % 2 === 1 }, + ); + order = result.order; + targetsHit.push(result.target); + totalWatchFrames += result.watchFrames; + step += 1; + } + + const earlyTargets = [...new Set(targetsHit.filter((t) => t !== dragged))]; + await expect( + earlyTargets.length >= 2, + `need ≥2 distinct early targets; got ${earlyTargets.join(",")}`, + ).toBe(true); + + const settleDeadline = Date.now() + CONTINUITY_DURATION + 400; + while (Date.now() < settleDeadline) { + const settledEarly = earlyTargets.filter((a) => isSettledLeaf(canvasElement, a)); + if (settledEarly.length >= Math.min(2, earlyTargets.length)) break; + await watchGap("early-settle wait", 80); + } + + pruneSettledMotions(canvasElement, motions); + + for (let i = 0; i < SETTLED_REHIT_SWAPS; i++) { + const rehitDeadline = Date.now() + CONTINUITY_DURATION + 400; + let forceTarget: string | null = null; + while (Date.now() < rehitDeadline) { + forceTarget = + earlyTargets.find((t) => { + if (!isSettledLeaf(canvasElement, t)) return false; + return applyInsertReorder(order, dragged, t).join(",") !== order.join(","); + }) ?? null; + if (forceTarget) break; + await watchGap(`re-hit wait ${i + 1}`, 80); + } + await expect( + forceTarget, + `re-hit ${i + 1}: no settled early target changes order from ${order.join(",")}`, + ).toBeTruthy(); + + await watchGap(`re-hit gap ${i + 1}`, BETWEEN_SWAP_MS); + const settledVisual = visualLeftOf(canvasElement, forceTarget!); + const settledBox = styleBoxLeftOf(canvasElement, forceTarget!); + await expect( + Math.abs(settledVisual - settledBox) < 1.5, + `re-hit ${i + 1}: ${forceTarget} not fully settled ` + + `(${settledVisual.toFixed(1)} vs ${settledBox.toFixed(1)})`, + ).toBe(true); + + const result = await runInterruptSwap( + canvasElement, + session, + dragged, + order, + slots, + motions, + step, + `re-hit settled step ${i + 1} → ${forceTarget}`, + { forceTarget: forceTarget! }, + ); + order = result.order; + totalWatchFrames += result.watchFrames; + step += 1; + } + + // Fresh mid-flight burst so the handoff starts against live FLIPs. + for (let i = 0; i < PRE_HANDOFF_BURST; i++) { + await watchGap(`pre-handoff gap ${i + 1}`, BETWEEN_SWAP_MS); + const result = await runInterruptSwap( + canvasElement, + session, + dragged, + order, + slots, + motions, + step, + `pre-handoff burst ${i + 1}`, + { requireOthersAnimating: true }, + ); + order = result.order; + totalWatchFrames += result.watchFrames; + step += 1; + } + + // Brief dense sample right before release so we catch last-frame glitches. + totalWatchFrames += await watchLeafContinuity(canvasElement, motions, "pre-release", { + durationMs: 120, + watchAllLeaves: true, + dragged, + }); + + const preReleaseVisuals = new Map(); + for (const accessor of SPOTIFY_7D_LEAVES) { + preReleaseVisuals.set(accessor, visualLeftOf(canvasElement, accessor)); + } + const animatingBeforeRelease = SPOTIFY_7D_LEAVES.filter((a) => + hasActiveFlip(canvasElement, a), + ); + await expect( + animatingBeforeRelease.length > 0, + `expected mid-FLIP headers before release; order=${order.join(",")}`, + ).toBe(true); + + // Release → grab streams immediately while prior FLIPs are still flying. + endLeafDrag(session, canvasElement); + + const stillFlyingAfterRelease = SPOTIFY_7D_LEAVES.filter((a) => + hasActiveFlip(canvasElement, a), + ); + await expect( + stillFlyingAfterRelease.length > 0, + "prior-drag FLIPs must still be mid-flight when starting the streams drag", + ).toBe(true); + + for (const accessor of animatingBeforeRelease) { + const visualNow = visualLeftOf(canvasElement, accessor); + const prev = preReleaseVisuals.get(accessor)!; + await expect( + Math.abs(visualNow - prev) < VISUAL_JUMP_PX, + `after release: ${accessor} teleported (${prev.toFixed(1)} → ${visualNow.toFixed(1)})`, + ).toBe(true); + const motion = motions.get(accessor); + if (motion) motion.visualAtSample = visualNow; + } + + await expect( + order.includes(handoffDragged), + `handoff column ${handoffDragged} missing from order`, + ).toBe(true); + + const visualsAtNewDragStart = new Map(); + for (const accessor of SPOTIFY_7D_LEAVES) { + visualsAtNewDragStart.set(accessor, visualLeftOf(canvasElement, accessor)); + const motion = motions.get(accessor); + if (motion) motion.visualAtSample = visualsAtNewDragStart.get(accessor)!; + } + + session = beginLeafDrag(canvasElement, handoffDragged); + + // dragstart must not settle leftover FLIPs from the completion drag. + const stillFlyingAfterDragStart = SPOTIFY_7D_LEAVES.filter( + (a) => a !== handoffDragged && hasActiveFlip(canvasElement, a), + ); + await expect( + stillFlyingAfterDragStart.length > 0, + "expected prior-drag FLIPs to keep flying after streams dragstart", + ).toBe(true); + + for (const accessor of stillFlyingAfterRelease) { + if (accessor === handoffDragged) continue; + const visualNow = visualLeftOf(canvasElement, accessor); + const prev = visualsAtNewDragStart.get(accessor)!; + await expect( + Math.abs(visualNow - prev) < VISUAL_JUMP_PX, + `after dragstart(${handoffDragged}): ${accessor} teleported ` + + `(${prev.toFixed(1)} → ${visualNow.toFixed(1)})`, + ).toBe(true); + } + + // Keep sampling through the handoff seam (release → new dragstart). + totalWatchFrames += await watchLeafContinuity(canvasElement, motions, "handoff seam", { + durationMs: 200, + watchAllLeaves: true, + dragged: handoffDragged, + }); + + // First swaps drop on settled siblings while prior FLIPs are mid-flight; + // later ones also re-hit settled siblings explicitly. + const handoffRoster = SPOTIFY_7D_LEAVES.filter((a) => a !== handoffDragged); + const MID_FLIGHT_HANDOFF = CONTINUITY_FAST_FEEDBACK + ? Math.max(4, handoffSwaps - 3) + : Math.max(80, handoffSwaps - 40); + for (let i = 0; i < handoffSwaps; i++) { + await watchGap(`handoff gap ${i + 1}`, BETWEEN_SWAP_MS, handoffDragged); + + let forceTarget: string | undefined; + const preferSettledRehit = i >= MID_FLIGHT_HANDOFF && i % 2 === 1; + if (preferSettledRehit) { + const rehitDeadline = Date.now() + CONTINUITY_DURATION + 300; + while (Date.now() < rehitDeadline) { + const settled = handoffRoster.find((t) => { + if (!isSettledLeaf(canvasElement, t)) return false; + return applyInsertReorder(order, handoffDragged, t).join(",") !== order.join(","); + }); + if (settled) { + forceTarget = settled; + break; + } + await watchGap(`handoff re-hit wait ${i + 1}`, 60, handoffDragged); + } + if (forceTarget) + await watchGap(`handoff re-hit gap ${i + 1}`, BETWEEN_SWAP_MS, handoffDragged); + } + if (!forceTarget) { + // Prefer a settled candidate; skip mid-FLIP leaves (ignored in production). + for (let idx = 0; idx < handoffRoster.length; idx++) { + const rotated = handoffRoster[(i + idx) % handoffRoster.length]; + if (hasActiveFlip(canvasElement, rotated)) continue; + if (applyInsertReorder(order, handoffDragged, rotated).join(",") !== order.join(",")) { + forceTarget = rotated; + break; + } + } + } + + const result = await runInterruptSwap( + canvasElement, + session, + handoffDragged, + order, + slots, + motions, + step, + `handoff step ${i + 1}/${handoffSwaps} (dragging ${handoffDragged}` + + `${i < MID_FLIGHT_HANDOFF ? ", mid-flight overlap" : ""})`, + forceTarget + ? { forceTarget } + : { requireOthersAnimating: i < MID_FLIGHT_HANDOFF }, + ); + order = result.order; + totalWatchFrames += result.watchFrames; + step += 1; + } + + endLeafDrag(session, canvasElement); + + const finalOrder = orderedLeaves(canvasElement, SPOTIFY_7D_LEAVES); + await expect(finalOrder.join(",")).toBe(order.join(",")); + + // Watch through final settle — cover distance-scaled WAAPI (up to ~2500ms). + totalWatchFrames += await watchLeafContinuity(canvasElement, motions, "final settle", { + durationMs: Math.max(CONTINUITY_DURATION, 2500) + 250, + watchAllLeaves: true, + }); + const settledDest = expectedLeftMap(order, slots); + for (const accessor of SPOTIFY_7D_LEAVES) { + // Paint/layout settle — do not require style.transform === "" yet. + // WAAPI fill:forwards can leave a stale start translate on style until + // the finished handler clears it, while getBoundingClientRect is home. + await expect( + isSettledLeaf(canvasElement, accessor), + `${accessor} not visually settled after final watch`, + ).toBe(true); + await expect( + Math.abs(styleLeftOf(canvasElement, accessor) - settledDest.get(accessor)!) < 1.5, + `${accessor} settled style.left mismatch`, + ).toBe(true); + } + + // ~8 leaves × frames; full play is dense, fast mode is a shorter sample. + const minWatchFrames = CONTINUITY_FAST_FEEDBACK ? 200 : 5_000; + await expect( + totalWatchFrames > minWatchFrames, + `expected dense sampling (>${minWatchFrames} frames); got ${totalWatchFrames}`, + ).toBe(true); + console.log( + `[continuity]${CONTINUITY_FAST_FEEDBACK ? " FAST FEEDBACK" : ""} ` + + `steps=${step} watchFrames=${totalWatchFrames} ` + + `(~${totalWatchFrames * SPOTIFY_7D_LEAVES.length} leaf samples` + + `${CONTINUITY_FAST_FEEDBACK ? "; set CONTINUITY_FAST_FEEDBACK=false for full play" : ""})`, + ); + } finally { + unfreezeScroll(); + } + }, +}; diff --git a/packages/react/src/__tests__/animationCoordinator.test.ts b/packages/react/src/__tests__/animationCoordinator.test.ts index 9629a8bba..70c48a2a6 100644 --- a/packages/react/src/__tests__/animationCoordinator.test.ts +++ b/packages/react/src/__tests__/animationCoordinator.test.ts @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; // coalescing, external-scroll distance scaling, and in-flight lifecycle. import { AnimationCoordinator } from "../../../core/src/managers/AnimationCoordinator"; import { getRenderedCells } from "../../../core/src/utils/bodyCell/eventTracking"; +import { setAbsoluteCellPosition } from "../../../core/src/utils/setAbsoluteCellPosition"; const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -22,6 +23,12 @@ const translateY = (transform: string): number => { return match ? parseFloat(match[1]) : NaN; }; +/** Pull the translateX pixel value out of a `translate3d(x, y, 0)` transform. */ +const translateX = (transform: string): number => { + const match = /translate3d\(\s*(-?[\d.]+)px/.exec(transform); + return match ? parseFloat(match[1]) : NaN; +}; + let container: HTMLElement; let coordinator: AnimationCoordinator; @@ -46,6 +53,7 @@ beforeEach(() => { }); afterEach(() => { + coordinator.setColumnReordering(false); coordinator.cancel(); // Clear the per-container rendered-cell registry between tests. getRenderedCells(container).clear(); @@ -106,16 +114,11 @@ describe("AnimationCoordinator — spam-sort coalescing", () => { cellB.style.top = "400px"; coordinator.play({ containers: [container] }); - // A was only in the stale (cancelled) chain: its inverted transform must be - // reset rather than left stranded, and it must never start a transition. - expect(cellA.style.transform).toBe(""); - expect(coordinator.isInFlight("rowA-name")).toBe(false); - // B is the latest cycle and carries the live inverse transform. expect(translateY(cellB.style.transform)).toBeCloseTo(-400, 0); // After the animation window everything settles — nothing stays in-flight. - await waitFor(() => !coordinator.isInFlight("rowB-name")); + await waitFor(() => !coordinator.hasInFlight()); expect(coordinator.isInFlight("rowA-name")).toBe(false); expect(coordinator.isInFlight("rowB-name")).toBe(false); }); @@ -137,6 +140,57 @@ describe("AnimationCoordinator — spam-sort coalescing", () => { }); }); +describe("AnimationCoordinator — column reorder mode", () => { + it("allows ColumnReorderAnimator to own paint continuity during column drag", () => { + // During column-reorder, left writes stay plain — the animator holds+tweens + // after commit from the pre-write visual snapshot. + coordinator.setColumnReordering(true); + expect(coordinator.isColumnReordering()).toBe(true); + + const cell = makeCell("col-pin", 0); + cell.style.left = "0px"; + cell.style.transform = ""; + + setAbsoluteCellPosition(cell, 120, 0); + + expect(cell.style.transform).toBe(""); + expect(cell.style.left).toBe("120px"); + }); + + it("does not settle mid-flight FLIPs when (re)entering column drag mode", async () => { + // Long duration so the handoff assertions aren't racing the safety timeout. + coordinator.setDuration(500); + + // Start with sort (non-column-reorder) mode to create an in-flight animation. + const cell = makeCell("col-c", 0); + cell.style.left = "0px"; + + coordinator.captureSnapshot({ containers: [container] }); + cell.style.left = "120px"; + coordinator.play({ containers: [container] }); + expect(translateX(cell.style.transform)).toBeCloseTo(-120, 0); + await waitFor(() => coordinator.isInFlight("col-c")); + + // Freeze a mid-slide translate (style is identity once the transition has + // started; settleInFlight would clear both transform and inFlight). + cell.style.transition = "none"; + cell.style.transform = "translate3d(-60px, 0, 0)"; + + // Mimic entering column drag mode. + coordinator.setColumnReordering(true); + // In column-reorder mode, the in-flight FLIP must be preserved so ColumnReorderAnimator + // can continue it. The frozen transform should be preserved. + expect(translateX(cell.style.transform)).toBeCloseTo(-60, 0); + expect(coordinator.isInFlight("col-c")).toBe(true); + }); + + it("turns off column reorder mode on destroy", () => { + coordinator.setColumnReordering(true); + coordinator.destroy(); + expect(coordinator.isColumnReordering()).toBe(false); + }); +}); + describe("AnimationCoordinator — onHostDiscard teardown signal", () => { it("fires the callback before permanently removing a retained ghost", () => { const discarded: HTMLElement[] = [];