Notifications: read and unread menu actions exceed 150 ms #1387

Closed
opened 2026-10-07 17:44:48 +00:00 by kayg · 27 comments
Owner

Evidence

The #1369 Release-build audit used a real input path on the perf VM (macOS browser profile, 1440 × 900, five samples, fixture of 12 Notifications). Mark as unread from the row menu took p50 369.7 ms and p95/max 635.3 ms. Mark as read took p50 384.0 ms and p95/max 484.2 ms. DESIGN §59 sets a 150 ms budget for an accepted action.

The unread samples recorded one 98 ms long task. The read samples recorded three long tasks from 52 ms to 91 ms. The critical path had zero or one Notifications API request per sample; read state is patched before the request in inbox.svelte.ts:markRead and markUnread. The action handler is InboxPanel.svelte:runMenu.

Request

Profile the Notifications row menu and remove the work that delays the accepted state. Keep the optimistic update and cross-view consistency. Record five samples for keyboard and pointer input, and keep the accepted read/unread state within 150 ms.

This is separate from #1363, which covers deleting a Notification and its Undo row. Audit: #1369.

## Evidence The #1369 Release-build audit used a real input path on the perf VM (macOS browser profile, 1440 × 900, five samples, fixture of 12 Notifications). Mark as unread from the row menu took p50 369.7 ms and p95/max 635.3 ms. Mark as read took p50 384.0 ms and p95/max 484.2 ms. DESIGN §59 sets a 150 ms budget for an accepted action. The unread samples recorded one 98 ms long task. The read samples recorded three long tasks from 52 ms to 91 ms. The critical path had zero or one Notifications API request per sample; read state is patched before the request in `inbox.svelte.ts:markRead` and `markUnread`. The action handler is `InboxPanel.svelte:runMenu`. ## Request Profile the Notifications row menu and remove the work that delays the accepted state. Keep the optimistic update and cross-view consistency. Record five samples for keyboard and pointer input, and keep the accepted read/unread state within 150 ms. This is separate from #1363, which covers deleting a Notification and its Undo row. Audit: #1369.
Author
Owner

Starting #1387 on branch job/notifread-1387, based at d395b6e25da20d8c67b8b9fc1a593b813939c8a7 (current branch tip; working tree clean). I am tracing the server write path and client inbox refresh/render path before optimizing the accepted read/unread action.

Starting #1387 on branch `job/notifread-1387`, based at `d395b6e25da20d8c67b8b9fc1a593b813939c8a7` (current branch tip; working tree clean). I am tracing the server write path and client inbox refresh/render path before optimizing the accepted read/unread action.
Author
Owner

Finding with code evidence: successful mark-read and mark-unread paths each issue one API request and do not refetch the inbox. The client then replaces items with a full .map() copy in #patch; the panel derives and sorts grouped rows from that array, so one row toggle invalidates work for every loaded Notification. At 500 rows this is the likely client-side long task. The server uses one UPDATE by the Notifications primary key plus User predicate; mark-unread runs its existence check only when the update reports no affected row. I will preserve that bounded write path and measure it as part of the before/after profile. Failure currently renders inline actionError; #1387 requires rollback plus an error toast, so I will route only failed toggles through the shared transient toast.

Finding with code evidence: successful mark-read and mark-unread paths each issue one API request and do not refetch the inbox. The client then replaces `items` with a full `.map()` copy in `#patch`; the panel derives and sorts grouped rows from that array, so one row toggle invalidates work for every loaded Notification. At 500 rows this is the likely client-side long task. The server uses one `UPDATE` by the Notifications primary key plus User predicate; mark-unread runs its existence check only when the update reports no affected row. I will preserve that bounded write path and measure it as part of the before/after profile. Failure currently renders inline `actionError`; #1387 requires rollback plus an error toast, so I will route only failed toggles through the shared transient toast.
Author
Owner

The first full web test run found one real regression in the shared toast policy: the stale-item (404) reconciliation toast had no action. I am adding a useful refresh action so every actionless toast remains explicitly classified; the focused inbox tests themselves passed.

The first full web test run found one real regression in the shared toast policy: the stale-item (404) reconciliation toast had no action. I am adding a useful refresh action so every actionless toast remains explicitly classified; the focused inbox tests themselves passed.
Author
Owner

Correction after tracing the shared toast store: actionless notifications are already dropped at runtime. The failure was the missing explicit policy on the stale-row (404) call, not a missing Refresh action. I kept the existing no-visible-toast behavior, added policy: 'drop', and kept the screen reader announcement. The full run had already evaluated the older source, so I will verify the corrected call site with the final suite.

Correction after tracing the shared toast store: actionless notifications are already dropped at runtime. The failure was the missing explicit policy on the stale-row (404) call, not a missing Refresh action. I kept the existing no-visible-toast behavior, added `policy: 'drop'`, and kept the screen reader announcement. The full run had already evaluated the older source, so I will verify the corrected call site with the final suite.
Author
Owner

Perf setup finding: the first locked server attempt exited before readiness because the benchmark launched the shared release binary without the media sandbox runtime (NotFound). I verified mediaSandboxEnv() prepares the runtime on the build host (local process limit 1875), then updated the profile to archive and copy that runtime with the binary and set its root, PATH and limit on perf-test. No build ran on perf-test; the baseline is queued for a fresh locked attempt.

Perf setup finding: the first locked server attempt exited before readiness because the benchmark launched the shared release binary without the media sandbox runtime (`NotFound`). I verified `mediaSandboxEnv()` prepares the runtime on the build host (local process limit 1875), then updated the profile to archive and copy that runtime with the binary and set its root, PATH and limit on perf-test. No build ran on perf-test; the baseline is queued for a fresh locked attempt.
Author
Owner

