Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/marketing/src/app/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
69 changes: 45 additions & 24 deletions packages/core/src/core/SimpleTableVanilla.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,33 +353,23 @@ export class SimpleTableVanilla<TData extends RowData = Row> {
}

/**
* 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[] = [];
Expand All @@ -393,6 +383,7 @@ export class SimpleTableVanilla<TData extends RowData = Row> {
}
};
for (const header of headers) walk(header, undefined);
parts.sort();
return parts.join("|");
}

Expand All @@ -414,10 +405,22 @@ export class SimpleTableVanilla<TData extends RowData = Row> {
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;
Expand All @@ -426,8 +429,9 @@ export class SimpleTableVanilla<TData extends RowData = Row> {
// 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,
});
}
Expand Down Expand Up @@ -1344,6 +1348,7 @@ export class SimpleTableVanilla<TData extends RowData = Row> {
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
Expand Down Expand Up @@ -1381,6 +1386,14 @@ export class SimpleTableVanilla<TData extends RowData = Row> {
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();
}
Expand Down Expand Up @@ -1685,8 +1698,12 @@ export class SimpleTableVanilla<TData extends RowData = Row> {
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();
Expand Down Expand Up @@ -1723,11 +1740,15 @@ export class SimpleTableVanilla<TData extends RowData = Row> {
// 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();
Expand Down
38 changes: 35 additions & 3 deletions packages/core/src/core/rendering/RenderOrchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}

Expand All @@ -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
) {
Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -535,6 +552,7 @@ export class RenderOrchestrator {
maxHeaderDepth,
flattenResult,
processedResult,
headersUnchangedForScrollBailout,
};
}

Expand Down Expand Up @@ -570,13 +588,16 @@ export class RenderOrchestrator {
maxHeaderDepth,
flattenResult,
processedResult,
headersUnchangedForScrollBailout,
} = snapshot;
this.lastProcessedResult = processedResult;

const verticalScrollFastPath = context.positionOnlyBody === true;

if (
verticalScrollFastPath &&
!context.columnDragging &&
headersUnchangedForScrollBailout &&
this.lastScrollRafPaintedRange !== null &&
processedResult.renderedStartIndex === this.lastScrollRafPaintedRange.start &&
processedResult.renderedEndIndex === this.lastScrollRafPaintedRange.end
Expand Down Expand Up @@ -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);
Expand Down
96 changes: 90 additions & 6 deletions packages/core/src/core/rendering/SectionRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Accessor> = 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 {
Expand Down Expand Up @@ -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 &&
Expand All @@ -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) {
Expand All @@ -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<string, { left: number; width: number; leafIndex: number }>();
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<number, number>();
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;
Expand All @@ -1286,6 +1369,7 @@ export class SectionRenderer {
cells,
deps: {
headersHash,
headersStructureHash,
rowsRef: bandSlice,
collapsedHeadersSize: collapsedHeaders.size,
rowHeight,
Expand Down
Loading
Loading