PERF: desktop Calendar week pinch-zoom and fling run at 10 fps (layout-heavy long tasks) #498

Open
opened 2026-09-30 08:04:43 +00:00 by kayg · 3 comments
Owner

Problem

On desktop, pinch-zoom and horizontal fling in the Calendar week view run at about 10 fps with layout-heavy long tasks. The phone view of the same week is smooth.

Found by the #367 perf round (perf-test VM, release build of dev at 369ab6a2f, apps/web/e2e/calendar-perf.mjs, 200 Log entries + 100 photos in one week).

Measurements (1440×900, mouse, 4× CPU throttle like the other browser probes)

Gesture fps frame p50 / p95 / p99 long tasks > 50 ms longest main thread per s (script / layout / style)
Pinch zoom (Ctrl + wheel) 10.1 83 / 217 / 267 ms 55 220 ms 24 / 65 / 41 ms
Horizontal fling 10.3 67 / 250 / 317 ms 20 243 ms 135 / 103 / 80 ms
Vertical scroll 33.1 50 / 117 / 117 ms 0 — 3 / 0.3 / 8 ms
Baseline: plain boxes fling 105.9 17 / 33 ms 0 — —

Phone (390×844, touch): pinch 32.5 fps (2 long tasks), fling 60 fps, vertical scroll 112.7 fps. DOM: 21 columns, 200 blocks, 89 piles on desktop.

The plain-box baseline flings at 106 fps on the same VM, so the cost is in the Calendar grid, not the host.

Suggested direction (not decided)

Profile the zoom and fling handlers with a Chrome trace; look for per-frame layout of all 200 blocks/89 piles (forced layout, pile re-grouping on every zoom step) and move zoom to a transform with a settled re-layout. DESIGN §18 asks for 60 fps scrolling.

Raw data: docs/perf/runs/2026-09-29T224501Z-369ab6a2/raw/calendar-grid.json.

## Problem On desktop, pinch-zoom and horizontal fling in the Calendar week view run at about 10 fps with layout-heavy long tasks. The phone view of the same week is smooth. Found by the #367 perf round (perf-test VM, release build of `dev` at `369ab6a2f`, `apps/web/e2e/calendar-perf.mjs`, 200 Log entries + 100 photos in one week). ## Measurements (1440×900, mouse, 4× CPU throttle like the other browser probes) | Gesture | fps | frame p50 / p95 / p99 | long tasks > 50 ms | longest | main thread per s (script / layout / style) | |---|---:|---:|---:|---:|---:| | Pinch zoom (Ctrl + wheel) | 10.1 | 83 / 217 / 267 ms | 55 | 220 ms | 24 / 65 / 41 ms | | Horizontal fling | 10.3 | 67 / 250 / 317 ms | 20 | 243 ms | 135 / 103 / 80 ms | | Vertical scroll | 33.1 | 50 / 117 / 117 ms | 0 | — | 3 / 0.3 / 8 ms | | Baseline: plain boxes fling | 105.9 | 17 / 33 ms | 0 | — | — | Phone (390×844, touch): pinch 32.5 fps (2 long tasks), fling 60 fps, vertical scroll 112.7 fps. DOM: 21 columns, 200 blocks, 89 piles on desktop. The plain-box baseline flings at 106 fps on the same VM, so the cost is in the Calendar grid, not the host. ## Suggested direction (not decided) Profile the zoom and fling handlers with a Chrome trace; look for per-frame layout of all 200 blocks/89 piles (forced layout, pile re-grouping on every zoom step) and move zoom to a transform with a settled re-layout. DESIGN §18 asks for 60 fps scrolling. Raw data: `docs/perf/runs/2026-09-29T224501Z-369ab6a2/raw/calendar-grid.json`.
Author
Owner

Starting fix-498 on branch job/fix-498, based at 6c87f5ff94 (dev merge base at job start).

Starting fix-498 on branch job/fix-498, based at 6c87f5ff9442cd658572139bc536d018fd5222a4 (dev merge base at job start).
Author
Owner

Finding: TimeGrid.writeZoom reads grid.offsetTop through contentTop() after the prior frame writes --hour, so the next frame can force layout. onWheel also reads getBoundingClientRect for every Ctrl+wheel input. The fling path derives its rendered day window from the live first-visible index, so crossing a day replaces keyed GridColumn instances and re-runs onrange. The archived 1440x900/4x run measured zoom at 65.1 ms/s layout and fling at 134.6 ms/s script plus 102.9 ms/s layout (docs/perf/runs/2026-09-29T224501Z-369ab6a2/raw/calendar-grid.json).