Second setup finding: the staged local runtime's calternal-media-sandbox was an absolute symlink back into this worktree. The first copy retained that link, so the remote server still could not launch it. The profile archive now dereferences runtime links; I verified its launcher and decoder entries are regular executables before retrying.

Second setup finding: the staged local runtime's `calternal-media-sandbox` was an absolute symlink back into this worktree. The first copy retained that link, so the remote server still could not launch it. The profile archive now dereferences runtime links; I verified its launcher and decoder entries are regular executables before retrying.
Author
Owner

One more runtime correction from the second attempt: dereferencing every symlink copied the build host's vips binaries, which are not the perf VM's decoders. I changed staging to replace only the worktree-relative launcher with a regular file and preserve vips/vipsheader links to perf-test's /usr/bin. The local archive check confirms that shape; no latency measurements were recorded in the failed attempt.

One more runtime correction from the second attempt: dereferencing every symlink copied the build host's `vips` binaries, which are not the perf VM's decoders. I changed staging to replace only the worktree-relative launcher with a regular file and preserve `vips`/`vipsheader` links to perf-test's `/usr/bin`. The local archive check confirms that shape; no latency measurements were recorded in the failed attempt.
Author
Owner

The server still returned Error: NotFound after the decoder links were corrected. The cause is now confirmed in calternal-fs::Root::open_split: both instance and User data roots must already exist. The profile created only its parent directory. It now creates /data and /home before launch; this is a benchmark harness fix, with no server code change.

The server still returned `Error: NotFound` after the decoder links were corrected. The cause is now confirmed in `calternal-fs::Root::open_split`: both instance and User data roots must already exist. The profile created only its parent directory. It now creates `/data` and `/home` before launch; this is a benchmark harness fix, with no server code change.
Author
Owner

With both storage roots created, the baseline server started and the 12-row inbox loaded. The first pointer action changed an item but the benchmark did not collect both its next-frame and API clocks within 10 seconds. I added a sanitized timeout snapshot (input observed, row state, request count/status) to identify which clock is missing; this failed attempt recorded no latency numbers.

With both storage roots created, the baseline server started and the 12-row inbox loaded. The first pointer action changed an item but the benchmark did not collect both its next-frame and API clocks within 10 seconds. I added a sanitized timeout snapshot (input observed, row state, request count/status) to identify which clock is missing; this failed attempt recorded no latency numbers.
Author
Owner

The diagnostic separated the clocks: the row changed in 119.3 ms, but the benchmark's page-level window.fetch wrapper saw no request. api-client uses its own transport path, so that probe was not authoritative. The profile now records Requests and Responses through Playwright and reads the matching browser Resource Timing entry; it still asserts one write and no inbox GET.

The diagnostic separated the clocks: the row changed in 119.3 ms, but the benchmark's page-level `window.fetch` wrapper saw no request. `api-client` uses its own transport path, so that probe was not authoritative. The profile now records Requests and Responses through Playwright and reads the matching browser Resource Timing entry; it still asserts one write and no inbox GET.
Author
Owner

Before-profile finding: one 500-row keyboard toggle on the unoptimized baseline showed its expected row state after 804 ms. This is a single observation, not a percentile. The failed profile also captured a GET of the inbox during the action window. The Notifications store polls every 60 seconds; the profile had counted this independent freshness poll as a toggle request and stopped before it wrote summary percentiles. I updated the profile to require exactly one PUT/DELETE read-state write per toggle and report overlapping inbox reads separately. node --check bench/notifications-read-1387.mjs and git diff --check passed. Change committed as 89cb199a3.

Before-profile finding: one 500-row keyboard toggle on the unoptimized baseline showed its expected row state after 804 ms. This is a single observation, not a percentile. The failed profile also captured a GET of the inbox during the action window. The Notifications store polls every 60 seconds; the profile had counted this independent freshness poll as a toggle request and stopped before it wrote summary percentiles. I updated the profile to require exactly one PUT/DELETE read-state write per toggle and report overlapping inbox reads separately. `node --check bench/notifications-read-1387.mjs` and `git diff --check` passed. Change committed as 89cb199a3.
Author
Owner

The full before profile completed on shared release server/app revision af202e151 (4× CPU throttle, 20 toggles per mode, real inbox data). Input-to-visible p50/p95: 12 rows pointer 147.6/182.1 ms, keyboard 175.3/296.0 ms; 500 rows pointer 826.2/1052.3 ms, keyboard 613.7/995.0 ms. The read-state write request p50 stayed 84.6–91.1 ms and p95 102.9–122.7 ms. This points to client list work as the dominant 500-row delay; the successful server path already uses one User-and-ID-scoped UPDATE, and the toggle does not call the inbox list endpoint. One periodic freshness poll overlapped the 500-row pointer set and is recorded separately. The saved profile is target/tmp/notifications-read-1387-before.json; after-profile is queued on perf-run.

The full before profile completed on shared release server/app revision af202e151 (4× CPU throttle, 20 toggles per mode, real inbox data). Input-to-visible p50/p95: 12 rows pointer 147.6/182.1 ms, keyboard 175.3/296.0 ms; 500 rows pointer 826.2/1052.3 ms, keyboard 613.7/995.0 ms. The read-state write request p50 stayed 84.6–91.1 ms and p95 102.9–122.7 ms. This points to client list work as the dominant 500-row delay; the successful server path already uses one User-and-ID-scoped UPDATE, and the toggle does not call the inbox list endpoint. One periodic freshness poll overlapped the 500-row pointer set and is recorded separately. The saved profile is target/tmp/notifications-read-1387-before.json; after-profile is queued on perf-run.
Author
Owner

