From c7deabc36076c03e2d242e0c591d9fe1c1055c09 Mon Sep 17 00:00:00 2001 From: chh-ay Date: Mon, 27 Jul 2026 12:49:48 +0700 Subject: [PATCH] fix(core): repaint when device pixel ratio changes --- .changeset/calm-displays-repaint.md | 5 + packages/core/src/grid.ts | 26 ++ packages/core/test/device-pixel-ratio.test.ts | 308 ++++++++++++++++++ test/browser/vanilla-workbench.spec.ts | 126 +++++++ 4 files changed, 465 insertions(+) create mode 100644 .changeset/calm-displays-repaint.md create mode 100644 packages/core/test/device-pixel-ratio.test.ts diff --git a/.changeset/calm-displays-repaint.md b/.changeset/calm-displays-repaint.md new file mode 100644 index 00000000..3b97e488 --- /dev/null +++ b/.changeset/calm-displays-repaint.md @@ -0,0 +1,5 @@ +--- +"@sheetwrite/core": patch +--- + +Re-rasterize the grid when a window moves between displays with different pixel densities instead of remaining blurry or oversized until the next interaction. diff --git a/packages/core/src/grid.ts b/packages/core/src/grid.ts index 1401704e..ce51a300 100644 --- a/packages/core/src/grid.ts +++ b/packages/core/src/grid.ts @@ -460,6 +460,14 @@ export class GridImpl implements Grid { committedPatches: 0, }; private resizeObserver: ResizeObserver | null = null; + private resolutionMediaQuery: MediaQueryList | null = null; + private readonly onResolutionChange = (): void => { + if (this.destroyed) return; + this.armResolutionListener(); + this.syncSizer(); + this.renderCoordinator.invalidate(); + this.scheduleRender(); + }; private readonly onScroll = () => this.scheduleRender(); private readonly disposeStore: () => void; @@ -968,6 +976,7 @@ export class GridImpl implements Grid { this.resizeObserver = new ResizeObserver(() => this.onResize()); this.resizeObserver.observe(host); } + this.armResolutionListener(); this.render(); } @@ -3057,6 +3066,21 @@ export class GridImpl implements Grid { this.render(); } + /** + * A resolution query stops being useful after it changes, so replace it with + * one matching the current display. This catches DPR-only changes that do not + * resize the host and therefore never reach ResizeObserver. + */ + private armResolutionListener(): void { + this.resolutionMediaQuery?.removeEventListener("change", this.onResolutionChange); + this.resolutionMediaQuery = null; + if (typeof globalThis.matchMedia !== "function") return; + + const devicePixelRatio = globalThis.devicePixelRatio ?? 1; + this.resolutionMediaQuery = globalThis.matchMedia(`(resolution: ${devicePixelRatio}dppx)`); + this.resolutionMediaQuery.addEventListener("change", this.onResolutionChange); + } + private onResize(): void { this.syncSizer(); this.render(); @@ -3076,6 +3100,8 @@ export class GridImpl implements Grid { this.scroller.removeEventListener("scroll", this.onScroll); this.scroller.removeEventListener("contextmenu", this.onContextMenu); this.resizeObserver?.disconnect(); + this.resolutionMediaQuery?.removeEventListener("change", this.onResolutionChange); + this.resolutionMediaQuery = null; this.document.destroy(); this.disposeStore(); // Free the WASM CellStore only when we constructed it. A caller-provided diff --git a/packages/core/test/device-pixel-ratio.test.ts b/packages/core/test/device-pixel-ratio.test.ts new file mode 100644 index 00000000..998923f9 --- /dev/null +++ b/packages/core/test/device-pixel-ratio.test.ts @@ -0,0 +1,308 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { GridImpl } from "../src/grid.js"; +import { installCanvasTestStubs } from "../src/testing.js"; +import type { CellScalar, Store, Workbook } from "../src/types.js"; +import { makeWorkbook } from "./fixtures.js"; + +class ResolutionQuery extends EventTarget { + readonly media: string; + matches = true; + + constructor(media: string) { + super(); + this.media = media; + } + + change(matches: boolean): void { + this.matches = matches; + this.dispatchEvent(new Event("change")); + } + + listenerCount = 0; + + override addEventListener(...args: Parameters): void { + if (args[0] === "change") this.listenerCount += 1; + super.addEventListener(...args); + } + + override removeEventListener(...args: Parameters): void { + if (args[0] === "change") this.listenerCount -= 1; + super.removeEventListener(...args); + } +} + +function makeStore(workbook: Workbook): Store { + return { + getWorkbook: () => workbook, + getCell: () => { + throw new Error("getCell must not run during painting"); + }, + getFormula: () => null, + getSpillAnchor: () => null, + getRefTarget: () => null, + recalculateVolatile: () => {}, + getVisibleWindow: (sheet, rows, cols) => { + const size = Math.max(0, (rows.end - rows.start) * cols.length); + const values: CellScalar[] = new Array(size).fill(null); + return { sheet, rows, cols, values, styleIds: new Uint32Array(size), styles: [{}] }; + }, + ensureColumns: () => {}, + applyTransaction: () => ({ status: "noop", epoch: 0, reason: "empty" }), + on: () => () => {}, + viewRowCount: (sheet) => workbook.sheets.find((entry) => entry.id === sheet)?.rowCount ?? 0, + }; +} + +function mountHost(): HTMLDivElement { + const host = document.createElement("div"); + Object.defineProperty(host, "clientWidth", { value: 800, configurable: true }); + Object.defineProperty(host, "clientHeight", { value: 400, configurable: true }); + document.body.appendChild(host); + return host; +} + +async function flushScheduledFrame(frames: Map): Promise { + expect(frames.size).toBe(1); + const entry = frames.entries().next().value; + if (!entry) throw new Error("expected a scheduled animation frame"); + frames.delete(entry[0]); + entry[1](0); + await Promise.resolve(); +} + +describe("device-pixel-ratio lifecycle", () => { + let restoreCanvas: () => void; + let originalDpr: PropertyDescriptor | undefined; + let originalMatchMedia: typeof globalThis.matchMedia | undefined; + let originalClientWidth: PropertyDescriptor | undefined; + let originalClientHeight: PropertyDescriptor | undefined; + let originalRequestAnimationFrame: PropertyDescriptor | undefined; + let originalCancelAnimationFrame: PropertyDescriptor | undefined; + let scheduledFrames: Map; + let dpr: number; + let queries: ResolutionQuery[]; + + beforeEach(() => { + restoreCanvas = installCanvasTestStubs(); + originalDpr = Object.getOwnPropertyDescriptor(globalThis, "devicePixelRatio"); + originalMatchMedia = globalThis.matchMedia; + originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientWidth"); + originalClientHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientHeight"); + originalRequestAnimationFrame = Object.getOwnPropertyDescriptor( + globalThis, + "requestAnimationFrame", + ); + originalCancelAnimationFrame = Object.getOwnPropertyDescriptor( + globalThis, + "cancelAnimationFrame", + ); + dpr = 1; + queries = []; + scheduledFrames = new Map(); + let nextFrame = 1; + + Object.defineProperty(globalThis, "devicePixelRatio", { + configurable: true, + get: () => dpr, + }); + Object.defineProperty(globalThis, "matchMedia", { + configurable: true, + value: (media: string) => { + const query = new ResolutionQuery(media); + queries.push(query); + return query as unknown as MediaQueryList; + }, + }); + Object.defineProperty(globalThis, "requestAnimationFrame", { + configurable: true, + value: (callback: FrameRequestCallback): number => { + const frame = nextFrame++; + scheduledFrames.set(frame, callback); + return frame; + }, + }); + Object.defineProperty(globalThis, "cancelAnimationFrame", { + configurable: true, + value: (frame: number): void => { + scheduledFrames.delete(frame); + }, + }); + Object.defineProperty(HTMLElement.prototype, "clientWidth", { + configurable: true, + get() { + return (this as HTMLElement).classList.contains("sheetwrite-viewport") ? 800 : 0; + }, + }); + Object.defineProperty(HTMLElement.prototype, "clientHeight", { + configurable: true, + get() { + return (this as HTMLElement).classList.contains("sheetwrite-viewport") ? 372 : 0; + }, + }); + }); + + afterEach(() => { + restoreCanvas(); + if (originalDpr) Object.defineProperty(globalThis, "devicePixelRatio", originalDpr); + else Reflect.deleteProperty(globalThis, "devicePixelRatio"); + if (originalMatchMedia) { + Object.defineProperty(globalThis, "matchMedia", { + configurable: true, + value: originalMatchMedia, + }); + } else { + Reflect.deleteProperty(globalThis, "matchMedia"); + } + if (originalRequestAnimationFrame) { + Object.defineProperty(globalThis, "requestAnimationFrame", originalRequestAnimationFrame); + } else { + Reflect.deleteProperty(globalThis, "requestAnimationFrame"); + } + if (originalCancelAnimationFrame) { + Object.defineProperty(globalThis, "cancelAnimationFrame", originalCancelAnimationFrame); + } else { + Reflect.deleteProperty(globalThis, "cancelAnimationFrame"); + } + if (originalClientWidth) { + Object.defineProperty(HTMLElement.prototype, "clientWidth", originalClientWidth); + } else Reflect.deleteProperty(HTMLElement.prototype, "clientWidth"); + if (originalClientHeight) { + Object.defineProperty(HTMLElement.prototype, "clientHeight", originalClientHeight); + } else Reflect.deleteProperty(HTMLElement.prototype, "clientHeight"); + document.body.replaceChildren(); + }); + + it("rearms its resolution query, repaints, and detaches on destroy", async () => { + const workbook = makeWorkbook(20); + const host = mountHost(); + const grid = new GridImpl(host, { workbook }, makeStore(workbook)); + const canvas = host.querySelector("canvas"); + + expect(canvas).toBeInstanceOf(HTMLCanvasElement); + expect(queries.map((query) => query.media)).toEqual(["(resolution: 1dppx)"]); + expect(queries[0]?.listenerCount).toBe(1); + expect(canvas?.width).toBe(800); + + dpr = 2; + queries[0]?.change(false); + + expect(queries.map((query) => query.media)).toEqual([ + "(resolution: 1dppx)", + "(resolution: 2dppx)", + ]); + expect(queries[0]?.listenerCount).toBe(0); + expect(queries[1]?.listenerCount).toBe(1); + expect(canvas?.width).toBe(800); + await flushScheduledFrame(scheduledFrames); + expect(canvas?.width).toBe(1_600); + + dpr = 1; + queries[1]?.change(false); + expect(queries.map((query) => query.media)).toEqual([ + "(resolution: 1dppx)", + "(resolution: 2dppx)", + "(resolution: 1dppx)", + ]); + expect(canvas?.width).toBe(1_600); + await flushScheduledFrame(scheduledFrames); + expect(canvas?.width).toBe(800); + + grid.destroy(); + expect(queries[2]?.listenerCount).toBe(0); + + dpr = 2; + queries[2]?.change(false); + expect(queries).toHaveLength(3); + expect(scheduledFrames.size).toBe(0); + + for (let cycle = 0; cycle < 3; cycle++) { + const cycleWorkbook = makeWorkbook(20); + const cycleGrid = new GridImpl( + mountHost(), + { workbook: cycleWorkbook }, + makeStore(cycleWorkbook), + ); + expect(queries.reduce((count, query) => count + query.listenerCount, 0)).toBe(1); + cycleGrid.destroy(); + expect(queries.reduce((count, query) => count + query.listenerCount, 0)).toBe(0); + } + }); + + it("posts the new DPR to the Worker viewport before repainting", async () => { + const workerDescriptor = Object.getOwnPropertyDescriptor(globalThis, "Worker"); + const transferDescriptor = Object.getOwnPropertyDescriptor( + HTMLCanvasElement.prototype, + "transferControlToOffscreen", + ); + let instance: RecordingWorker | null = null; + + class RecordingWorker extends EventTarget { + readonly messages: unknown[] = []; + + constructor() { + super(); + instance = this; + } + + postMessage(message: unknown): void { + this.messages.push(message); + } + + terminate(): void {} + } + + Object.defineProperty(globalThis, "Worker", { + configurable: true, + value: RecordingWorker, + }); + Object.defineProperty(HTMLCanvasElement.prototype, "transferControlToOffscreen", { + configurable: true, + value: () => ({}), + }); + + let grid: GridImpl | undefined; + try { + const workbook = makeWorkbook(20); + grid = new GridImpl( + mountHost(), + { workbook, renderer: "worker", workerUrl: "/worker.js" }, + makeStore(workbook), + ); + const worker = instance as RecordingWorker | null; + if (!worker) throw new Error("expected the Worker renderer to construct a worker"); + const viewportDprs = (): number[] => + worker.messages.flatMap((message) => + message !== null && + typeof message === "object" && + "type" in message && + message.type === "viewport" && + "dpr" in message && + typeof message.dpr === "number" + ? [message.dpr] + : [], + ); + + expect(grid.rendererKind()).toBe("worker"); + expect(viewportDprs()).toEqual([1]); + + dpr = 2; + queries[0]?.change(false); + expect(viewportDprs()).toEqual([1]); + await flushScheduledFrame(scheduledFrames); + expect(viewportDprs()).toEqual([1, 2]); + } finally { + grid?.destroy(); + if (workerDescriptor) Object.defineProperty(globalThis, "Worker", workerDescriptor); + else Reflect.deleteProperty(globalThis, "Worker"); + if (transferDescriptor) { + Object.defineProperty( + HTMLCanvasElement.prototype, + "transferControlToOffscreen", + transferDescriptor, + ); + } else { + Reflect.deleteProperty(HTMLCanvasElement.prototype, "transferControlToOffscreen"); + } + } + }); +}); diff --git a/test/browser/vanilla-workbench.spec.ts b/test/browser/vanilla-workbench.spec.ts index aff184fd..fe8c8dd6 100644 --- a/test/browser/vanilla-workbench.spec.ts +++ b/test/browser/vanilla-workbench.spec.ts @@ -299,6 +299,132 @@ test("renderer selection is construction-bound and deep-linked", { expect(errors.console).toEqual([]); }); +test("DPR-only changes repaint both main-thread and Worker canvases", { + tag: "@portability", +}, async ({ page }) => { + const errors = collectErrors(page); + await page.addInitScript(() => { + const nativeMatchMedia = window.matchMedia.bind(window); + const resolutionQueries = new Set(); + let dpr = 1; + + Object.defineProperty(window, "devicePixelRatio", { + configurable: true, + get: () => dpr, + }); + window.matchMedia = ((media: string): MediaQueryList => { + if (!media.startsWith("(resolution: ")) return nativeMatchMedia(media); + + const target = new EventTarget(); + Object.defineProperties(target, { + matches: { + configurable: true, + get: () => media === `(resolution: ${dpr}dppx)`, + }, + media: { configurable: true, value: media }, + onchange: { configurable: true, writable: true, value: null }, + }); + Object.assign(target, { + addListener(listener: EventListener): void { + target.addEventListener("change", listener); + }, + removeListener(listener: EventListener): void { + target.removeEventListener("change", listener); + }, + }); + const query = target as MediaQueryList; + resolutionQueries.add(query); + return query; + }) as typeof window.matchMedia; + Object.defineProperty(window, "__sheetwriteSetDpr", { + configurable: true, + value: (next: number): void => { + dpr = next; + for (const query of Array.from(resolutionQueries)) { + if (!query.matches) query.dispatchEvent(new Event("change")); + } + }, + }); + }); + + await page.goto(VANILLA_URL); + await waitForLive(page); + const canvas = page.locator(CANVAS); + const backingScale = () => + canvas.evaluate((element) => { + const node = element as HTMLCanvasElement; + return node.width / node.getBoundingClientRect().width; + }); + await expect.poll(backingScale).toBeCloseTo(1, 1); + + await page.evaluate(() => { + ( + window as typeof window & { + __sheetwriteSetDpr: (next: number) => void; + } + ).__sheetwriteSetDpr(2); + }); + await expect.poll(backingScale).toBeCloseTo(2, 1); + await expect.poll(() => canvasBodyPainted(page)).toBe(true); + + await page.evaluate(() => { + ( + window as typeof window & { + __sheetwriteSetDpr: (next: number) => void; + } + ).__sheetwriteSetDpr(1); + }); + await expect.poll(backingScale).toBeCloseTo(1, 1); + + await page.getByRole("radio", { name: "Web Worker" }).click(); + await expect(page.getByTestId("renderer")).toContainText( + "Requested: Web Worker ยท Active: Web Worker", + { timeout: 20_000 }, + ); + await expect(page.getByTestId("renderer")).toHaveAttribute("data-fallback-count", "0"); + await expect + .poll(async () => Number((await canvas.getAttribute("data-worker-frame")) ?? 0), { + timeout: 20_000, + message: "Worker never acknowledged its initial frame", + }) + .toBeGreaterThan(0); + const firstWorkerFrame = Number((await canvas.getAttribute("data-worker-frame")) ?? 0); + + await page.evaluate(() => { + ( + window as typeof window & { + __sheetwriteSetDpr: (next: number) => void; + } + ).__sheetwriteSetDpr(2); + }); + await expect + .poll(async () => Number((await canvas.getAttribute("data-worker-frame")) ?? 0), { + timeout: 20_000, + message: "Worker did not repaint after the DPR-only change", + }) + .toBeGreaterThan(firstWorkerFrame); + await expect.poll(() => canvasBodyPainted(page)).toBe(true); + + const secondWorkerFrame = Number((await canvas.getAttribute("data-worker-frame")) ?? 0); + await page.evaluate(() => { + ( + window as typeof window & { + __sheetwriteSetDpr: (next: number) => void; + } + ).__sheetwriteSetDpr(1); + }); + await expect + .poll(async () => Number((await canvas.getAttribute("data-worker-frame")) ?? 0), { + timeout: 20_000, + message: "Worker did not repaint after the rearmed DPR query changed", + }) + .toBeGreaterThan(secondWorkerFrame); + + expect(errors.page).toEqual([]); + expect(errors.worker).toEqual([]); + expect(errors.console).toEqual([]); +}); + test("worker repaint keeps a cached non-shared view painted after a sub-row scroll", { tag: "@portability", }, async ({ page }) => {