Finding: TimeGrid.writeZoom reads grid.offsetTop through contentTop() after the prior frame writes --hour, so the next frame can force layout. onWheel also reads getBoundingClientRect for every Ctrl+wheel input. The fling path derives its rendered day window from the live first-visible index, so crossing a day replaces keyed GridColumn instances and re-runs onrange. The archived 1440x900/4x run measured zoom at 65.1 ms/s layout and fling at 134.6 ms/s script plus 102.9 ms/s layout (docs/perf/runs/2026-09-29T224501Z-369ab6a2/raw/calendar-grid.json).
Author
Owner

Fix report — Forgejo #498

Branch: job/fix-498
Base: 15e17aeafc8ea160c109e62fba57f6961c39d21f
Head: b48f984e79dd9ad2e6332b8ec52afde1e23d9c4e

What changed

  • Cached the Calendar grid geometry before input. Ctrl/Cmd-wheel zoom no longer reads a box after a zoom style write.
  • Pinch and wheel zoom now preview with scaleY() transforms. One settled --hour write restores the time under the pointer and clears the preview.
  • Kept keyed day columns mounted while the visible days remain inside the two-day edge buffer. A fling no longer remounts them at every date boundary.
  • Applied content-visibility: auto to off-screen columns and fixed timed-item/pile subtrees. The fixed item boxes keep their hit areas and accessible semantics.
  • Added regression coverage for transform-only zoom frames, geometry reads, and buffered-window shifts. No expected test behavior changed.

Performance

Archived perf-VM baseline (2026-09-29, host load [0.8, 2.4, 2.8]; same Calendar grid harness):

  • Zoom: 10.1 fps; frame p50/p95/p99 83.2/216.7/266.8 ms; 55 long tasks; longest 220 ms.
  • Fling: 10.3 fps; frame p50/p95/p99 66.6/250/316.6 ms; 20 long tasks; longest 243 ms.

The after run could not use the perf VM: flock -n /root/perf.lock returned 1 while another run held the lock. I did not measure without the lock.

Local diagnostic after run (host calternal-dev, load [19.8, 19.7, 23.2]; 1440×900, 4× CPU throttle):

  • Zoom: 3.6 fps; p50/p95/p99 233.4/583.4/833.3 ms; 115 long tasks; longest 769 ms; forced style/layout 0 ms; Chromium CPU mean/peak 169.65/231.93%; summed RSS mean/peak 724,315,958/759,300,096 bytes.
  • Fling: 3.1 fps; p50/p95/p99 183.4/1533.3/2233.2 ms; 13 long tasks; longest 2252 ms; forced style/layout 0 ms; Chromium CPU mean/peak 168.37/481.29%; summed RSS mean/peak 745,828,147/768,679,936 bytes.
  • The same run’s plain-box baseline was 10.8 fps for zoom and 42.9 fps for fling. Local load was high, so these numbers do not verify the 60 fps perf-VM target and are not comparable with the archived VM baseline.

The local before/after captures also ran at very different host loads. Before ([8.7, 16.7, 23.3]): zoom 2.2 fps / p95 1283.3 ms, fling 0.8 fps / p95 9566.3 ms. After ([25.5, 30.2, 28.5]): zoom 1.6 fps / p95 1666.6 ms, fling 0.9 fps / p95 12882.8 ms. Treat this pair as diagnostic only, not a performance comparison.

Screenshots and frame strips

24 production screenshots cover Week zoom out/default/in and Day at 1440, 820 and 390 CSS px in Paper and Tokyo Night. PNGs are 1× at 1440, 2× at 820, and 2× at 390. Six SVG strips show before/after pinch zoom, horizontal fling and vertical scroll.

Frame strips:

Raw profiles:

Gates

bun run check:

$ node scripts/check-type-tokens.mjs && node scripts/check-motion-tokens.mjs && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json
Text sizes and UI shape values use shared role tokens.
UI transitions and animation options use shared motion tokens or documented exceptions.
Loading svelte-check in workspace: /home/kayg/Developer/calternal-wt/fix-498/apps/web
Getting Svelte diagnostics...

svelte-check found 0 errors and 0 warnings

bun run test summary:

 Test Files  140 passed (140)
      Tests  916 passed (916)
   Start at  03:11:41
   Duration  115.35s (transform 55%, environment 16%, import 16%, tests 10%, setup 4%)

Environment  |component| jsdom was created 43 times · 99.55s total, 25% of tracked time
             create it once per worker with pool: 'vmThreads' (keeps per-file isolation) or isolate: false (shares it across files)
             learn more: https://vitest.dev/guide/improving-performance#test-environments

bun run build completed successfully. node --check apps/web/e2e/calendar-perf.mjs, bash -n bench/run.sh, and git diff --check exited 0. No Rust crate changed, so Rust gates were not run.