The first optimized profile still missed the 150 ms target at 500 rows: visible-state p50/p95 were 342.6/671.3 ms (pointer) and 436.0/665.2 ms (keyboard). The remaining work was rebuilding/sorting derived grouped rows even though read state does not affect their order. Commit 3e1d095b adds a per-panel grouping cache keyed by item/Undo array identity and local day boundary; a focused regression test confirms read-state changes retain group identity while structural changes refresh it. bun run check passes with 0 perf-lint violations; 25 focused tests pass. The final after profile and clean screenshot capture are queued on perf-run.

The first optimized profile still missed the 150 ms target at 500 rows: visible-state p50/p95 were 342.6/671.3 ms (pointer) and 436.0/665.2 ms (keyboard). The remaining work was rebuilding/sorting derived grouped rows even though read state does not affect their order. Commit 3e1d095b adds a per-panel grouping cache keyed by item/Undo array identity and local day boundary; a focused regression test confirms read-state changes retain group identity while structural changes refresh it. `bun run check` passes with 0 perf-lint violations; 25 focused tests pass. The final after profile and clean screenshot capture are queued on perf-run.
Author
Owner

The final #1387 after profile still misses §59 at 500 loaded rows: pointer visible-state p50/p95 733.1/915.9 ms; keyboard 562.3/848.7 ms. The matching write-request p50 was 83.1 ms and 87.2 ms, so the server write is not the dominant delay. The browser recorded 25 long tasks across 20 pointer toggles and 22 across 20 keyboard toggles. All nine Mac-platform evidence captures succeeded at 390, 820 and 1440 px in light/dark, including post-click and shortcut-open states. I am profiling the remaining main-thread work before choosing the next client-side fix.

The final #1387 after profile still misses §59 at 500 loaded rows: pointer visible-state p50/p95 733.1/915.9 ms; keyboard 562.3/848.7 ms. The matching write-request p50 was 83.1 ms and 87.2 ms, so the server write is not the dominant delay. The browser recorded 25 long tasks across 20 pointer toggles and 22 across 20 keyboard toggles. All nine Mac-platform evidence captures succeeded at 390, 820 and 1440 px in light/dark, including post-click and shortcut-open states. I am profiling the remaining main-thread work before choosing the next client-side fix.
Author
Owner

Benchmark audit finding: the page CPU profile spent substantial time in browser visibility and selector work during Playwright locator-based clicks (checkVisibility: 7.48 s; querySelectorAll: 1.95 s of 64.28 s sampled). The same run measured 666 style recalculations and 156 layouts, while the read-state endpoint remained a direct user-scoped UPDATE. To avoid charging full-DOM locator actionability scans to the product clock, commit ab4b8a486 now resolves the visible menu action before timing and dispatches the pointer click by CDP coordinates. I am taking paired before/after profiles with this path; the earlier profiles remain recorded separately as the initial baseline and first implementation result.

Benchmark audit finding: the page CPU profile spent substantial time in browser visibility and selector work during Playwright locator-based clicks (`checkVisibility`: 7.48 s; `querySelectorAll`: 1.95 s of 64.28 s sampled). The same run measured 666 style recalculations and 156 layouts, while the read-state endpoint remained a direct user-scoped UPDATE. To avoid charging full-DOM locator actionability scans to the product clock, commit `ab4b8a486` now resolves the visible menu action before timing and dispatches the pointer click by CDP coordinates. I am taking paired before/after profiles with this path; the earlier profiles remain recorded separately as the initial baseline and first implementation result.
Author
Owner

Corrected-baseline finding: with the same 20-toggle sequence, the base app passed the 12-row pointer mode, then failed the existing even-toggle assertion after the 12-row keyboard mode: the row ended read (false) instead of unread (true). Each toggle had already observed its expected optimistic row state and a successful single write. The baseline profile therefore stopped before writing its summary. This is consistent with the base implementation's full-inbox refresh racing the toggle; that cause is an inference, while the final state mismatch is observed. The original complete before-profile remains at docs/perf/runs/notifications-read-1387-before.json. The optimized after-profile is queued with the corrected CDP pointer path.

Corrected-baseline finding: with the same 20-toggle sequence, the base app passed the 12-row pointer mode, then failed the existing even-toggle assertion after the 12-row keyboard mode: the row ended `read` (`false`) instead of unread (`true`). Each toggle had already observed its expected optimistic row state and a successful single write. The baseline profile therefore stopped before writing its summary. This is consistent with the base implementation's full-inbox refresh racing the toggle; that cause is an inference, while the final state mismatch is observed. The original complete before-profile remains at `docs/perf/runs/notifications-read-1387-before.json`. The optimized after-profile is queued with the corrected CDP pointer path.
Author
Owner

#1387 implementation report

Branch: job/notifread-1387
Base: af202e1514955ddf0f8b4b3d2503c780d807ed84
Head: cfc06d4d3a42ed0058487efaaf43431f423f774b

Built

Read and unread now patch the selected item proxy and unread count synchronously. A successful toggle sends one PUT or DELETE and does not refetch the inbox. A generation fence prevents an older poll from replacing a newer local state. Failures restore the exact prior read timestamp and count, then show the Retry toast. A stale 404 removes the row and keeps the screen-reader announcement.

The Notifications store already uses a direct User-and-ID-scoped SQL UPDATE. Unread uses a SELECT EXISTS only when the row was not updated, so no server change was needed. The panel’s row design was not changed.

Performance

