From 67209c0d16887746d10acbaeb067331e1c02f0e6 Mon Sep 17 00:00:00 2001 From: XingKaiXin Date: Sat, 11 Jul 2026 21:18:23 +0800 Subject: [PATCH] perf(web): cap receipt simulation work --- .../web/src/components/InteractiveReceipt.tsx | 32 +++++++++++++++---- .../interactive-receipt-frame-policy.test.ts | 25 +++++++++++++++ .../lib/interactive-receipt-frame-policy.ts | 14 ++++++++ tests/e2e/browsing.spec.ts | 13 ++++++++ 4 files changed, 77 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/lib/interactive-receipt-frame-policy.test.ts create mode 100644 apps/web/src/lib/interactive-receipt-frame-policy.ts diff --git a/apps/web/src/components/InteractiveReceipt.tsx b/apps/web/src/components/InteractiveReceipt.tsx index 7cd5b00..f0ae2ad 100644 --- a/apps/web/src/components/InteractiveReceipt.tsx +++ b/apps/web/src/components/InteractiveReceipt.tsx @@ -1,5 +1,10 @@ import { useLayoutEffect, useMemo, useRef } from "react"; import type { SessionData } from "../lib/api"; +import { + isReceiptSettled, + nextReceiptIdleFrame, + shouldSimulateReceiptFrame, +} from "../lib/interactive-receipt-frame-policy"; import { SMART_TAG_LABELS } from "./SmartTagChips"; import type { SessionDetailToc } from "./session-detail/toc"; @@ -557,10 +562,12 @@ export function InteractiveReceipt({ let lastMetrics: SheetMetrics | null = null; let stableFrames = 0; let idleFrames = 0; + let lastSimulationTime = 0; let isVisible = false; let anchorVisible = true; let running = false; const desktopMedia = window.matchMedia(minWidthQuery); + const reducedMotionMedia = window.matchMedia("(prefers-reduced-motion: reduce)"); const shouldRun = () => desktopMedia.matches && document.visibilityState === "visible" && anchorVisible; @@ -582,7 +589,7 @@ export function InteractiveReceipt({ if (isVisible === visible) return; isVisible = visible; canvas.style.visibility = visible ? "visible" : "hidden"; - hitSurface.style.visibility = visible ? "visible" : "hidden"; + hitSurface.style.visibility = visible && !reducedMotionMedia.matches ? "visible" : "hidden"; }; const stopLoop = () => { @@ -609,6 +616,7 @@ export function InteractiveReceipt({ ctx.setTransform(ratio, 0, 0, ratio, 0, 0); stableFrames = 0; idleFrames = 0; + lastSimulationTime = 0; setVisible(false); resetSheet(getSheetMetrics()); if (shouldRun()) startLoop(); @@ -633,7 +641,7 @@ export function InteractiveReceipt({ }; const onPointerDown = (event: PointerEvent) => { - if (event.button !== 0) return; + if (event.button !== 0 || reducedMotionMedia.matches) return; const point = getPoint(event); const target = findGrabTarget(sheet.particles, point.x, point.y); if (target == null) return; @@ -787,6 +795,11 @@ export function InteractiveReceipt({ stopLoop(); return; } + if (!shouldSimulateReceiptFrame(time, lastSimulationTime)) { + animationFrame = window.requestAnimationFrame(tick); + return; + } + lastSimulationTime = time; const metrics = getSheetMetrics(); const changed = metricsChanged(lastMetrics, metrics); @@ -800,12 +813,15 @@ export function InteractiveReceipt({ } setTopRowPins(sheet.particles, metrics); - integrate(time); - constrain(); + if (!reducedMotionMedia.matches) { + integrate(time); + constrain(); + } draw(); - idleFrames = pointer.id == null ? idleFrames + 1 : 0; - if (stableFrames >= 2) setVisible(true); - if (stableFrames >= 2 && idleFrames >= 90) { + idleFrames = nextReceiptIdleFrame(pointer.id != null, idleFrames); + const isReducedMotion = reducedMotionMedia.matches; + if (isReducedMotion || stableFrames >= 2) setVisible(true); + if (isReducedMotion || isReceiptSettled(stableFrames, idleFrames)) { running = false; animationFrame = 0; return; @@ -823,6 +839,7 @@ export function InteractiveReceipt({ }); intersectionObserver.observe(anchor); desktopMedia.addEventListener("change", syncLoopState); + reducedMotionMedia.addEventListener("change", syncLoopState); document.addEventListener("visibilitychange", syncLoopState); window.addEventListener("resize", syncLoopState); hitSurface.addEventListener("pointerdown", onPointerDown); @@ -836,6 +853,7 @@ export function InteractiveReceipt({ observer.disconnect(); intersectionObserver.disconnect(); desktopMedia.removeEventListener("change", syncLoopState); + reducedMotionMedia.removeEventListener("change", syncLoopState); document.removeEventListener("visibilitychange", syncLoopState); window.removeEventListener("resize", syncLoopState); hitSurface.removeEventListener("pointerdown", onPointerDown); diff --git a/apps/web/src/lib/interactive-receipt-frame-policy.test.ts b/apps/web/src/lib/interactive-receipt-frame-policy.test.ts new file mode 100644 index 0000000..c856713 --- /dev/null +++ b/apps/web/src/lib/interactive-receipt-frame-policy.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { + RECEIPT_FRAME_INTERVAL_MS, + isReceiptSettled, + nextReceiptIdleFrame, + shouldSimulateReceiptFrame, +} from "./interactive-receipt-frame-policy"; + +describe("interactive receipt frame policy", () => { + it("caps simulation work at 60 frames per second", () => { + expect(shouldSimulateReceiptFrame(8, 1)).toBe(false); + expect(shouldSimulateReceiptFrame(RECEIPT_FRAME_INTERVAL_MS + 1, 1)).toBe(true); + }); + + it("resets idle settling while dragging", () => { + expect(nextReceiptIdleFrame(true, 20)).toBe(0); + expect(nextReceiptIdleFrame(false, 20)).toBe(21); + }); + + it("stops only after layout and motion have settled", () => { + expect(isReceiptSettled(1, 36)).toBe(false); + expect(isReceiptSettled(2, 35)).toBe(false); + expect(isReceiptSettled(2, 36)).toBe(true); + }); +}); diff --git a/apps/web/src/lib/interactive-receipt-frame-policy.ts b/apps/web/src/lib/interactive-receipt-frame-policy.ts new file mode 100644 index 0000000..45f1de8 --- /dev/null +++ b/apps/web/src/lib/interactive-receipt-frame-policy.ts @@ -0,0 +1,14 @@ +export const RECEIPT_FRAME_INTERVAL_MS = 1000 / 60; +export const RECEIPT_IDLE_SETTLE_FRAMES = 36; + +export function shouldSimulateReceiptFrame(time: number, lastFrameTime: number): boolean { + return lastFrameTime === 0 || time - lastFrameTime >= RECEIPT_FRAME_INTERVAL_MS - 1; +} + +export function nextReceiptIdleFrame(isDragging: boolean, idleFrames: number): number { + return isDragging ? 0 : idleFrames + 1; +} + +export function isReceiptSettled(stableFrames: number, idleFrames: number): boolean { + return stableFrames >= 2 && idleFrames >= RECEIPT_IDLE_SETTLE_FRAMES; +} diff --git a/tests/e2e/browsing.spec.ts b/tests/e2e/browsing.spec.ts index 4d89a7c..b236951 100644 --- a/tests/e2e/browsing.spec.ts +++ b/tests/e2e/browsing.spec.ts @@ -131,3 +131,16 @@ test("keeps detail drawers modal and restores focus", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 800 }); await expect(tocDialog).toBeHidden(); }); + +test("renders a static receipt for reduced motion", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.goto("/claudecode/e2e-dashboard"); + await page.getByRole("button", { name: "Open session receipt" }).click(); + + const receiptCanvas = page.locator('canvas[aria-hidden="true"].fixed'); + const receiptHitSurface = page.locator( + '[aria-label="Interactive thermal receipt with Verlet paper simulation"]', + ); + await expect(receiptCanvas).toBeVisible(); + await expect(receiptHitSurface).toBeHidden(); +});