Decisions not specified by DESIGN §39

  • Use a 280 ms input-rest interval before the one final layout commit.
  • Keep two buffered dates at each edge and rebuild that window only when the visible range reaches that threshold.
  • Use content-visibility: auto for off-screen fixed rows and pile content while leaving their positioned boxes in place.

Known gap: the 60 fps target remains unverified on the perf VM because its required lock was busy.

# Fix report — Forgejo #498 Branch: `job/fix-498` Base: `15e17aeafc8ea160c109e62fba57f6961c39d21f` Head: `b48f984e79dd9ad2e6332b8ec52afde1e23d9c4e` ## What changed - Cached the Calendar grid geometry before input. Ctrl/Cmd-wheel zoom no longer reads a box after a zoom style write. - Pinch and wheel zoom now preview with `scaleY()` transforms. One settled `--hour` write restores the time under the pointer and clears the preview. - Kept keyed day columns mounted while the visible days remain inside the two-day edge buffer. A fling no longer remounts them at every date boundary. - Applied `content-visibility: auto` to off-screen columns and fixed timed-item/pile subtrees. The fixed item boxes keep their hit areas and accessible semantics. - Added regression coverage for transform-only zoom frames, geometry reads, and buffered-window shifts. No expected test behavior changed. ## Performance Archived perf-VM baseline (2026-09-29, host load `[0.8, 2.4, 2.8]`; same Calendar grid harness): - Zoom: 10.1 fps; frame p50/p95/p99 83.2/216.7/266.8 ms; 55 long tasks; longest 220 ms. - Fling: 10.3 fps; frame p50/p95/p99 66.6/250/316.6 ms; 20 long tasks; longest 243 ms. The after run could not use the perf VM: `flock -n /root/perf.lock` returned 1 while another run held the lock. I did not measure without the lock. Local diagnostic after run (host `calternal-dev`, load `[19.8, 19.7, 23.2]`; 1440×900, 4× CPU throttle): - Zoom: 3.6 fps; p50/p95/p99 233.4/583.4/833.3 ms; 115 long tasks; longest 769 ms; forced style/layout 0 ms; Chromium CPU mean/peak 169.65/231.93%; summed RSS mean/peak 724,315,958/759,300,096 bytes. - Fling: 3.1 fps; p50/p95/p99 183.4/1533.3/2233.2 ms; 13 long tasks; longest 2252 ms; forced style/layout 0 ms; Chromium CPU mean/peak 168.37/481.29%; summed RSS mean/peak 745,828,147/768,679,936 bytes. - The same run’s plain-box baseline was 10.8 fps for zoom and 42.9 fps for fling. Local load was high, so these numbers do not verify the 60 fps perf-VM target and are not comparable with the archived VM baseline. The local before/after captures also ran at very different host loads. Before (`[8.7, 16.7, 23.3]`): zoom 2.2 fps / p95 1283.3 ms, fling 0.8 fps / p95 9566.3 ms. After (`[25.5, 30.2, 28.5]`): zoom 1.6 fps / p95 1666.6 ms, fling 0.9 fps / p95 12882.8 ms. Treat this pair as diagnostic only, not a performance comparison. ## Screenshots and frame strips 24 production screenshots cover Week zoom out/default/in and Day at 1440, 820 and 390 CSS px in Paper and Tokyo Night. PNGs are 1× at 1440, 2× at 820, and 2× at 390. Six SVG strips show before/after pinch zoom, horizontal fling and vertical scroll. - **Week zoom out:** [1440 Paper](https://git.kayg.org/attachments/fd147b38-4e56-4a4b-b6a6-c3a009cd08cc), [1440 Tokyo Night](https://git.kayg.org/attachments/ce2bd9e6-fe00-4bb9-8242-99f5d0a8ca11), [820 Paper](https://git.kayg.org/attachments/30f69c3d-e69a-4ba2-86a7-41467cb3c51d), [820 Tokyo Night](https://git.kayg.org/attachments/8c7d2956-136d-4d2c-9c48-3be1e9a8861a), [390 Paper](https://git.kayg.org/attachments/bf189c38-9a5b-4fe9-8c41-e4e3913b4c9c), [390 Tokyo Night](https://git.kayg.org/attachments/7522d244-2a99-40b1-956f-634b0e9e9925) - **Week zoom default:** [1440 Paper](https://git.kayg.org/attachments/a27495b2-bf0d-4bc2-903a-3d3af5f7650a), [1440 Tokyo Night](https://git.kayg.org/attachments/b02b3c35-61d3-4c82-bcf2-3f38a364a001), [820 Paper](https://git.kayg.org/attachments/a692cea9-b81b-4744-b2bc-207fa6e0264a), [820 Tokyo Night](https://git.kayg.org/attachments/1a422d95-8c7d-4a8e-a0c2-e45d25b339ca), [390 Paper](https://git.kayg.org/attachments/a2c92347-c55c-412b-be6a-755db9d69199), [390 Tokyo Night](https://git.kayg.org/attachments/f3ea4491-46fd-4868-8b80-d814b8adeefa) - **Week zoom in:** [1440 Paper](https://git.kayg.org/attachments/1209cb05-69e3-4e84-ac89-cdabfb9783ed), [1440 Tokyo Night](https://git.kayg.org/attachments/c52209af-4d68-4187-9be0-cc3b1290047e), [820 Paper](https://git.kayg.org/attachments/3a65cbf4-af2a-49bd-b60c-e3e2043fd5b9), [820 Tokyo Night](https://git.kayg.org/attachments/71017eec-c16f-4d5a-bba5-a1d7119d3e47), [390 Paper](https://git.kayg.org/attachments/42b65feb-8e4c-4fd2-8ef5-1973760d7097), [390 Tokyo Night](https://git.kayg.org/attachments/9b417864-d08f-4df2-9581-c91d78618a4a) - **Day:** [1440 Paper](https://git.kayg.org/attachments/79c2a7eb-1bc8-4612-9f1b-7ef6ac18bbe7), [1440 Tokyo Night](https://git.kayg.org/attachments/1138ee77-cd04-40dc-b607-f700f92f0c1d), [820 Paper](https://git.kayg.org/attachments/183c270d-1095-455a-ae2e-aa94513d75f3), [820 Tokyo Night](https://git.kayg.org/attachments/16dda4fd-d3ab-4da1-8bc1-ba7fbfb47bd8), [390 Paper](https://git.kayg.org/attachments/3bda8b70-270b-4cf1-94bb-826aac20fc14), [390 Tokyo Night](https://git.kayg.org/attachments/1d3a8422-6173-4b0d-8344-741744ed3932) **Frame strips:** - [Before: pinch zoom](https://git.kayg.org/attachments/a8eb2409-2bd5-425c-8ced-f4929f25277a) - [After: pinch zoom](https://git.kayg.org/attachments/a8814a30-efd7-4d37-920c-c4b6d00b8b24) - [Before: horizontal fling](https://git.kayg.org/attachments/13154ca9-80cd-46f7-9058-fe5bd7449242) - [After: horizontal fling](https://git.kayg.org/attachments/16901e79-3dc1-4630-a3bc-ea17c7d78dde) - [Before: vertical scroll](https://git.kayg.org/attachments/431f4b4a-48c7-49ba-b40a-72289d7d3ed5) - [After: vertical scroll](https://git.kayg.org/attachments/b17e3f00-e7e0-48d2-9377-71e5d3e59397) **Raw profiles:** - [Perf-VM baseline JSON](https://git.kayg.org/attachments/33fb7bbf-5db3-4e6b-b1a9-565185120b63) - [Local after profile with CPU/RSS](https://git.kayg.org/attachments/7f177278-f4ae-40d0-9958-2b0f5add184e) - [Local before profile](https://git.kayg.org/attachments/0a6ae597-8c5d-41bf-9d0f-dae13aca95ac) - [Local after profile under higher load](https://git.kayg.org/attachments/e2fa2c4b-aedc-44e2-b519-a6662483c115) ## Gates `bun run check`: ```text $ node scripts/check-type-tokens.mjs && node scripts/check-motion-tokens.mjs && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json Text sizes and UI shape values use shared role tokens. UI transitions and animation options use shared motion tokens or documented exceptions. Loading svelte-check in workspace: /home/kayg/Developer/calternal-wt/fix-498/apps/web Getting Svelte diagnostics... svelte-check found 0 errors and 0 warnings ``` `bun run test` summary: ```text Test Files 140 passed (140) Tests 916 passed (916) Start at 03:11:41 Duration 115.35s (transform 55%, environment 16%, import 16%, tests 10%, setup 4%) Environment |component| jsdom was created 43 times · 99.55s total, 25% of tracked time create it once per worker with pool: 'vmThreads' (keeps per-file isolation) or isolate: false (shares it across files) learn more: https://vitest.dev/guide/improving-performance#test-environments ``` `bun run build` completed successfully. `node --check apps/web/e2e/calendar-perf.mjs`, `bash -n bench/run.sh`, and `git diff --check` exited 0. No Rust crate changed, so Rust gates were not run. ## Decisions not specified by DESIGN §39 - Use a 280 ms input-rest interval before the one final layout commit. - Keep two buffered dates at each edge and rebuild that window only when the visible range reaches that threshold. - Use `content-visibility: auto` for off-screen fixed rows and pile content while leaving their positioned boxes in place. Known gap: the 60 fps target remains unverified on the perf VM because its required lock was busy.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
kayg/calternal#498
No description provided.