Profiles used 20 toggles per mode, 12 and 500 real API-backed notifications, macOS platform emulation, 4× CPU throttle, and the shared release server. Before and after use the same Playwright locator click path. Values are input-to-visible p50/p95 milliseconds:

Rows Input Before After
12 Pointer 147.6 / 182.1 138.1 / 189.3
12 Keyboard 175.3 / 296.0 164.9 / 249.7
500 Pointer 826.2 / 1052.3 733.1 / 915.9
500 Keyboard 613.7 / 995.0 562.3 / 848.7

The read-state API p50/p95 stayed around 83–90 / 104–134 ms. Each toggle sent one write; the single inbox GET seen in each 500-row pointer mode was an independent 60-second poll. Lock load averages were 2.55/2.48/2.21 at before acquisition and 1.33/1.57/1.86 at completion; after was 2.11/2.64/3.22 and 2.16/1.78/2.42.

The 500-row result still misses the 150 ms target. A CPU diagnostic found browser visibility and selector scans during locator clicks (checkVisibility sampled 7.48 s and querySelectorAll 1.95 s of 64.28 s), plus 666 style recalculations and 156 layouts. A corrected CDP-coordinate reprofile was queued, but the shared perf lock remained held past the timebox, so it was stopped. Do not compare that unrun profile with the recorded locator-based pair. The base retry also ended with a read row after its 20-toggle keyboard mode despite each action observing its expected optimistic state and one successful write; the suspected poll race is an inference. The original complete before-profile is preserved.

Gates

bun run check:

PASS production dependency licences: 759 locked package releases across apps/web, apps/docs and packages
perf-lint: PASS; 0 violations; 22544 scoped exceptions
User browser caches use userStorage; only documented device/public-link exceptions remain.
Glass alpha, blur and backdrop-filter roles use packages/ui/src/tokens.css.
Text sizes and UI shape values use shared role tokens.
Keyboard focus rings use the shared focus tokens.
UI transitions and animation options use shared motion tokens or documented exceptions.
svelte-check found 0 errors and 4 warnings in 2 files

bun run test:

 Test Files  297 passed (297)
      Tests  2126 passed (2126)
   Start at  18:33:19
   Duration  630.16s (import 27%, transform 27%, environment 26%, tests 14%, setup 5%)

No Rust files changed, so no Rust crate gates ran. cargo clean completed; web build outputs were removed.

UX gaps and decisions

Closed: same-frame optimistic state and badge update, one write per toggle, no toggle-triggered inbox GET, exact failure rollback with Retry, and stale-row announcement/removal.

Left: 500-row p95 remains above 150 ms. The keyboard-open menu screenshot also shows a focus ring on its initial action; this is existing shared menu focus behavior and was not changed in this data-path job.

Decision: keep the existing panel appearance and optimize its data path; leave the already-direct server UPDATE unchanged.

Screenshots

Actual-size Mac-platform captures with loaded real API rows (desktop, 820 px tablet and 390 px phone; light and dark):

Files

apps/web/src/lib/notifications/inbox.svelte.ts, inbox.svelte.test.ts, model.ts, model.test.ts, InboxPanel.svelte, bench/notifications-read-1387.mjs, contracts/perf/adoption-1058.json, contracts/perf/exceptions.json, docs/perf/baseline.json, and docs/perf/runs/notifications-read-1387-{before,after}.json.

