From 564a7da84e7d7096649bc22126ca86354fa81437 Mon Sep 17 00:00:00 2001 From: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> Date: Sun, 20 Sep 2026 15:27:51 -0600 Subject: [PATCH] perf(desktop): skip unused continuation rail measurements Signed-off-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> --- desktop/playwright.config.ts | 1 + .../src/features/messages/ui/MessageRow.tsx | 7 +- .../e2e/message-rail-measurement.spec.ts | 188 ++++++++++++++++++ perf/CHANNEL_ACTION_RAIL.md | 88 ++++++++ 4 files changed, 282 insertions(+), 2 deletions(-) create mode 100644 desktop/tests/e2e/message-rail-measurement.spec.ts create mode 100644 perf/CHANNEL_ACTION_RAIL.md diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 5dad5d64d2..a8335bb17d 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -145,6 +145,7 @@ export default defineConfig({ "**/inbox-refactor-screenshots.spec.ts", "**/inbox-title-overlap.spec.ts", "**/message-author-overlap.spec.ts", + "**/message-rail-measurement.spec.ts", "**/buzz-theme-screenshots.spec.ts", "**/appearance-previews.spec.ts", "**/channel-sort.spec.ts", diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 75b7d0d339..64dcea4d49 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -219,10 +219,13 @@ export const MessageRow = React.memo( // takes no layout space of its own. Measure its rendered footprint and // reserve it in the message-header row (see `headerNode`) so a long author // name ellipsizes before the rail instead of painting underneath it. The - // reservation is unconditional — the rail appears on hover AND - // focus-within, and padding must not reflow the header mid-interaction. + // reservation is unconditional for displayed headers — the rail appears + // on hover AND focus-within, so padding must not reflow mid-interaction. + // Continuations have no header consuming the width; keep their controls + // mounted without measuring an unused reservation. const articleRef = React.useRef(null); const actionRailMeasureRef = useMeasuredCssVariable({ + enabled: !isDisplayedAsContinuation, cssVariable: "--message-action-rail-width", dimension: "inline", resetValue: "0px", diff --git a/desktop/tests/e2e/message-rail-measurement.spec.ts b/desktop/tests/e2e/message-rail-measurement.spec.ts new file mode 100644 index 0000000000..ef42651925 --- /dev/null +++ b/desktop/tests/e2e/message-rail-measurement.spec.ts @@ -0,0 +1,188 @@ +import { expect, test } from "@playwright/test"; +import type { QueryClient } from "@tanstack/react-query"; +import type { ChannelWindowStore } from "../../src/features/messages/lib/channelWindowStore"; +import type { RelayEvent } from "../../src/shared/api/types"; +import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; + +type ProbeWindow = Window & { + __RAIL_PROBES__: { reads: string[]; active: (id: string) => number }; +}; + +// Exercise the production MessageRow caller, not only the hook's enabled option. +// Query-cache transitions below intentionally isolate presentation from send/relay behavior. +test("rail measurement follows displayed headers, including pending transitions", async ({ + page, +}) => { + await page.addInitScript(() => { + const active = new Map>(); + const reads: string[] = []; + const NativeObserver = ResizeObserver; + const originalRect = Element.prototype.getBoundingClientRect; + Element.prototype.getBoundingClientRect = function () { + const id = this.getAttribute("data-testid"); + if (id?.startsWith("message-action-bar-")) reads.push(id); + return originalRect.call(this); + }; + window.ResizeObserver = class extends NativeObserver { + observe(target: Element, options?: ResizeObserverOptions) { + const targets = active.get(this) ?? new Set(); + targets.add(target); + active.set(this, targets); + return super.observe(target, options); + } + unobserve(target: Element) { + active.get(this)?.delete(target); + return super.unobserve(target); + } + disconnect() { + active.delete(this); + return super.disconnect(); + } + }; + (window as ProbeWindow).__RAIL_PROBES__ = { + reads, + active: (id: string) => + [...active.values()] + .flatMap((targets) => [...targets]) + .filter( + (el) => + el.getAttribute("data-testid") === `message-action-bar-${id}`, + ).length, + }; + }); + await installMockBridge(page); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect + .poll(() => + page.evaluate(() => + window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: "general", + }), + ), + ) + .toBe(true); + const { first, second, channelId } = await page.evaluate((pubkey) => { + const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__; + if (!emit) throw new Error("Mock message emitter unavailable"); + const timestamp = Math.floor(Date.now() / 1000) + 1000; + const first = emit({ + channelName: "general", + content: "Rail header fixture", + pubkey, + createdAt: timestamp, + }); + const second = emit({ + channelName: "general", + content: "Rail continuation fixture", + pubkey, + createdAt: timestamp + 1, + }); + const channelId = first.tags.find((tag) => tag[0] === "h")?.[1]; + if (!channelId) throw new Error("Emitted message has no channel"); + return { + first: first.id, + second: second.id, + channelId, + }; + }, TEST_IDENTITIES.alice.pubkey); + const firstRow = page.locator(`[data-message-id="${first}"]`); + const secondRow = page.locator(`[data-message-id="${second}"]`); + await expect(firstRow.getByTestId("message-header")).toHaveCount(1); + await expect(secondRow.getByTestId("message-body")).toHaveText( + "Rail continuation fixture", + ); + await expect(secondRow.getByTestId("message-header")).toHaveCount(0); + const active = (id: string) => + page.evaluate( + (id) => (window as ProbeWindow).__RAIL_PROBES__.active(id), + id, + ); + await expect.poll(() => active(first)).toBe(1); + expect(await active(second)).toBe(0); + expect( + await page.evaluate( + (id) => + (window as ProbeWindow).__RAIL_PROBES__.reads.filter( + (x: string) => x === `message-action-bar-${id}`, + ).length, + second, + ), + ).toBe(0); + + const setPending = async (pending: boolean) => { + await page.evaluate( + ({ id, channelId, pending }) => { + const client = window.__BUZZ_E2E_QUERY_CLIENT__ as + | QueryClient + | undefined; + if (!client) throw new Error("Query client unavailable"); + const update = (event: RelayEvent) => + event.id === id ? { ...event, pending } : event; + client.setQueryData( + ["channel-messages", channelId], + (events) => events?.map(update), + ); + client.setQueryData( + ["channel-window", channelId], + (store) => + store + ? { + ...store, + pages: store.pages.map((page) => ({ + ...page, + rows: page.rows.map((row) => ({ + ...row, + event: update(row.event), + })), + })), + liveOverlay: store.liveOverlay.map(update), + } + : store, + ); + }, + { id: second, channelId, pending }, + ); + }; + // A pending continuation still shows a header: bare isContinuation is wrong. + await setPending(true); + await expect(secondRow.getByTestId("message-header")).toHaveCount(1); + await expect(secondRow.getByTestId("message-send-status")).toHaveText( + "Sending…", + ); + await expect.poll(() => active(second)).toBe(1); + const railWidth = () => + secondRow.evaluate( + (el) => + Number.parseFloat( + getComputedStyle(el).getPropertyValue("--message-action-rail-width"), + ) || 0, + ); + await expect.poll(railWidth).toBeGreaterThan(0); + const rail = secondRow.getByTestId(`message-action-bar-${second}`); + const measuredBeforeResize = await railWidth(); + await rail.evaluate((element, width) => { + element.style.width = `${width}px`; + }, measuredBeforeResize + 40); + await expect.poll(railWidth).toBe(measuredBeforeResize + 40); + await rail.evaluate((element) => element.style.removeProperty("width")); + await expect.poll(railWidth).toBe(measuredBeforeResize); + await page.setViewportSize({ width: 900, height: 700 }); + await expect.poll(railWidth).toBeGreaterThan(0); + + await setPending(false); + await expect(secondRow.getByTestId("message-header")).toHaveCount(0); + await expect.poll(() => active(second)).toBe(0); + expect(await railWidth()).toBe(0); + // Controls stay mounted and reveal on hover/focus without measuring unused width. + await secondRow.hover(); + await expect(rail).toHaveCSS("opacity", "1"); + await page.mouse.move(0, 0); + await rail.getByRole("button").first().focus(); + await expect(rail).toHaveCSS("opacity", "1"); + await setPending(true); + await expect.poll(() => active(second)).toBe(1); + await page.getByTestId("channel-random").click(); + await expect(secondRow).toHaveCount(0); + await expect.poll(() => active(second)).toBe(0); +}); diff --git a/perf/CHANNEL_ACTION_RAIL.md b/perf/CHANNEL_ACTION_RAIL.md new file mode 100644 index 0000000000..eefa264f55 --- /dev/null +++ b/perf/CHANNEL_ACTION_RAIL.md @@ -0,0 +1,88 @@ +# Warm channel switching: unused action-rail measurement + +## Scope + +Skip action-rail width measurement only when `MessageRow` has no displayed +header: `enabled: !isDisplayedAsContinuation`. The existing predicate is +`isContinuation && !message.pending`, so pending messages retain measurement. +The existing hook owns observer cleanup, reset, and re-enable. + +Action controls remain mounted. Header reservation on hover/keyboard focus, +loading UI, corners, retention, scrolling, subscription freshness reads, and +transport are unchanged. No new scheduler, cache, or lifecycle owner. + +This is a modest rendering improvement, **not a resolution of multi-second +native startup, channel switching, or relay waits**. + +## Measurement + +Baseline: `497e369e75cfa2ed8caeb1204e7233f30c6a7f90`. +Candidate `MessageRow.tsx` SHA-256: +`5ef36df5cfad76887c5a6b1a5ecf0c653e77019067d598b4e15f58f97ce86f27`. + +Chromium 148.0.7778.96, production-optimized Vite e2e builds, mock Tauri/relay, +1280x800. Three alternating baseline/candidate context pairs per CPU rate; +two measured warm re-entries per scenario per context: **six samples per cell**. +Date fixed, native timers/rAF preserved; CPU profiling ran separately. + +| Scenario | CPU | Visible newest message, median | Max long task, median | +|---|---:|---:|---:| +| Plain, alternating authors | 1x | 88.6 -> 92.2 ms | 0 -> 0 ms | +| Rich, grouped messages | 1x | 178.4 -> 167.4 ms | 105.5 -> 87.0 ms | +| Plain, alternating authors | 4x | 396.0 -> 402.6 ms | 159.5 -> 161.0 ms | +| Rich, grouped messages | 4x | 766.4 -> 689.0 ms | 448.5 -> 373.5 ms | + +Rich readiness improved in each of the three pair medians at both rates. +Plain rows show no consistent benefit. This is about 11ms unthrottled in the +rich fixture; 4x throttling is a stress comparison, not a hardware model. + +Readiness requires the correct newest message body above the composer, visible +within the timeline, for two native animation frames. One further second counts +deferred work. Mounted rows remain 50 plain / 60 rich. No fresh Markdown parses +occurred; the existing cache already serves warm switches. The intentional +subscription-handoff freshness read remains. + +The build-only rail experiment and actual edited-source build contain 547 +byte-identical files. Raw samples, source/bundle hash manifest, diagnostic CPU +profiles and harness are retained in the local evidence package +`RESEARCH/OLD_BUZZ_CHANNEL_SWITCH_PROFILE_2026_09_20/` in the agent workspace. +These measurements do not establish native/WebKit paint or live-relay latency. + +A smaller initial retention window was rejected because deferred plain-row work +increased. Disabling corners diagnostically left most cost intact; a global +scheduler was not justified. Neither experiment is included in this patch. + +## Regression evidence + +`desktop/tests/e2e/message-rail-measurement.spec.ts` instruments the real +production caller: displayed header gets an observer; continuation gets neither +an observer nor an initial width read; pending transitions enable/disable/reset +and re-enable it; changing rail width by 40px must update the reservation exactly +and removing that width must restore it. Navigation cleans up. Hover/focus still +reveals continuation controls. Cache-driven pending transitions test presentation, +not send/relay lifecycle; visible controls do not prove every action executes. + +The original production build fails at the continuation observer assertion +(expected zero, got one). The candidate passes. The regression is registered in +the regular smoke project, not only a temporary test configuration. + +Validation on the candidate: + +- Standard command below builds the actual source and passes all four checks in + the three complete spec files. No retries. +- 6,531 desktop unit tests pass through the existing pre-push desktop-test hook. +- Desktop lint/static checks pass with existing warnings; TypeScript passes. +- Production protected-feature artifact matrix build passes. +- Independent read-only review found no production correctness blocker. Smoke + registration and exact resize assertion were strengthened after review. + +```bash +. ./bin/activate-hermit +cd desktop +pnpm test:e2e:smoke message-rail-measurement.spec.ts \ + message-author-overlap.spec.ts message-feedback-snapshots.spec.ts +``` + +Full repository CI, complete browser smoke, native/WebKit behavior, and release +readiness are not certified by these checks. No live app was restarted, cache +changed, or relay deployed for this work.