fix(grid): avoid canvas resize observer loops

This commit is contained in:
Klinge authored and GitHub committed 2026-08-25 22:35:39 +08:00
1 parent 19f8736b67
commit cb581baf20
3 files changed
+110 -13

No files matched your search

+51 -13
View File
@@ -4265,7 +4265,7 @@ function cellTextOverflowsElement(text: string, target: EventTarget | null): boo
}
function measureCellTextWidth(text: string, font: string): number {
const canvas = canvasRef.value ?? document.createElement("canvas");
const canvas = activeCanvasSurface() ?? document.createElement("canvas");
const context = canvas.getContext("2d");
if (!context) return 0;
context.save();
@@ -6878,6 +6878,17 @@ function dataGridRowStyle(item: RowItem): CSSProperties {
}
const canvasRef = ref<HTMLCanvasElement>();
const canvasBackRef = ref<HTMLCanvasElement>();
// WKWebView(macOS)会为 canvas 元素滞留幽灵合成层:屏幕显示旧帧位图,2D 缓冲重绘
// (甚至改尺寸)都无法清除,只有把显示元素换掉才有效。双 canvas 交替翻转:绘制进
// 隐藏的那一张,画完移交可见性,幽灵层随旧元素的 display:none 一并废弃。
const canvasUsingBackSurface = ref(false);
function activeCanvasSurface(): HTMLCanvasElement | null {
return (canvasUsingBackSurface.value ? canvasBackRef.value : canvasRef.value) ?? null;
}
function inactiveCanvasSurface(): HTMLCanvasElement | null {
return (canvasUsingBackSurface.value ? canvasRef.value : canvasBackRef.value) ?? null;
}
const canvasOverlayRef = ref<HTMLElement>();
const canvasViewportWidth = ref(0);
const canvasViewportHeight = ref(0);
@@ -6939,6 +6950,10 @@ function dataGridSelectionScroller(): HTMLElement | null {
return canvasScrollerElement();
}
function canvasMeasurementSurface(): HTMLElement | null {
return canvasOverlayRef.value ?? null;
}
function dataGridCellFromClientPoint(clientX: number, clientY: number): { rowIndex: number; colIndex: number } | null {
const scroller = dataGridSelectionScroller();
if (!scroller) return null;
@@ -6985,9 +7000,9 @@ function syncCanvasViewport(entries?: readonly ResizeObserverEntry[]) {
if (!dataGridIsActive) return;
const scroller = canvasScrollerElement();
if (!scroller) return;
const canvas = canvasRef.value;
const canvasEntry = canvas ? entries?.find((entry) => entry.target === canvas) : undefined;
if (canvasEntry) canvasMeasuredDevicePixelSize.value = dataGridCanvasDevicePixelSize(canvasEntry);
const measurementSurface = canvasMeasurementSurface();
const surfaceEntry = measurementSurface ? entries?.find((entry) => entry.target === measurementSurface) : undefined;
if (surfaceEntry) canvasMeasuredDevicePixelSize.value = dataGridCanvasDevicePixelSize(surfaceEntry);
canvasViewportWidth.value = scroller.clientWidth;
canvasViewportHeight.value = scroller.clientHeight;
canvasScrollTop.value = scroller.scrollTop;
@@ -7048,7 +7063,7 @@ canvasRuntime = useDataGridCanvasRuntime({
draw: drawCanvasGrid,
syncViewport: syncCanvasViewport,
getViewport: canvasScrollerElement,
getSurface: () => canvasRef.value ?? null,
getSurface: canvasMeasurementSurface,
refreshPixelRatio: () => {
const next = currentCanvasDevicePixelRatio();
if (Math.abs(next - canvasDevicePixelRatio.value) > 0.001) {
@@ -7074,7 +7089,7 @@ function canvasColumnAt(contentX: number): number {
}
function canvasHitTest(event: MouseEvent): { rowIndex: number; visibleColIdx: number; rowNumber: boolean } | null {
const canvas = canvasRef.value;
const canvas = activeCanvasSurface();
const scroller = canvasScrollerElement();
if (!canvas || !scroller) return null;
const rect = canvas.getBoundingClientRect();
@@ -7189,7 +7204,8 @@ function onDomGridWheel(event: WheelEvent) {
function onCanvasMouseMove(event: MouseEvent) {
stopReleasedSelectionGesture(event);
if (columnHeaderPointerInteractionActive()) {
if (canvasRef.value) canvasRef.value.style.cursor = "default";
const cursorSurface = activeCanvasSurface();
if (cursorSurface) cursorSurface.style.cursor = "default";
onCanvasMouseLeave();
return;
}
@@ -7203,10 +7219,11 @@ function onCanvasMouseMove(event: MouseEvent) {
}
: null;
const actualColIdx = next ? visibleColumnIndexes.value[next.visibleColIdx] : undefined;
if (canvasRef.value) {
const cursorSurface = activeCanvasSurface();
if (cursorSurface) {
const overBooleanInteractive =
booleanCellsUseCheckbox.value && hit != null && !hit.rowNumber && hitItem != null && actualColIdx !== undefined && isBooleanGridCell(hitItem, actualColIdx) && canEditCellItem(hitItem, actualColIdx) && booleanInteractiveHitFromCanvasEvent(hitItem, hit, actualColIdx, event);
canvasRef.value.style.cursor = hit?.rowNumber ? "default" : overBooleanInteractive ? "pointer" : hitItem && actualColIdx !== undefined ? "text" : "cell";
cursorSurface.style.cursor = hit?.rowNumber ? "default" : overBooleanInteractive ? "pointer" : hitItem && actualColIdx !== undefined ? "text" : "cell";
}
if (next?.rowIndex === canvasHoverCell.value?.rowIndex && next?.visibleColIdx === canvasHoverCell.value?.visibleColIdx) {
return;
@@ -7246,7 +7263,7 @@ function keepCanvasDetailHover() {
function clearCanvasDetailHover(event?: MouseEvent) {
const relatedTarget = event?.relatedTarget;
if (relatedTarget instanceof Node && (canvasOverlayRef.value?.contains(relatedTarget) || canvasRef.value?.contains(relatedTarget))) {
if (relatedTarget instanceof Node && (canvasOverlayRef.value?.contains(relatedTarget) || activeCanvasSurface()?.contains(relatedTarget) || canvasBackRef.value?.contains(relatedTarget))) {
return;
}
onCanvasMouseLeave();
@@ -7254,7 +7271,7 @@ function clearCanvasDetailHover(event?: MouseEvent) {
function booleanCheckboxHitFromCanvasEvent(item: RowItem, hit: { rowIndex: number; visibleColIdx: number }, actualColIdx: number, event: MouseEvent): boolean {
if (item.data[actualColIdx] === null) return false;
const canvas = canvasRef.value;
const canvas = activeCanvasSurface();
const canvasRect = canvas?.getBoundingClientRect();
const cellRect = canvasCellViewportRect(hit.rowIndex, hit.visibleColIdx);
if (!canvasRect || !cellRect) return false;
@@ -7271,7 +7288,7 @@ function booleanCheckboxHitFromCanvasEvent(item: RowItem, hit: { rowIndex: numbe
function booleanNullTextHitFromCanvasEvent(item: RowItem, hit: { rowIndex: number; visibleColIdx: number }, actualColIdx: number, event: MouseEvent): boolean {
if (item.data[actualColIdx] !== null) return false;
const canvas = canvasRef.value;
const canvas = activeCanvasSurface();
const canvasRect = canvas?.getBoundingClientRect();
const cellRect = canvasCellViewportRect(hit.rowIndex, hit.visibleColIdx);
if (!canvasRect || !cellRect) return false;
@@ -7527,7 +7544,7 @@ const canvasRightAlignedActionCell = computed(() => {
});
function drawCanvasGrid() {
const canvas = canvasRef.value;
const canvas = inactiveCanvasSurface();
const scroller = canvasScrollerElement();
if (!canvas || !scroller || !useCanvasGridRows.value) return;
@@ -7571,6 +7588,12 @@ function drawCanvasGrid() {
booleanDisplayMode: booleanDisplayMode.value,
flatteningMultiLineEnabled: flatteningMultiLineEnabled.value,
});
flipCanvasSurface();
}
function flipCanvasSurface() {
// 双 canvas 翻转:刚绘制的帧离开前景层,幽灵层随隐藏一并废弃,新显画布接到下一帧
canvasUsingBackSurface.value = !canvasUsingBackSurface.value;
}
watch(
@@ -12933,6 +12956,21 @@ function currentGridContextMenuItems(): ContextMenuItem[] {
:style="{
width: `${canvasSurfaceWidth}px`,
height: `${canvasViewportHeight}px`,
display: canvasUsingBackSurface ? 'none' : '',
}"
@mousemove="onCanvasMouseMove"
@mouseleave="onCanvasMouseLeave"
@mousedown="onCanvasMouseDown"
@contextmenu="onCanvasContext"
@dblclick="onCanvasDblClick"
/>
<canvas
ref="canvasBackRef"
class="canvas-grid-surface dbx-data-grid-font-family sticky left-0 top-0 z-0 block font-normal"
:style="{
width: `${canvasSurfaceWidth}px`,
height: `${canvasViewportHeight}px`,
display: canvasUsingBackSurface ? '' : 'none',
}"
@mousemove="onCanvasMouseMove"
@mouseleave="onCanvasMouseLeave"
@@ -133,6 +133,16 @@ beforeEach(() => {
vi.clearAllMocks();
localStorage.removeItem("dbx-filter-builder-value-shortcut-hint-days");
});
describe("DataGrid canvas surfaces", () => {
it("uses the stable overlay for viewport and device-pixel measurement", () => {
expect(dataGridSource).toContain("function canvasMeasurementSurface(): HTMLElement | null");
expect(dataGridSource).toContain("return canvasOverlayRef.value ?? null;");
expect(dataGridSource).toContain("const measurementSurface = canvasMeasurementSurface();");
expect(dataGridSource).toContain("getSurface: canvasMeasurementSurface,");
expect(dataGridSource).toContain("const canvas = inactiveCanvasSurface();");
expect(dataGridSource).toContain("canvasUsingBackSurface.value = !canvasUsingBackSurface.value;");
});
});
describe("DataGridSearchBar", () => {
it("focuses/selects the input and forwards keyboard, navigation, and suggestion interactions", async () => {
@@ -53,6 +53,55 @@ describe("data grid visual runtimes", () => {
expect(resize.observe).toHaveBeenNthCalledWith(2, surface, { box: "device-pixel-content-box" });
runtime.dispose();
});
it("keeps viewport observation on a stable measurement surface while render surfaces flip", () => {
const viewport = { children: [] } as unknown as Element;
const measurementSurface = { children: [] } as unknown as Element;
const frontSurface = { children: [] } as unknown as Element;
const backSurface = { children: [] } as unknown as Element;
const observe = vi.fn();
const disconnect = vi.fn();
const syncViewport = vi.fn();
let activeSurface = frontSurface;
let observerCallback: ResizeObserverCallback | null = null;
let observer: ResizeObserver | null = null;
const runtime = useDataGridCanvasRuntime({
draw: () => {
activeSurface = activeSurface === frontSurface ? backSurface : frontSurface;
},
syncViewport,
getViewport: () => viewport,
getSurface: () => measurementSurface,
createResizeObserver: (callback) => {
observerCallback = callback;
observer = { observe, disconnect } as unknown as ResizeObserver;
return observer;
},
});
runtime.observeViewport();
runtime.drawNow();
runtime.drawNow();
expect(activeSurface).toBe(frontSurface);
expect(observe.mock.calls[0]?.[0]).toBe(viewport);
expect(observe.mock.calls[1]?.[0]).toBe(measurementSurface);
expect(observe.mock.calls[1]?.[1]).toEqual({ box: "device-pixel-content-box" });
expect(observe.mock.calls.map(([target]) => target)).not.toContain(frontSurface);
expect(observe.mock.calls.map(([target]) => target)).not.toContain(backSurface);
expect(syncViewport).toHaveBeenCalledOnce();
const entry = {
target: measurementSurface,
contentRect: { width: 640, height: 320 },
devicePixelContentBoxSize: [{ inlineSize: 1280, blockSize: 640 }],
} as unknown as ResizeObserverEntry;
observerCallback?.([entry], observer as ResizeObserver);
expect(syncViewport).toHaveBeenCalledTimes(2);
expect(syncViewport).toHaveBeenLastCalledWith([entry]);
expect(dataGridCanvasDevicePixelSize(entry)).toEqual({ cssWidth: 640, cssHeight: 320, pixelWidth: 1280, pixelHeight: 640 });
runtime.dispose();
});
it("coalesces canvas frames and cancels all resources on dispose", () => {
const frames = createFrameDriver();