#1387 implementation report **Branch:** `job/notifread-1387` **Base:** `af202e1514955ddf0f8b4b3d2503c780d807ed84` **Head:** `cfc06d4d3a42ed0058487efaaf43431f423f774b` ## Built Read and unread now patch the selected item proxy and unread count synchronously. A successful toggle sends one PUT or DELETE and does not refetch the inbox. A generation fence prevents an older poll from replacing a newer local state. Failures restore the exact prior read timestamp and count, then show the Retry toast. A stale 404 removes the row and keeps the screen-reader announcement. The Notifications store already uses a direct User-and-ID-scoped SQL UPDATE. Unread uses a SELECT EXISTS only when the row was not updated, so no server change was needed. The panel’s row design was not changed. ## Performance Profiles used 20 toggles per mode, 12 and 500 real API-backed notifications, macOS platform emulation, 4× CPU throttle, and the shared release server. Before and after use the same Playwright locator click path. Values are input-to-visible p50/p95 milliseconds: | Rows | Input | Before | After | |---:|---|---:|---:| | 12 | Pointer | 147.6 / 182.1 | 138.1 / 189.3 | | 12 | Keyboard | 175.3 / 296.0 | 164.9 / 249.7 | | 500 | Pointer | 826.2 / 1052.3 | 733.1 / 915.9 | | 500 | Keyboard | 613.7 / 995.0 | 562.3 / 848.7 | The read-state API p50/p95 stayed around 83–90 / 104–134 ms. Each toggle sent one write; the single inbox GET seen in each 500-row pointer mode was an independent 60-second poll. Lock load averages were 2.55/2.48/2.21 at before acquisition and 1.33/1.57/1.86 at completion; after was 2.11/2.64/3.22 and 2.16/1.78/2.42. The 500-row result still misses the 150 ms target. A CPU diagnostic found browser visibility and selector scans during locator clicks (`checkVisibility` sampled 7.48 s and `querySelectorAll` 1.95 s of 64.28 s), plus 666 style recalculations and 156 layouts. A corrected CDP-coordinate reprofile was queued, but the shared perf lock remained held past the timebox, so it was stopped. Do not compare that unrun profile with the recorded locator-based pair. The base retry also ended with a read row after its 20-toggle keyboard mode despite each action observing its expected optimistic state and one successful write; the suspected poll race is an inference. The original complete before-profile is preserved. ## Gates `bun run check`: ``` PASS production dependency licences: 759 locked package releases across apps/web, apps/docs and packages perf-lint: PASS; 0 violations; 22544 scoped exceptions User browser caches use userStorage; only documented device/public-link exceptions remain. Glass alpha, blur and backdrop-filter roles use packages/ui/src/tokens.css. Text sizes and UI shape values use shared role tokens. Keyboard focus rings use the shared focus tokens. UI transitions and animation options use shared motion tokens or documented exceptions. svelte-check found 0 errors and 4 warnings in 2 files ``` `bun run test`: ``` Test Files 297 passed (297) Tests 2126 passed (2126) Start at 18:33:19 Duration 630.16s (import 27%, transform 27%, environment 26%, tests 14%, setup 5%) ``` No Rust files changed, so no Rust crate gates ran. `cargo clean` completed; web build outputs were removed. ## UX gaps and decisions Closed: same-frame optimistic state and badge update, one write per toggle, no toggle-triggered inbox GET, exact failure rollback with Retry, and stale-row announcement/removal. Left: 500-row p95 remains above 150 ms. The keyboard-open menu screenshot also shows a focus ring on its initial action; this is existing shared menu focus behavior and was not changed in this data-path job. Decision: keep the existing panel appearance and optimize its data path; leave the already-direct server UPDATE unchanged. ## Screenshots Actual-size Mac-platform captures with loaded real API rows (desktop, 820 px tablet and 390 px phone; light and dark): - [Desktop light, after pointer click](https://git.kayg.org/attachments/fc9c4a7c-3bdf-4e5d-a97f-ca265f94dca0) - [Desktop light, pointer menu](https://git.kayg.org/attachments/59ae5797-d6ec-422d-ba03-c07cdc813f92) - [Desktop light, shortcut menu](https://git.kayg.org/attachments/5ad97ed2-0f05-4866-ab18-78275324c86f) - [390 px light, pointer failure and rollback](https://git.kayg.org/attachments/8ef78e6c-5023-4c75-85ca-454236013d32) - [820 px light, keyboard failure and rollback](https://git.kayg.org/attachments/95b667a3-e4dc-4a6c-bf31-640156c373f5) - [1440 px light, keyboard failure and rollback](https://git.kayg.org/attachments/5449e7e9-41b3-406b-b3f8-1b8d1fdc40ec) - [390 px dark, keyboard failure and rollback](https://git.kayg.org/attachments/251cc9ae-7e0f-4609-af5b-ed8a24d41a23) - [820 px dark, pointer failure and rollback](https://git.kayg.org/attachments/d4e6e222-d30b-45e0-94b9-f4e82524c03f) - [1440 px dark, pointer failure and rollback](https://git.kayg.org/attachments/02a0ba1c-a99f-4db0-b6f4-bd2695548e86) ## Files `apps/web/src/lib/notifications/inbox.svelte.ts`, `inbox.svelte.test.ts`, `model.ts`, `model.test.ts`, `InboxPanel.svelte`, `bench/notifications-read-1387.mjs`, `contracts/perf/adoption-1058.json`, `contracts/perf/exceptions.json`, `docs/perf/baseline.json`, and `docs/perf/runs/notifications-read-1387-{before,after}.json`.
Author
Owner

Starting continuation on branch job/notifread-1387 at cfc06d4d3a; current merge base with origin/dev is af202e1514. I will profile direct CDP input before changing the 500-row render path, then test the poll race and compare revisions as requested.

Starting continuation on branch job/notifread-1387 at cfc06d4d3a42ed0058487efaaf43431f423f774b; current merge base with origin/dev is af202e1514955ddf0f8b4b3d2503c780d807ed84. I will profile direct CDP input before changing the 500-row render path, then test the poll race and compare revisions as requested.
Author
Owner

Profile harness finding: after paging to 500 rows, the inbox remained scrolled at the bottom, so the CDP right-click point for the newest row was outside the viewport and menu open timed out after 10 s. The harness now scrolls to the top and checks the target bounds; a 40-row repeat passed, and the corrected 500-row trace is running.

Profile harness finding: after paging to 500 rows, the inbox remained scrolled at the bottom, so the CDP right-click point for the newest row was outside the viewport and menu open timed out after 10 s. The harness now scrolls to the top and checks the target bounds; a 40-row repeat passed, and the corrected 500-row trace is running.
Author
Owner

Direct-CDP Linux profile finding: the corrected production profile loaded all 500 rows and dispatched 10 trusted CDP toggles per input mode. Trace-instrumented input-to-paint was pointer p50/p95 311.1/435.1 ms and keyboard 127.6/350.5 ms. Over the 10 pointer intervals Chrome recorded 959.25 ms of style recalculation, 98.61 ms layout, and 230.74 ms paint; the keyboard intervals recorded 294.93 ms, 47.20 ms, and 107.45 ms respectively. The strongest JS costs were the minified Svelte runtime chunk (330.15 ms pointer, 214.73 ms keyboard) and the app component chunk (112.02 ms pointer, 40.98 ms keyboard). The aria-label badge update was 33.1/22.7 ms p50 and HTTP/1.1 request queue was 40.1/35.8 ms p50. These are diagnostic trace numbers with profiler/trace overhead, not the final comparison measurements. Raw traces are in the ignored target/tmp directory.

Direct-CDP Linux profile finding: the corrected production profile loaded all 500 rows and dispatched 10 trusted CDP toggles per input mode. Trace-instrumented input-to-paint was pointer p50/p95 311.1/435.1 ms and keyboard 127.6/350.5 ms. Over the 10 pointer intervals Chrome recorded 959.25 ms of style recalculation, 98.61 ms layout, and 230.74 ms paint; the keyboard intervals recorded 294.93 ms, 47.20 ms, and 107.45 ms respectively. The strongest JS costs were the minified Svelte runtime chunk (330.15 ms pointer, 214.73 ms keyboard) and the app component chunk (112.02 ms pointer, 40.98 ms keyboard). The aria-label badge update was 33.1/22.7 ms p50 and HTTP/1.1 request queue was 40.1/35.8 ms p50. These are diagnostic trace numbers with profiler/trace overhead, not the final comparison measurements. Raw traces are in the ignored target/tmp directory.
Author
Owner

Correction to my previous profiling comment: discard its 500-row pointer/keyboard latency and trace-cost numbers. The test observer watched the whole document and queried the selected row from its mutation callback, which could add work to the measured interval. The profiler now watches only the selected row and resolves a one-shot paint event without polling. A clean Mac Chrome run is in progress; I will report its results and the clean Linux trace separately.

Correction to my previous profiling comment: discard its 500-row pointer/keyboard latency and trace-cost numbers. The test observer watched the whole document and queried the selected row from its mutation callback, which could add work to the measured interval. The profiler now watches only the selected row and resolves a one-shot paint event without polling. A clean Mac Chrome run is in progress; I will report its results and the clean Linux trace separately.
Author
Owner

Mac profiling run reached 500 loaded Notifications and began the 10-toggle trace. The run then stopped during summary because Chrome's default resource timing buffer had filled during fixture seeding, so the measured write queue/response arrays were empty. I updated the profiler to raise the buffer and clear seed timings after the 500-row list loads; empty timing summaries now remain reportable. I will rerun trace capture with the interleaved comparison so both use one fresh fixture.

Mac profiling run reached 500 loaded Notifications and began the 10-toggle trace. The run then stopped during summary because Chrome's default resource timing buffer had filled during fixture seeding, so the measured write queue/response arrays were empty. I updated the profiler to raise the buffer and clear seed timings after the 500-row list loads; empty timing summaries now remain reportable. I will rerun trace capture with the interleaved comparison so both use one fresh fixture.
Author
Owner

Finding: the first corrected CDP launch stopped before server startup because the merged routeCurrentBuild helper retained three declarations after conflict-marker cleanup. I kept one declaration with both upstream CALTERNAL_E2E_BUILD_DIR selection and the Mac CDP local-document option. node --check apps/web/e2e/harness.mjs and node --check bench/notifications-read-1387-cdp.mjs now pass.

Finding: the first corrected CDP launch stopped before server startup because the merged `routeCurrentBuild` helper retained three declarations after conflict-marker cleanup. I kept one declaration with both upstream `CALTERNAL_E2E_BUILD_DIR` selection and the Mac CDP local-document option. `node --check apps/web/e2e/harness.mjs` and `node --check bench/notifications-read-1387-cdp.mjs` now pass.
Author
Owner

Finding (fixed build, Mac Chrome trace): each pointer input overlaps 28.7–74.5 ms of UpdateLayoutTree, while the document contains about 8,918 elements. The item grouping cache and row keys stay stable for read-state changes. I found a remaining focus-state fan-out: activeShortcutRowId is shared reactive state read by every row's tabindex and Delete tooltip. Focus restoration can invalidate every row. I am removing that dependency while keeping one roving tab stop and the same visible UI.

Finding (fixed build, Mac Chrome trace): each pointer input overlaps 28.7–74.5 ms of `UpdateLayoutTree`, while the document contains about 8,918 elements. The item grouping cache and row keys stay stable for read-state changes. I found a remaining focus-state fan-out: `activeShortcutRowId` is shared reactive state read by every row's `tabindex` and Delete tooltip. Focus restoration can invalidate every row. I am removing that dependency while keeping one roving tab stop and the same visible UI.
Author
Owner

Mac Chrome 154 trace on 500 Notifications found a remaining global cost: UpdateLayoutTree covered 8,918–8,919 elements on each input. The overlap total was 576.69 ms for 10 pointer toggles and 438.61 ms for 10 keyboard toggles. The root layout writes data-input="pointer" and focus-ring suppression on every input, even when each value is already set; Chrome then recalculates the full document. I stopped the interleaved run after round 2 to make the shared root writes idempotent and will rerun all three rounds on the final build. The trace used headed Mac Chrome over TLS through ssh -R with HTTP/1.1; queued time is measured separately.

Mac Chrome 154 trace on 500 Notifications found a remaining global cost: `UpdateLayoutTree` covered 8,918–8,919 elements on each input. The overlap total was 576.69 ms for 10 pointer toggles and 438.61 ms for 10 keyboard toggles. The root layout writes `data-input="pointer"` and focus-ring suppression on every input, even when each value is already set; Chrome then recalculates the full document. I stopped the interleaved run after round 2 to make the shared root writes idempotent and will rerun all three rounds on the final build. The trace used headed Mac Chrome over TLS through `ssh -R` with HTTP/1.1; queued time is measured separately.
Author
Owner

Completed #1387 on job/notifread-1387.

Head: f0e6cccf42571d14b5efc83b8143bc6305f935ef

What changed

  • The Inbox store now finds a Notification by ID and updates one stable row proxy. It adjusts unreadCount in O(1); read-state changes do not regroup or replace the keyed list. Writes for one ID stay ordered, and a poll waits for pending writes.
  • A 20-toggle regression test proves a stale poll cannot override the latest User action. Only the newest failed write rolls back.
  • Roving focus updates the shortcut metadata on at most two rows. Shared input-modality helpers skip unchanged root attributes, avoiding needless document-wide style invalidation.
  • Added direct-CDP trace and interleaved comparison support. The Mac test front can use a fixed port for its reverse SSH tunnel. No visual styling changed.

Mac Chrome comparison

Chrome 154.0.8037.98, headed, GPU compositing enabled on the Apple Metal VM. The test loaded 500 real Notifications. Each build had three interleaved rounds of 10 pointer and 10 keyboard toggles. Input was dispatched with CDP, without Playwright locator input. The pointer clock starts on the trusted click event, when the Menu action runs; keyboard starts on trusted Enter keydown. Paint is the target row class update followed by two animation frames.

Values are p50/p95 milliseconds. “All 30” is nearest-rank over the three rounds.

Build Pointer R1 Pointer R2 Pointer R3 Pointer all 30
origin/dev 85.3 / 101.8 65.3 / 192.8 74.4 / 109.4 79.8 / 109.4
cfc06d4d3 68.7 / 104.3 54.0 / 69.4 70.3 / 169.4 66.9 / 108.8
Fixed 61.1 / 119.0 58.7 / 83.3 63.2 / 81.4 62.4 / 88.2
Build Keyboard R1 Keyboard R2 Keyboard R3 Keyboard all 30
origin/dev 96.4 / 130.6 74.8 / 112.0 84.1 / 108.8 85.9 / 118.5
cfc06d4d3 68.4 / 116.6 56.6 / 98.9 66.6 / 80.1 63.5 / 113.9
Fixed 57.2 / 73.2 57.7 / 63.9 56.6 / 268.3 57.4 / 86.2

The requested 100 ms p95 target is met over all 30 samples for both inputs: pointer 88.2 ms and keyboard 86.2 ms. Round 1 pointer p95 was 119 ms; rounds 2 and 3 were 83.3 and 81.4 ms. Round 3 keyboard had one 268.3 ms sample with a 205 ms long task.

Trace costs and transport

Over ten traced toggles, style recalculation took 519.21 ms for pointer and 639.70 ms for keyboard; layout took 38.85 and 54.86 ms; paint took 101.65 and 157.13 ms. The notification bell aria update was p50/p95 5.1/9.6 ms for pointer and 5.2/16.0 ms for keyboard. The trace did not attribute time to named Svelte-effect, grouping, keyed-each or badge functions: the production bundle is minified, so those zero-valued name buckets are not evidence of zero execution. The top JS frames were also minified (ui 25.51 ms, route f 19.30 ms and an anonymous Svelte chunk 12.25 ms for pointer, summed over the intervals). Store tests check stable group and sibling-row identities.

Mac API traffic used HTTP/1.1 through the TLS front over ssh -R. In the untraced fixed comparison, request queue p50/p95 was 1.20/3.03 ms for pointer and 1.08/1.61 ms for keyboard. Write response p50/p95 was 390.6/914.8 ms and 380.7/808.4 ms. Optimistic paint did not wait for these responses. The traced keyboard pass had one 339.17 ms queue outlier; it did not appear in the untraced comparison. Mac load average was 13.52/4.58/3.14 at lock acquisition and 2.49/2.70/2.75 after the run.

Linux headless Chromium with the same 500-row fixture reported pointer 126.6/240 ms and keyboard 111.1/226 ms; the trace contained 23 and 14 long tasks over 50 ms. The target is based on real-GPU Mac Chrome.

UX gaps

Closed: quick repeated toggles can no longer be reverted by a poll; pointer and keyboard actions update the same row immediately; the roving-row Delete hint remains available without making every row reactive.

Left: one pointer round was above 100 ms at p95, and one keyboard sample had a 205 ms long task. Production minification prevents source-level CPU attribution for Svelte, grouping and keyed-each work. The Mac CDP run records main-thread trace costs, not whole-process RSS.

Decisions

  • I start the pointer clock at click, not pointerdown. The earlier marker included the remote CDP delay between press and release, before the Menu action ran.
  • I calculated the target p95 over all 30 samples per build/input and also reported every 10-toggle round. If the target must pass independently in every round, fixed pointer round 1 did not pass.

The compact profile is docs/perf/runs/notifications-read-1387-mac-cdp-after.json, linked from docs/perf/baseline.json.

Gates

bun run check:

perf-lint: PASS; 0 violations; 22542 scoped exceptions
Focus policy passes: 50 focus ring rules, 134 DOM focus calls and 2 data-input writers audited.
UI transitions and animation options use shared motion tokens or documented exceptions.
svelte-check found 0 errors and 4 warnings in 2 files

bun run test -- --maxWorkers=2:

 Test Files  301 passed (301)
      Tests  2177 passed (2177)
   Start at  03:06:59
   Duration  414.36s (transform 27%, environment 26%, import 22%, tests 18%, setup 5%)
EXIT_CODE=0

No Rust crates changed. cargo clean ran. Web build output was removed.

Known gates are only the four existing CSS warnings in Mail and Money.

Completed #1387 on `job/notifread-1387`. Head: `f0e6cccf42571d14b5efc83b8143bc6305f935ef` ## What changed - The Inbox store now finds a Notification by ID and updates one stable row proxy. It adjusts `unreadCount` in O(1); read-state changes do not regroup or replace the keyed list. Writes for one ID stay ordered, and a poll waits for pending writes. - A 20-toggle regression test proves a stale poll cannot override the latest User action. Only the newest failed write rolls back. - Roving focus updates the shortcut metadata on at most two rows. Shared input-modality helpers skip unchanged root attributes, avoiding needless document-wide style invalidation. - Added direct-CDP trace and interleaved comparison support. The Mac test front can use a fixed port for its reverse SSH tunnel. No visual styling changed. ## Mac Chrome comparison Chrome 154.0.8037.98, headed, GPU compositing enabled on the Apple Metal VM. The test loaded 500 real Notifications. Each build had three interleaved rounds of 10 pointer and 10 keyboard toggles. Input was dispatched with CDP, without Playwright locator input. The pointer clock starts on the trusted `click` event, when the Menu action runs; keyboard starts on trusted Enter keydown. Paint is the target row class update followed by two animation frames. Values are p50/p95 milliseconds. “All 30” is nearest-rank over the three rounds. | Build | Pointer R1 | Pointer R2 | Pointer R3 | Pointer all 30 | |---|---:|---:|---:|---:| | origin/dev | 85.3 / 101.8 | 65.3 / 192.8 | 74.4 / 109.4 | 79.8 / 109.4 | | cfc06d4d3 | 68.7 / 104.3 | 54.0 / 69.4 | 70.3 / 169.4 | 66.9 / 108.8 | | Fixed | 61.1 / 119.0 | 58.7 / 83.3 | 63.2 / 81.4 | 62.4 / 88.2 | | Build | Keyboard R1 | Keyboard R2 | Keyboard R3 | Keyboard all 30 | |---|---:|---:|---:|---:| | origin/dev | 96.4 / 130.6 | 74.8 / 112.0 | 84.1 / 108.8 | 85.9 / 118.5 | | cfc06d4d3 | 68.4 / 116.6 | 56.6 / 98.9 | 66.6 / 80.1 | 63.5 / 113.9 | | Fixed | 57.2 / 73.2 | 57.7 / 63.9 | 56.6 / 268.3 | 57.4 / 86.2 | The requested 100 ms p95 target is met over all 30 samples for both inputs: pointer 88.2 ms and keyboard 86.2 ms. Round 1 pointer p95 was 119 ms; rounds 2 and 3 were 83.3 and 81.4 ms. Round 3 keyboard had one 268.3 ms sample with a 205 ms long task. ## Trace costs and transport Over ten traced toggles, style recalculation took 519.21 ms for pointer and 639.70 ms for keyboard; layout took 38.85 and 54.86 ms; paint took 101.65 and 157.13 ms. The notification bell aria update was p50/p95 5.1/9.6 ms for pointer and 5.2/16.0 ms for keyboard. The trace did not attribute time to named Svelte-effect, grouping, keyed-each or badge functions: the production bundle is minified, so those zero-valued name buckets are not evidence of zero execution. The top JS frames were also minified (`ui` 25.51 ms, route `f` 19.30 ms and an anonymous Svelte chunk 12.25 ms for pointer, summed over the intervals). Store tests check stable group and sibling-row identities. Mac API traffic used HTTP/1.1 through the TLS front over `ssh -R`. In the untraced fixed comparison, request queue p50/p95 was 1.20/3.03 ms for pointer and 1.08/1.61 ms for keyboard. Write response p50/p95 was 390.6/914.8 ms and 380.7/808.4 ms. Optimistic paint did not wait for these responses. The traced keyboard pass had one 339.17 ms queue outlier; it did not appear in the untraced comparison. Mac load average was 13.52/4.58/3.14 at lock acquisition and 2.49/2.70/2.75 after the run. Linux headless Chromium with the same 500-row fixture reported pointer 126.6/240 ms and keyboard 111.1/226 ms; the trace contained 23 and 14 long tasks over 50 ms. The target is based on real-GPU Mac Chrome. ## UX gaps **Closed:** quick repeated toggles can no longer be reverted by a poll; pointer and keyboard actions update the same row immediately; the roving-row Delete hint remains available without making every row reactive. **Left:** one pointer round was above 100 ms at p95, and one keyboard sample had a 205 ms long task. Production minification prevents source-level CPU attribution for Svelte, grouping and keyed-each work. The Mac CDP run records main-thread trace costs, not whole-process RSS. ## Decisions - I start the pointer clock at `click`, not `pointerdown`. The earlier marker included the remote CDP delay between press and release, before the Menu action ran. - I calculated the target p95 over all 30 samples per build/input and also reported every 10-toggle round. If the target must pass independently in every round, fixed pointer round 1 did not pass. The compact profile is `docs/perf/runs/notifications-read-1387-mac-cdp-after.json`, linked from `docs/perf/baseline.json`. ## Gates `bun run check`: ``` perf-lint: PASS; 0 violations; 22542 scoped exceptions Focus policy passes: 50 focus ring rules, 134 DOM focus calls and 2 data-input writers audited. UI transitions and animation options use shared motion tokens or documented exceptions. svelte-check found 0 errors and 4 warnings in 2 files ``` `bun run test -- --maxWorkers=2`: ``` Test Files 301 passed (301) Tests 2177 passed (2177) Start at 03:06:59 Duration 414.36s (transform 27%, environment 26%, import 22%, tests 18%, setup 5%) EXIT_CODE=0 ``` No Rust crates changed. `cargo clean` ran. Web build output was removed. Known gates are only the four existing CSS warnings in Mail and Money.
Author
Owner

Deployed to calternal.cloud in merge round 35c (build 5a2c02c13, live 2026-10-09 ~16:35 IST). Closing.

Deployed to calternal.cloud in merge round 35c (build 5a2c02c13, live 2026-10-09 ~16:35 IST). Closing.
kayg closed this issue 2026-10-09 11:11:41 +00:00
Commenting is not possible because the repository is archived.
No labels
No milestone
No project
No assignees
1 participant
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-v0#1387
No description provided.