From 4bc840715491f6f168f7eca4e5f98d3f6f970a7b Mon Sep 17 00:00:00 2001 From: ael-dev3 <183124839+ael-dev3@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:20:48 +0200 Subject: [PATCH 1/9] test(perf): retain bounded post-opening diagnostics --- e2e/helpers/post-open-diagnostics.ts | 157 +++++++++++++++++++++++++++ e2e/perf.spec.ts | 78 ++++++++----- 2 files changed, 206 insertions(+), 29 deletions(-) create mode 100644 e2e/helpers/post-open-diagnostics.ts diff --git a/e2e/helpers/post-open-diagnostics.ts b/e2e/helpers/post-open-diagnostics.ts new file mode 100644 index 00000000..847ef680 --- /dev/null +++ b/e2e/helpers/post-open-diagnostics.ts @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import type { Page } from "@playwright/test"; +import { safePerfUrl } from "./perf-diagnostics"; + +export const POST_OPEN_LIMITS = { frames: 64, longtasks: 64, loafs: 32, scripts: 4, resources: 128 } as const; + +/** Metadata only: no extra frame loop, DOM observer, request or page readiness. */ +export function postOpenDiagnosticsProbe(limits: typeof POST_OPEN_LIMITS) { + const state = ((window as any).__allthingPostOpenDiagnostics = { + limits, hover: null as any, clickHash: null as string | null, click: null as number | null, + opened: null as number | null, frames: [] as any[], longtasks: [] as any[], loafs: [] as any[], + counts: { frames: 0, longtasks: 0, loafs: 0 }, available: { longtasks: false, loafs: false }, + observers: [] as PerformanceObserver[], + }); + const hash = (target: EventTarget | null) => target instanceof Element + ? target.closest(".cast-card[data-hash]")?.getAttribute("data-hash") ?? null : null; + document.addEventListener("pointerover", (event) => { + if (state.click === null && event.isTrusted && event.pointerType === "mouse") { + const selected = hash(event.target); + if (selected) state.hover = { at: performance.now(), hash: selected }; + } + }, { capture: true }); + // This runs before the measurement's capture listener. Attribute lookup + // therefore adds no work inside its unchanged click-to-text interval. + document.addEventListener("click", (event) => { + if (state.click === null && event.isTrusted) state.clickHash = hash(event.target); + }, { capture: true, once: true }); + const consume = (kind: "longtasks" | "loafs", entries: PerformanceEntry[]) => { + if (state.click === null) return; + const end = state.opened === null ? Infinity : state.click + state.opened; + for (const raw of entries as any[]) { + if (raw.startTime + raw.duration < state.click || raw.startTime > end) continue; + state.counts[kind]++; + if (state[kind].length >= limits[kind]) continue; + // Retain native entry references only. Projecting script metadata and + // URLs is deferred until after the measured interaction/assertions. + state[kind].push(raw); + } + }; + for (const [kind, type] of [["longtasks", "longtask"], ["loafs", "long-animation-frame"]] as const) { + if (!PerformanceObserver.supportedEntryTypes.includes(type)) continue; + state.available[kind] = true; + const observer = new PerformanceObserver((list) => consume(kind, list.getEntries())); + observer.observe({ type, buffered: false }); + (observer as any).__postOpenKind = kind; + state.observers.push(observer); + } + state.consume = consume; +} + +type RawPostOpen = { + click: number | null; opened: number | null; collected: number; + hover: { at: number; hash: string } | null; clickHash: string | null; + frames: { at: number; shown: boolean }[]; + longtasks: { start: number; duration: number }[]; + loafs: { start: number; duration: number; blocking: number; render: number; layout: number; firstUI: number; + scripts: { start: number; duration: number; execution: number; forcedLayout: number; pause: number; source: string; position: number }[] }[]; + resources: { url: string; kind: string; start: number; request: number; response: number; end: number; duration: number; transferBytes: number; encodedBytes: number; decodedBytes: number }[]; + counts: { frames: number; longtasks: number; loafs: number; scripts: number; resources: number }; + available: { longtasks: boolean; loafs: boolean }; +}; + +/** Runs in Node, after the measured interaction and its original assertions. */ +export function sanitizePostOpenDiagnostics(raw: RawPostOpen) { + const relative = (at: number) => Number.isFinite(at) && raw.click !== null ? at - raw.click : null; + const hash = (value?: string | null) => /^0x[0-9a-f]{40}$/i.test(value ?? "") ? value : null; + const url = (value: string) => safePerfUrl(value).slice(0, 2048); + const count = (observed: number, retained: number, limit: number) => ({ observed, retained, omitted: Math.max(0, observed - retained), limit }); + const frames = raw.frames.slice(0, POST_OPEN_LIMITS.frames).map((frame) => ({ at: relative(frame.at), shown: frame.shown })); + const longtasks = raw.longtasks.slice(0, POST_OPEN_LIMITS.longtasks).map((task) => ({ start: relative(task.start), duration: task.duration })); + const loafs = raw.loafs.slice(0, POST_OPEN_LIMITS.loafs).map((frame) => ({ + start: relative(frame.start), duration: frame.duration, blocking: frame.blocking, + render: frame.render ? relative(frame.render) : null, layout: frame.layout ? relative(frame.layout) : null, + firstUI: frame.firstUI ? relative(frame.firstUI) : null, + scripts: frame.scripts.slice(0, POST_OPEN_LIMITS.scripts).map((script) => ({ + start: relative(script.start), duration: script.duration, execution: relative(script.execution), + forcedLayout: script.forcedLayout, pause: script.pause, source: url(script.source), position: script.position, + })), + })); + const resources = raw.resources.slice(0, POST_OPEN_LIMITS.resources).map((resource) => { + const parsed = new URL(resource.url); + return { + url: url(resource.url), kind: resource.kind.slice(0, 32), + selectedPost: !!raw.clickHash && parsed.pathname === "/api/v1/cast" && parsed.searchParams.get("hash") === raw.clickHash, + start: relative(resource.start), request: resource.request ? relative(resource.request) : null, + response: resource.response ? relative(resource.response) : null, end: relative(resource.end), duration: resource.duration, + transferBytes: resource.transferBytes, encodedBytes: resource.encodedBytes, decodedBytes: resource.decodedBytes, + }; + }); + return { + version: 1, clock: "milliseconds relative to the original click capture t0", opened: raw.opened, + resourceScope: "completed entries still in the browser timing buffer; in-flight or previously evicted entries are unavailable", + collected: relative(raw.collected), hover: raw.hover ? { at: relative(raw.hover.at), hash: hash(raw.hover.hash) } : null, + clickHash: hash(raw.clickHash), available: raw.available, frames, longtasks, loafs, resources, + bounds: { + frames: count(raw.counts.frames, frames.length, POST_OPEN_LIMITS.frames), + longtasks: count(raw.counts.longtasks, longtasks.length, POST_OPEN_LIMITS.longtasks), + loafs: count(raw.counts.loafs, loafs.length, POST_OPEN_LIMITS.loafs), + scripts: { ...count(raw.counts.scripts, loafs.reduce((sum, frame) => sum + frame.scripts.length, 0), POST_OPEN_LIMITS.loafs * POST_OPEN_LIMITS.scripts), perFrameLimit: POST_OPEN_LIMITS.scripts, scope: "retained long-animation frames; omitted frames' scripts are unavailable" }, + resources: count(raw.counts.resources, resources.length, POST_OPEN_LIMITS.resources), + }, + }; +} + +export async function readPostOpenDiagnostics(page: Page) { + const raw = await page.evaluate(() => { + const state = (window as any).__allthingPostOpenDiagnostics; + if (!state) return null; + for (const observer of state.observers) { + state.consume((observer as any).__postOpenKind, observer.takeRecords()); + observer.disconnect(); + } + // Reading completed resource entries happens after the metric and all + // assertions. Keep pre-click prefetch phases too, including split chunks. + const kept: { entry: PerformanceResourceTiming; priority: number }[] = []; + let observed = 0; + for (const entry of performance.getEntriesByType("resource") as PerformanceResourceTiming[]) { + const url = new URL(entry.name); + if (url.origin !== location.origin || !(url.pathname === "/api/v1/cast" || /\.(?:wasm|js)$/.test(url.pathname))) continue; + observed++; + const priority = url.pathname === "/api/v1/cast" && state.clickHash && url.searchParams.get("hash") === state.clickHash + ? 0 : /\/split_post\.(?:wasm|js)$/.test(url.pathname) ? 1 : 2; + if (kept.length < state.limits.resources) kept.push({ entry, priority }); + else { + // Keep the selected post and its split module even when earlier + // generic code chunks have filled the diagnostic resource cap. + const replace = kept.findIndex((item) => item.priority > priority); + if (replace >= 0) kept[replace] = { entry, priority }; + } + } + const resources = kept.sort((a, b) => a.priority - b.priority || a.entry.startTime - b.entry.startTime).map(({ entry }) => ({ + url: entry.name, kind: entry.initiatorType, start: entry.startTime, request: entry.requestStart, + response: entry.responseStart, end: entry.responseEnd, duration: entry.duration, + transferBytes: entry.transferSize, encodedBytes: entry.encodedBodySize, decodedBytes: entry.decodedBodySize, + })); + let scripts = 0; + const loafs = state.loafs.map((entry: any) => { + scripts += entry.scripts?.length ?? 0; + return { + start: entry.startTime, duration: entry.duration, blocking: entry.blockingDuration, + render: entry.renderStart, layout: entry.styleAndLayoutStart, firstUI: entry.firstUIEventTimestamp, + scripts: (entry.scripts ?? []).slice(0, state.limits.scripts).map((script: any) => ({ + start: script.startTime, duration: script.duration, execution: script.executionStart, + forcedLayout: script.forcedStyleAndLayoutDuration, pause: script.pauseDuration, + source: String(script.sourceURL ?? "").slice(0, 4096), position: script.sourceCharPosition, + })), + }; + }); + return { + click: state.click, opened: state.opened, collected: performance.now(), + hover: state.hover, clickHash: state.clickHash, frames: state.frames, loafs, + longtasks: state.longtasks.map((entry: PerformanceEntry) => ({ start: entry.startTime, duration: entry.duration })), + resources, counts: { ...state.counts, scripts, resources: observed }, available: state.available, + } as RawPostOpen; + }); + return raw ? sanitizePostOpenDiagnostics(raw) : { unavailable: "post-opening probe did not initialize" }; +} diff --git a/e2e/perf.spec.ts b/e2e/perf.spec.ts index c1c487ba..c0e46a9c 100644 --- a/e2e/perf.spec.ts +++ b/e2e/perf.spec.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import { expect, test, type Page } from "@playwright/test"; import { perfDiagnosticsProbe, perfResponses, readPerfDiagnostics } from "./helpers/perf-diagnostics"; +import { POST_OPEN_LIMITS, postOpenDiagnosticsProbe, readPostOpenDiagnostics } from "./helpers/post-open-diagnostics"; /* * Performance budget (docs/PERFORMANCE.md), measured in the browser: @@ -88,35 +89,54 @@ test.describe("performance budget", () => { expect(value).toBeLessThan(LCP_BUDGET); }); - test("opening a post from the feed takes under 100 ms", async ({ page }) => { - await page.goto("/"); - // A card with plain text to click (.cast-title: releases before full text). - const card = page.locator("#app .cast-card:not(.skeleton):has(.cast-text, .cast-title):not(:has(.cast-text a))").nth(2); - await expect(card).toBeVisible(); - await card.hover(); // prefetches the post's data - await page.waitForTimeout(600); - await page.evaluate(() => { - (window as any).__openedIn = null; - document.addEventListener( - "click", - () => { - const t0 = performance.now(); - const check = () => - document.querySelector(".post .post-text, .post .mini-app-note") - ? ((window as any).__openedIn = performance.now() - t0) - : requestAnimationFrame(check); - requestAnimationFrame(check); - }, - { capture: true, once: true }, - ); - }); - await card.locator(".cast-text, .cast-title").first().click({ position: { x: 4, y: 8 } }); - await expect.poll(() => page.evaluate(() => (window as any).__openedIn)).not.toBeNull(); - const opened = await page.evaluate(() => (window as any).__openedIn as number); - // With prefetched data, the replies are in place too. - await expect(page.locator(".replies .comments, .replies .empty").first()).toBeVisible({ timeout: 5000 }); - console.log(`post opened in ${opened.toFixed(0)} ms`); - expect(opened).toBeLessThan(OPEN_BUDGET); + test("opening a post from the feed takes under 100 ms", async ({ page }, testInfo) => { + await page.addInitScript(postOpenDiagnosticsProbe, POST_OPEN_LIMITS); + try { + await page.goto("/"); + // A card with plain text to click (.cast-title: releases before full text). + const card = page.locator("#app .cast-card:not(.skeleton):has(.cast-text, .cast-title):not(:has(.cast-text a))").nth(2); + await expect(card).toBeVisible(); + await card.hover(); // prefetches the post's data + await page.waitForTimeout(600); + await page.evaluate(() => { + (window as any).__openedIn = null; + document.addEventListener( + "click", + () => { + const t0 = performance.now(); + const diagnostic = (window as any).__allthingPostOpenDiagnostics; + if (diagnostic) diagnostic.click = t0; + const check = () => { + const result = + document.querySelector(".post .post-text, .post .mini-app-note") + ? ((window as any).__openedIn = performance.now() - t0) + : requestAnimationFrame(check); + // Observe this existing check only; never schedule another frame. + if (diagnostic) { + diagnostic.counts.frames++; + if (diagnostic.frames.length < diagnostic.limits.frames) { + diagnostic.frames.push({ at: performance.now(), shown: (window as any).__openedIn !== null }); + } + diagnostic.opened = (window as any).__openedIn; + } + return result; + }; + requestAnimationFrame(check); + }, + { capture: true, once: true }, + ); + }); + await card.locator(".cast-text, .cast-title").first().click({ position: { x: 4, y: 8 } }); + await expect.poll(() => page.evaluate(() => (window as any).__openedIn)).not.toBeNull(); + const opened = await page.evaluate(() => (window as any).__openedIn as number); + // With prefetched data, the replies are in place too. + await expect(page.locator(".replies .comments, .replies .empty").first()).toBeVisible({ timeout: 5000 }); + console.log(`post opened in ${opened.toFixed(0)} ms`); + expect(opened).toBeLessThan(OPEN_BUDGET); + } finally { + const diagnostic = await readPostOpenDiagnostics(page).catch(() => ({ unavailable: "diagnostics could not be collected" })); + await testInfo.attach("post-open-diagnostics.json", { body: JSON.stringify({ budget: OPEN_BUDGET, retry: testInfo.retry, ...diagnostic }, null, 2), contentType: "application/json" }); + } }); test("interactions respond within 100 ms (Event Timing)", async ({ page }) => { From 56a65d8bfc3abdc59d9a854be2050523c7f7c057 Mon Sep 17 00:00:00 2001 From: ael-dev3 <183124839+ael-dev3@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:25:56 +0200 Subject: [PATCH 2/9] test(perf): preserve initial card hover timestamp --- e2e/helpers/post-open-diagnostics.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/e2e/helpers/post-open-diagnostics.ts b/e2e/helpers/post-open-diagnostics.ts index 847ef680..e8f64674 100644 --- a/e2e/helpers/post-open-diagnostics.ts +++ b/e2e/helpers/post-open-diagnostics.ts @@ -17,7 +17,9 @@ export function postOpenDiagnosticsProbe(limits: typeof POST_OPEN_LIMITS) { document.addEventListener("pointerover", (event) => { if (state.click === null && event.isTrusted && event.pointerType === "mouse") { const selected = hash(event.target); - if (selected) state.hover = { at: performance.now(), hash: selected }; + // Moving from a card's center to its text must not replace the + // original intent timestamp with the click action's pointer move. + if (selected && selected !== state.hover?.hash) state.hover = { at: performance.now(), hash: selected }; } }, { capture: true }); // This runs before the measurement's capture listener. Attribute lookup From 1f410c07d75b5e99d1b2155d12470504d49f1bb4 Mon Sep 17 00:00:00 2001 From: ael-dev3 <183124839+ael-dev3@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:28:44 +0200 Subject: [PATCH 3/9] perf(home): reuse observed heights when retaining cards --- CHANGELOG.md | 1 + crates/allthing-web/Cargo.toml | 3 + crates/allthing-web/src/components/cast.rs | 4 + crates/allthing-web/src/kept.rs | 213 ++++++++++++++++++++- docs/ARCHITECTURE.md | 2 +- e2e/kept-front.fixture.spec.ts | 157 +++++++++++++++ 6 files changed, 369 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00e2268c..c3f2367d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ All notable changes to Allthing are recorded here. The format follows [Keep a Ch ### Fixed +- Leaving Home reuses observed card heights when their exact current sizes still match, reducing layout reads before a post opens while preserving Back geometry and the existing fallback for late size changes or unsupported browsers. - A hover card dismissed with Escape cannot reopen when the pointer re-enters during its closing animation. - A data center with no default 24-hour Home copy, or an expired retained one, can use a fresh public copy shared by another data center, preserving its original age and current time bounds, instead of waiting for the full upstream build. Missing or invalid copies retain the normal fallback, ordinary background refreshes still build normally, and other ranges and private feeds are unchanged. - CI rejects browser tests that pass only on retry, so a flaky run cannot appear as a clean release check. diff --git a/crates/allthing-web/Cargo.toml b/crates/allthing-web/Cargo.toml index 884f1d00..b6013a04 100644 --- a/crates/allthing-web/Cargo.toml +++ b/crates/allthing-web/Cargo.toml @@ -47,6 +47,7 @@ features = [ "Document", "DomMatrixReadOnly", "DomRect", + "DomRectReadOnly", "DomTokenList", "Element", "EventTarget", @@ -81,6 +82,8 @@ features = [ "ReadableStream", "ReadableStreamDefaultReader", "Response", + "ResizeObserver", + "ResizeObserverEntry", "ScrollBehavior", "ScrollIntoViewOptions", "ScrollLogicalPosition", diff --git a/crates/allthing-web/src/components/cast.rs b/crates/allthing-web/src/components/cast.rs index 94189ecb..1865e2f4 100644 --- a/crates/allthing-web/src/components/cast.rs +++ b/crates/allthing-web/src/components/cast.rs @@ -74,6 +74,7 @@ pub fn CastCard( if let Some(element) = node.get() { if track_seen { crate::seen::watch(&element, seen); + crate::kept::watch_height(&element); } watch_for_prefetch(&element); } @@ -82,6 +83,9 @@ pub fn CastCard( if let Some(element) = node.get_untracked() { unwatch(&element); crate::seen::unwatch(&element); + if track_seen { + crate::kept::unwatch_height(&element); + } } }); view! { diff --git a/crates/allthing-web/src/kept.rs b/crates/allthing-web/src/kept.rs index 63586347..d2b73e1e 100644 --- a/crates/allthing-web/src/kept.rs +++ b/crates/allthing-web/src/kept.rs @@ -13,12 +13,14 @@ //! it. Out for longer than the client cache keeps its data, it's drawn anew //! when it shows again. -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; +use std::collections::HashMap; use leptos::prelude::*; use leptos::tachys::view::{Mountable, Render}; use leptos_router::hooks::use_url; use wasm_bindgen::JsCast; +use wasm_bindgen::closure::Closure; use crate::pages::home::Home; use crate::util::BASE; @@ -41,6 +43,164 @@ struct Kept { thread_local! { static KEPT: RefCell> = const { RefCell::new(None) }; + static HEIGHT_OBSERVER: Option = height_observer(); + static HEIGHTS: RefCell> = RefCell::new(HashMap::new()); + static NEXT_HEIGHT: Cell = const { Cell::new(0) }; +} + +/// One opaque ID, rather than reading and parsing every CSS edge during a +/// navigation. Only mounted Home cards register, and cleanup removes them. +const HEIGHT_ID: &str = "data-kept-size"; +const SIZE_TOLERANCE: f64 = 0.000_001; + +#[derive(Clone, Copy, Debug)] +struct ObservedHeight { + content: f64, + border_height: f64, + border_width: f64, +} + +impl ObservedHeight { + fn new(content: f64, border_height: f64, border_width: f64) -> Option { + [content, border_height, border_width] + .into_iter() + .all(|size| size.is_finite() && size > 0.0) + .then_some(Self { content, border_height, border_width }) + .filter(|size| size.content <= size.border_height + SIZE_TOLERANCE) + } + + fn for_box(self, height: f64, width: f64) -> Option { + ((height - self.border_height).abs() <= SIZE_TOLERANCE && (width - self.border_width).abs() <= SIZE_TOLERANCE) + .then_some(self.content) + } +} + +#[derive(Clone)] +struct ObservedCard { + size: ObservedHeight, + style: Option, + class: Option, +} + +struct HeightObserver { + observer: web_sys::ResizeObserver, + // The shared observer and its callback have the same lifetime. + _callback: Closure, +} + +fn height_id(element: &web_sys::Element) -> Option { + element.get_attribute(HEIGHT_ID)?.parse().ok() +} + +fn height_observer() -> Option { + let callback = Closure::::new(|entries: js_sys::Array| { + for entry in entries.iter().filter_map(|entry| entry.dyn_into::().ok()) { + let element = entry.target(); + let Some(id) = height_id(&element).filter(|_| element.is_connected()) else { continue }; + // Missing/fragmented border boxes in older browsers keep the + // existing exact measurement fallback. Reflect also tolerates a + // browser that implements ResizeObserver without borderBoxSize. + let border = js_sys::Reflect::get(&entry, &"borderBoxSize".into()) + .ok() + .and_then(|value| value.dyn_into::().ok()) + .filter(|boxes| boxes.length() == 1) + .map(|boxes| boxes.get(0)); + let size = border.and_then(|border| { + let height = js_sys::Reflect::get(&border, &"blockSize".into()).ok()?.as_f64()?; + let width = js_sys::Reflect::get(&border, &"inlineSize".into()).ok()?.as_f64()?; + ObservedHeight::new(entry.content_rect().height(), height, width) + }); + HEIGHTS.with(|heights| { + let mut heights = heights.borrow_mut(); + match size { + Some(size) => { + heights.insert( + id, + ObservedCard { + size, + style: element.get_attribute("style"), + class: element.get_attribute("class"), + }, + ); + } + None => { + heights.remove(&id); + } + } + }); + } + }); + let observer = web_sys::ResizeObserver::new(callback.as_ref().unchecked_ref()).ok()?; + Some(HeightObserver { observer, _callback: callback }) +} + +/// Remember a Home card's exact content height outside navigation, after +/// layout. Content-box observation follows the value we cache, including +/// content changes that leave the outer border box unchanged. +pub fn watch_height(element: &web_sys::Element) { + HEIGHT_OBSERVER.with(|observer| { + let Some(observer) = observer else { return }; + if height_id(element).is_some() { + return; + } + let id = NEXT_HEIGHT.with(|next| { + let id = next.get().wrapping_add(1); + next.set(id); + id + }); + if element.set_attribute(HEIGHT_ID, &id.to_string()).is_err() { + return; + } + observer.observer.observe(element); + }); +} + +pub fn unwatch_height(element: &web_sys::Element) { + HEIGHT_OBSERVER.with(|observer| { + if let Some(observer) = observer { + observer.observer.unobserve(element); + } + }); + if let Some(id) = height_id(element) { + HEIGHTS.with(|heights| heights.borrow_mut().remove(&id)); + let _ = element.remove_attribute(HEIGHT_ID); + } +} + +fn observed_height(element: &web_sys::HtmlElement) -> Option { + let id = height_id(element)?; + let observed = HEIGHTS.with(|heights| heights.borrow().get(&id).cloned())?; + // A late edge change can leave the border box unchanged while the + // observer's content-height delivery is still pending. In that case + // keep the CSS fallback rather than trusting the old content height. + let unchanged = + observed.style == element.get_attribute("style") && observed.class == element.get_attribute("class"); + let held = unchanged + .then(|| { + // Rounded offsetHeight cannot detect a late fractional change. A + // single exact box checks both dimensions and rejects transforms + // and responsive width changes alike. + let rect = element.get_bounding_client_rect(); + observed.size.for_box(rect.height(), rect.width()) + }) + .flatten(); + if held.is_none() { + HEIGHTS.with(|heights| heights.borrow_mut().remove(&id)); + } + held +} + +fn remember_retention_style(element: &web_sys::HtmlElement) { + let Some(id) = height_id(element) else { return }; + HEIGHTS.with(|heights| { + if let Some(observed) = heights.borrow_mut().get_mut(&id) { + // Only entries accepted above survive. Our own two style + // writes must not make their next reuse look like a late edge + // change, nor make an invalidated entry trustworthy again. + observed.style = element.get_attribute("style"); + observed.class = element.get_attribute("class"); + } + }); } /// Whether `path` is the front page's. @@ -112,6 +272,9 @@ fn take_out(slot: &web_sys::HtmlDivElement) { let taken = KEPT.with(|kept| { let mut kept = kept.borrow_mut(); let Some(kept) = kept.as_mut().filter(|kept| kept.out_since.is_none()) else { return false }; + // Every height read comes before animation or retention writes, + // so those writes cannot force another style/layout calculation. + let heights = measure_heights(slot); // What has come in stays in: put back, it would come in again. if let Ok(entered) = slot.query_selector_all(ENTERED) { for i in 0..entered.length() { @@ -120,7 +283,10 @@ fn take_out(slot: &web_sys::HtmlDivElement) { } } } - keep_heights(slot); + for (card, held) in heights { + let _ = card.style().set_property("contain-intrinsic-height", &format!("auto {held}px")); + remember_retention_style(&card); + } kept.view.unmount(); kept.out_since = Some(js_sys::Date::now()); true @@ -140,13 +306,14 @@ fn take_out(slot: &web_sys::HtmlDivElement) { /// 180 px, so a front page of 30 cards came back 2,800 px taller at 1280 px, /// and moved under the reader as its cards were laid out again, one screen /// at a time. With the heights kept, it's as it was from its first frame. -fn keep_heights(slot: &web_sys::Element) { - let Ok(cards) = slot.query_selector_all(".cast-card, .cast-list > .poll-card") else { return }; - // All read, then all kept: a height kept between two reads would have - // the page laid out again for the next. - let heights: Vec<_> = (0..cards.length()) +fn measure_heights(slot: &web_sys::Element) -> Vec<(web_sys::HtmlElement, f64)> { + let Ok(cards) = slot.query_selector_all(".cast-card, .cast-list > .poll-card") else { return Vec::new() }; + (0..cards.length()) .filter_map(|i| { let card = cards.item(i)?.dyn_into::().ok()?; + if let Some(held) = observed_height(&card) { + return Some((card, held)); + } let style = window().get_computed_style(&card).ok()??; let px = |property: &str| { let value = style.get_property_value(property).ok()?; @@ -166,8 +333,34 @@ fn keep_heights(slot: &web_sys::Element) { .try_fold(height, |left, edge| Some(left - px(edge)?))?; (held > 0.0).then_some((card, held)) }) - .collect(); - for (card, held) in heights { - let _ = card.style().set_property("contain-intrinsic-height", &format!("auto {held}px")); + .collect() +} + +#[cfg(test)] +mod tests { + use super::ObservedHeight; + + #[test] + fn observed_sizes_preserve_fractional_heights_and_require_both_exact_dimensions() { + let size = ObservedHeight::new(308.125, 350.125, 770.375).unwrap(); + assert_eq!(size.for_box(350.125, 770.375), Some(308.125)); + assert_eq!(size.for_box(350.375, 770.375), None, "same rounded offsetHeight is not enough"); + assert_eq!(size.for_box(350.125, 770.625), None, "a responsive width change invalidates it"); + assert_eq!(size.for_box(350.125 * 0.96, 770.375 * 0.96), None, "transforms retain the fallback"); + assert_eq!(size.for_box(f64::NAN, 770.375), None); + } + + #[test] + fn empty_unmounted_and_invalid_observations_are_not_kept() { + for (content, height, width) in [ + (0.0, 0.0, 0.0), + (100.0, 0.0, 200.0), + (100.0, 120.0, 0.0), + (121.0, 120.0, 200.0), + (f64::NAN, 120.0, 200.0), + (100.0, f64::INFINITY, 200.0), + ] { + assert!(ObservedHeight::new(content, height, width).is_none()); + } } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index faa3c186..6b4a706e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -219,7 +219,7 @@ Allthing counts Farcaster's daily active accounts as [SnapMeter](https://github. - posts open instantly from the feed's own data; - cards prefetch on hover and in idle time, within caps, and the cards in view have who liked and recasted them, and their replies, preloaded in batches; a link to a post or a channel fetches that page's data as it's pressed or touched, or once a mouse has rested on it for 100 ms (`split.rs`; not a profile's link, whose press opens the account's card); - `nav.rs` opens the page a link or a card leads to once the press has painted, not in the press's own task (`go`, and `InstantLinks` for every link the router would follow). A page with nothing to show yet (no data kept, nor a cast or person a page showed) waits for its data, and a page whose code is its own for that code, 250 ms at most, with the page left on screen and the press held; past that it opens with its placeholders. What's drawn anew (a post, a list's cards) rises a few pixels into place, readable from its first frame; there's no view transition, which delayed the next page by hundreds of milliseconds ([PERFORMANCE.md](PERFORMANCE.md#3-client-side-navigation)). It restores scroll on back and forward, and makes the whole card open the post; a quoted cast's card inside it opens the quoted cast, and media opens the media viewer. - - `kept.rs` keeps the front page while another page shows: drawn once, it's taken out of the page and put back as it was, so Back (or Home) shows it at once with its cards where they were, where drawing them again took 110 to 260 ms on a slow phone. Each card keeps the height it had: out of view, a card is laid out at the height it last had in view (`content-visibility: auto`), which it forgets out of the page, so put back without it the page was 1,600 to 2,800 px taller in its first frame. Out of the page it reads the address it last showed, so another page doesn't redraw it, and its popovers close; out for over 5 minutes, it's drawn anew. + - `kept.rs` keeps the front page while another page shows: drawn once, it's taken out of the page and put back as it was, so Back (or Home) shows it at once with its cards where they were, where drawing them again took 110 to 260 ms on a slow phone. Each card keeps the height it had: out of view, a card is laid out at the height it last had in view (`content-visibility: auto`), which it forgets out of the page, so put back without it the page was 1,600 to 2,800 px taller in its first frame. One shared `ResizeObserver` remembers mounted Home cards' exact content and border sizes after layout. Leaving Home verifies both current border dimensions and the card's style/class fingerprints before reusing a content height; missing or changed sizes, transforms and unsupported observers retain the exact CSS measurement fallback. All height reads precede animation and retention writes, and disposing a card removes its observation and cached size. Out of the page it reads the address it last showed, so another page doesn't redraw it, and its popovers close; out for over 5 minutes, it's drawn anew. - **Channel pages.** `pages/channel.rs` heads the feed with the channel's banner (a fixed 3:1 box, cover-cropped), its picture over the banner's edge, its name and `/id`, description, follower and member counts, link, lead and moderators (six, then "+N"), and puts the pinned cast above the feed. Each part shows only when the payload has it, so a channel known only to Hypersnap shows its name and how many accounts have cast there. - **Popovers.** `popover.rs` is one accessible popover (hover or focus after 280 ms, tap on touch, Escape, outside click). A panel a tap or a click opened, and that the pointer leaving doesn't close (any on a touch screen, and React's picker), is dismissed lightly: the first press outside it only closes it, in the window's capture phase, before the card under it can open its post, a link can be followed or another panel can open; a modal dialog opened from the panel keeps its own presses. One the pointer opened closes as it leaves, and a click elsewhere does what it does. `who.rs` uses it for who liked, recasted and replied; a like or recast count has its button as the popover's `lead`, which pointing at opens the preview too but whose click is its own, and a count of 0 is plain text (`idle`). It is also behind the profile's "Wallets" button, which lists every verified address with copy-to-clipboard, and the front page's client bar: once the cron has counted accounts by app, pointing at an app's segment or legend entry (or tapping it, or resting keyboard focus on it) opens its card, with its active accounts in the past 24 hours, its share of the trending casts and a link that shows only its casts. The segments are `aria-hidden` duplicates of the legend's links, which are the ones to tab to. The legend is one line, scrolling sideways where it doesn't fit, so the feed under it doesn't move as apps are read; its cards are placed against the viewport, so the line doesn't clip them. No native tooltips: a browser's `title` tooltip would be drawn over the solid popovers in the system's style, so nothing on a post, in a popover or around one has a `title` (a title on an element a panel sits inside, like the client bar, shows over the panel too). What one would say is read out instead (`aria-description`), or shown, as the post's signing key is in its meta line. - **Preloaded previews.** `preload.rs` loads who liked and recasted the feed cards in view, and their replies, before anyone hovers a count: the cards' shared prefetch observer reports them, and each card in view brings its run of six cards, in list order, as one `/api/v1/engagement` batch, from the counts each card carries (`data-likes`, `data-recasts`, `data-replies`). Then the next run brings its batch once its first card is less than a screen below the viewport and all its cards are on the page, so the reader finds it loaded. `api::preload_engagement` files each list under its own `/reactions?…&limit=10` path in the client cache, and each replies preview under its own `/replies?hash=…` path, dated as the batch. A preview reads that cache synchronously, so its people or replies are there in the frame the popover opens, hovered or tapped, with no "Loading…" (a resource would resolve a tick later). Limits: [PERFORMANCE.md](PERFORMANCE.md#popover-previews). diff --git a/e2e/kept-front.fixture.spec.ts b/e2e/kept-front.fixture.spec.ts index e7163ffb..6ad9108d 100644 --- a/e2e/kept-front.fixture.spec.ts +++ b/e2e/kept-front.fixture.spec.ts @@ -154,3 +154,160 @@ test("the front page kept keeps its sort, and another page's address doesn't red await expect(page).toHaveTitle("new · Allthing"); expect(asked.length).toBe(requests); }); + +// Observe real deliveries, or hold a later one to exercise the interval +// between a size change and its observer callback. No measurements are timed. +async function retentionProbe(page: Page, unsupported: boolean) { + await page.addInitScript((unsupported) => { + const state = { + armed: false, hold: false, observed: new WeakSet(), unobserved: new WeakSet(), + deliveries: new WeakMap(), content: new WeakMap(), + styles: [] as string[], pending: [] as Array<() => void>, expectedHeight: 0, + }; + (window as any).__retention = state; + const style = window.getComputedStyle.bind(window); + window.getComputedStyle = ((element, pseudo) => { + if (state.armed && element.matches(".kept-front .cast-card") && state.styles.length < 80) { + state.styles.push(element.getAttribute("data-hash")!); + } + return style(element, pseudo); + }) as typeof window.getComputedStyle; + if (unsupported) { + Object.defineProperty(window, "ResizeObserver", { value: undefined, configurable: true }); + return; + } + const NativeObserver = window.ResizeObserver; + window.ResizeObserver = class extends NativeObserver { + constructor(callback: ResizeObserverCallback) { + super((entries, observer) => { + const deliver = () => { + callback(entries, observer); + for (const entry of entries) if (entry.contentRect.height > 0) { + state.observed.add(entry.target); + state.deliveries.set(entry.target, (state.deliveries.get(entry.target) ?? 0) + 1); + state.content.set(entry.target, entry.contentRect.height); + } + }; + if (state.hold) state.pending.push(deliver); else deliver(); + }); + } + unobserve(element: Element) { + state.unobserved.add(element); + super.unobserve(element); + } + }; + }, unsupported); +} + +for (const mode of ["observed", "late fractional change", "content change within a fixed border", "late padding change within a fixed border", "without ResizeObserver"] as const) { + test(`Home retention preserves exact sizes with fewer navigation reads: ${mode}`, async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 900 }); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + await retentionProbe(page, mode === "without ResizeObserver"); + const asked = await serve(page); + await page.route("**/api/v1/home**", (route) => { + const later = new URL(route.request().url()).searchParams.has("offset"); + asked.push(later ? "home later" : "home"); + return route.fulfill({ json: { pool: later ? [] : feed.slice(0, 3).map((cast) => ({ ...cast, ts: cast.ts - 7200 })), notes: {}, meta } }); + }); + await page.goto("/"); + await appCame(page); + const cards = page.locator("#app .cast-list .cast-card"); + await expect(cards).toHaveCount(3); + await cards.evaluateAll((cards) => { + (window as any).__retainedCards = cards; + for (const card of cards) (card as HTMLElement).style.height = "350.125px"; + }); + await expect.poll(() => cards.evaluateAll((cards) => cards.every((card) => card.getAnimations().length === 0))).toBe(true); + if (mode !== "without ResizeObserver") { + await expect.poll(() => cards.evaluateAll((cards) => cards.every((card) => (window as any).__retention.observed.has(card)))).toBe(true); + } + let expectedContent: number | undefined; + if (mode === "content change within a fixed border") { + const delivered = await cards.first().evaluate((card) => (window as any).__retention.deliveries.get(card) as number); + await cards.first().evaluate((card) => { + const padding = parseFloat(getComputedStyle(card).paddingTop); + (card as HTMLElement).style.paddingTop = `${padding + 8}px`; + }); + // The fixed outer height stays the same; the cached content height + // changes, so content-box observation must deliver another value. + await expect.poll(() => cards.first().evaluate((card) => (window as any).__retention.deliveries.get(card) as number)).toBeGreaterThan(delivered); + expectedContent = await cards.first().evaluate((card) => (window as any).__retention.content.get(card) as number); + } + if (mode === "late padding change within a fixed border") { + expectedContent = await cards.first().evaluate((card) => (window as any).__retention.content.get(card) - 8); + } + const requests = asked.length; + await page.evaluate((mode) => { + const state = (window as any).__retention; + state.hold = mode.startsWith("late "); + document.addEventListener("click", () => { + const first = (window as any).__retainedCards[0] as HTMLElement; + if (mode === "late fractional change") first.style.height = "350.375px"; + if (mode === "late padding change within a fixed border") first.style.paddingTop = `${parseFloat(getComputedStyle(first).paddingTop) + 8}px`; + state.expectedHeight = document.documentElement.scrollHeight; + state.armed = true; + }, { capture: true, once: true }); + }, mode); + await cards.first().locator(".cast-text").click(); + await expect(page.locator("#app .post .post-text")).toBeVisible(); + const read = await page.evaluate(() => (window as any).__retention.styles as string[]); + expect(read).toEqual(mode === "without ResizeObserver" ? feed.slice(0, 3).map((cast) => cast.hash) + : mode.startsWith("late ") ? [feed[0].hash] : []); + await page.goBack(); + await expect(page).toHaveURL(/\/$/); + await expect(cards).toHaveCount(3); + const restored = await cards.evaluateAll((cards) => ({ + same: cards.every((card, i) => card === (window as any).__retainedCards[i]), + heights: cards.map((card) => card.getBoundingClientRect().height), + height: document.documentElement.scrollHeight, expected: (window as any).__retention.expectedHeight, + })); + expect(restored.same).toBe(true); + expect(restored.heights).toEqual([mode === "late fractional change" ? 350.375 : 350.125, 350.125, 350.125]); + expect(restored.height).toBe(restored.expected); + if (expectedContent !== undefined) { + const retained = await cards.first().evaluate((card) => parseFloat((card as HTMLElement).style.containIntrinsicHeight.replace(/^auto /, ""))); + expect(retained).toBe(expectedContent); + } + expect(asked.length).toBe(requests); + expect(errors).toEqual([]); + if (mode === "observed") { + // Retention writes changed the inline style. A second trip must + // still reuse valid observations, with no CSS fallback or new nodes. + await page.evaluate(() => { + const state = (window as any).__retention; + state.armed = false; + state.styles = []; + document.addEventListener("click", () => { + state.expectedHeight = document.documentElement.scrollHeight; + state.armed = true; + }, { capture: true, once: true }); + }); + await cards.first().locator(".cast-text").click(); + await expect(page.locator("#app .post .post-text")).toBeVisible(); + expect(await page.evaluate(() => (window as any).__retention.styles)).toEqual([]); + await page.goBack(); + await expect(page).toHaveURL(/\/$/); + await expect(cards).toHaveCount(3); + const again = await cards.evaluateAll((cards) => ({ + same: cards.every((card, i) => card === (window as any).__retainedCards[i]), + heights: cards.map((card) => card.getBoundingClientRect().height), + height: document.documentElement.scrollHeight, expected: (window as any).__retention.expectedHeight, + })); + expect(again.same).toBe(true); + expect(again.heights).toEqual([350.125, 350.125, 350.125]); + expect(again.height).toBe(again.expected); + expect(asked.length).toBe(requests); + } + if (mode !== "without ResizeObserver") { + // Taking Home out preserves its owners; replacing its list disposes + // them and must release their observer registrations and cached IDs. + expect(await page.evaluate(() => (window as any).__retainedCards.some((card: Element) => (window as any).__retention.unobserved.has(card)))).toBe(false); + await page.getByRole("combobox", { name: "Time range" }).selectOption("1h"); + await expect(cards).toHaveCount(0); + await expect.poll(() => page.evaluate(() => (window as any).__retainedCards.every((card: Element) => + (window as any).__retention.unobserved.has(card) && !card.hasAttribute("data-kept-size")))).toBe(true); + } + }); +} From ea6e9f4ba4cb2882884581f7ed8ab3b6c4b448ce Mon Sep 17 00:00:00 2001 From: ael-dev3 <183124839+ael-dev3@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:37:00 +0200 Subject: [PATCH 4/9] docs: record post-opening performance diagnostics --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3f2367d..bc5cd56b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,8 @@ All notable changes to Allthing are recorded here. The format follows [Keep a Ch ### Development +- Post-opening performance checks retain bounded, sanitized hover, resource, frame, long-task and animation-frame diagnostics for each attempt, including failures. The click-to-post measurement, prefetch wait, sample count and strict 100 ms limit are unchanged. + - Pin the current Miniflare development dependency's Undici to 7.30.1, fixing its [EventSource memory](https://github.com/advisories/GHSA-4qv8-2p9j-2685), [integrity-check](https://github.com/advisories/GHSA-f72v-6w45-q472) and [content-encoding](https://github.com/advisories/GHSA-xjj6-f33q-929c) advisories. The version-scoped override leaves Wrangler, Miniflare and workerd unchanged; Wrangler 4.152.0 also still pins an affected version. - Smoothness checks retain bounded, sanitized raw input, long-task, animation-frame and frame diagnostics after each profile closes, including on failure. Metric inputs, strict checks and timing limits are unchanged. From 32c7f099f6f277ff2ac32b214b9b5c9e91cc396f Mon Sep 17 00:00:00 2001 From: ael-dev3 <183124839+ael-dev3@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:44:39 +0200 Subject: [PATCH 5/9] docs(perf): explain bounded post-opening evidence --- docs/PERFORMANCE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md index cc732d30..6f7b6282 100644 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -17,6 +17,8 @@ The CI `perf` job runs `e2e/perf.spec.ts` against https://allthing.social after Each cold-home sample logs its first-card and LCP times, the LCP element and image dimensions/loading hints, navigation and resource phases, cache/Server-Timing metadata, and main-thread long tasks. The JSON attachment `home-lcp-diagnostics.json` is retained in the `playwright-performance` Actions artifact for seven days, even when readiness or the budget fails. URLs retain only origin and pathname; credentials, query strings, fragments and Server-Timing descriptions are omitted. Cross-origin timing phases can be zero when the image host sends no Timing-Allow-Origin header. These observations add no warm-up fetch, wait, cache override or resource priority change: the same three cold contexts and 1.5-second median budget apply. +The post-opening check also retains `post-open-diagnostics.json` for each attempt, including failures, in that artifact. It records the selected cast's hover and completed resource phases, up to 64 existing measurement frames, 64 overlapping long tasks, 32 long animation frames with four scripts each, and 128 resources, prioritizing the selected post and its code. Counts report omitted entries; in-flight or evicted resource entries are unavailable. URLs omit credentials, query strings and fragments. Collection and sanitization finish after the original assertions; the probe adds some instrumentation cost but no extra frame loop, request or readiness wait. The same third card, 600 ms hover, click capture, post-text frame check, replies assertion and strict 100 ms limit apply. + ## Smoothness CI runs functional fixture files with two workers, then runs `smoothness.fixture.spec.ts` alone with one worker (`scripts/test-browser-fixtures.sh`). Both phases run even if the first fails, and either failure fails the existing browser job. Their HTML reports and test results are kept separately, including from the later static checks; the production performance job, test retries, timing assertions and 45-minute job cap are unchanged. From 7e58ef02f87fe9d6d76656f23ba46141b5d4e543 Mon Sep 17 00:00:00 2001 From: ael-dev3 <183124839+ael-dev3@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:45:55 +0200 Subject: [PATCH 6/9] test(home): await current size observations before retention --- e2e/kept-front.fixture.spec.ts | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/e2e/kept-front.fixture.spec.ts b/e2e/kept-front.fixture.spec.ts index 6ad9108d..e66d9e95 100644 --- a/e2e/kept-front.fixture.spec.ts +++ b/e2e/kept-front.fixture.spec.ts @@ -160,7 +160,7 @@ test("the front page kept keeps its sort, and another page's address doesn't red async function retentionProbe(page: Page, unsupported: boolean) { await page.addInitScript((unsupported) => { const state = { - armed: false, hold: false, observed: new WeakSet(), unobserved: new WeakSet(), + armed: false, hold: false, observed: new WeakMap(), unobserved: new WeakSet(), deliveries: new WeakMap(), content: new WeakMap(), styles: [] as string[], pending: [] as Array<() => void>, expectedHeight: 0, }; @@ -183,7 +183,9 @@ async function retentionProbe(page: Page, unsupported: boolean) { const deliver = () => { callback(entries, observer); for (const entry of entries) if (entry.contentRect.height > 0) { - state.observed.add(entry.target); + const border = entry.borderBoxSize[0]; + state.observed.set(entry.target, { height: border.blockSize, width: border.inlineSize, + style: entry.target.getAttribute("style"), class: entry.target.getAttribute("class") }); state.deliveries.set(entry.target, (state.deliveries.get(entry.target) ?? 0) + 1); state.content.set(entry.target, entry.contentRect.height); } @@ -211,8 +213,17 @@ for (const mode of ["observed", "late fractional change", "content change within asked.push(later ? "home later" : "home"); return route.fulfill({ json: { pool: later ? [] : feed.slice(0, 3).map((cast) => ({ ...cast, ts: cast.ts - 7200 })), notes: {}, meta } }); }); + // This deliberately short Hot feed exhausts the existing ten-page + // source budget while its sentinel is in view. Snapshot requests only + // after that real background work has finished, before testing Back. + const lastPage = page.waitForResponse((response) => { + const url = new URL(response.url()); + return url.pathname === "/api/v1/home" && url.searchParams.get("offset") === "900"; + }); await page.goto("/"); await appCame(page); + await lastPage; + await expect(page.locator("#app .cast-list[aria-busy='true']")).toHaveCount(0); const cards = page.locator("#app .cast-list .cast-card"); await expect(cards).toHaveCount(3); await cards.evaluateAll((cards) => { @@ -221,7 +232,12 @@ for (const mode of ["observed", "late fractional change", "content change within }); await expect.poll(() => cards.evaluateAll((cards) => cards.every((card) => card.getAnimations().length === 0))).toBe(true); if (mode !== "without ResizeObserver") { - await expect.poll(() => cards.evaluateAll((cards) => cards.every((card) => (window as any).__retention.observed.has(card)))).toBe(true); + await expect.poll(() => cards.evaluateAll((cards) => cards.every((card) => { + const observed = (window as any).__retention.observed.get(card); + const rect = card.getBoundingClientRect(); + return observed?.height === 350.125 && observed.width === rect.width + && observed.style === card.getAttribute("style") && observed.class === card.getAttribute("class"); + }))).toBe(true); } let expectedContent: number | undefined; if (mode === "content change within a fixed border") { @@ -304,7 +320,7 @@ for (const mode of ["observed", "late fractional change", "content change within // Taking Home out preserves its owners; replacing its list disposes // them and must release their observer registrations and cached IDs. expect(await page.evaluate(() => (window as any).__retainedCards.some((card: Element) => (window as any).__retention.unobserved.has(card)))).toBe(false); - await page.getByRole("combobox", { name: "Time range" }).selectOption("1h"); + await page.getByRole("combobox", { name: "Time range" }).selectOption({ label: "Past hour" }); await expect(cards).toHaveCount(0); await expect.poll(() => page.evaluate(() => (window as any).__retainedCards.every((card: Element) => (window as any).__retention.unobserved.has(card) && !card.hasAttribute("data-kept-size")))).toBe(true); From 546783514272e81492f56ffaa5736d8490a460f4 Mon Sep 17 00:00:00 2001 From: ael-dev3 <183124839+ael-dev3@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:33:01 +0200 Subject: [PATCH 7/9] fix(home): keep retention cache keys out of card markup --- crates/allthing-web/src/kept.rs | 15 +++++++-------- e2e/kept-front.fixture.spec.ts | 10 ++++++++-- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/crates/allthing-web/src/kept.rs b/crates/allthing-web/src/kept.rs index d2b73e1e..fddeb8a1 100644 --- a/crates/allthing-web/src/kept.rs +++ b/crates/allthing-web/src/kept.rs @@ -44,13 +44,14 @@ struct Kept { thread_local! { static KEPT: RefCell> = const { RefCell::new(None) }; static HEIGHT_OBSERVER: Option = height_observer(); + // Private keys leave hydrated card markup identical to early paint. + static HEIGHT_IDS: js_sys::WeakMap = js_sys::WeakMap::new(); static HEIGHTS: RefCell> = RefCell::new(HashMap::new()); static NEXT_HEIGHT: Cell = const { Cell::new(0) }; } -/// One opaque ID, rather than reading and parsing every CSS edge during a -/// navigation. Only mounted Home cards register, and cleanup removes them. -const HEIGHT_ID: &str = "data-kept-size"; +// Opaque IDs connect weak element keys to Rust measurements without adding +// attributes or properties to cards. Only mounted Home cards register. const SIZE_TOLERANCE: f64 = 0.000_001; #[derive(Clone, Copy, Debug)] @@ -89,7 +90,7 @@ struct HeightObserver { } fn height_id(element: &web_sys::Element) -> Option { - element.get_attribute(HEIGHT_ID)?.parse().ok() + HEIGHT_IDS.with(|ids| ids.get_checked(element.unchecked_ref()).and_then(|id| id.as_string())?.parse().ok()) } fn height_observer() -> Option { @@ -148,9 +149,7 @@ pub fn watch_height(element: &web_sys::Element) { next.set(id); id }); - if element.set_attribute(HEIGHT_ID, &id.to_string()).is_err() { - return; - } + HEIGHT_IDS.with(|ids| ids.set(element.unchecked_ref(), &id.to_string().into())); observer.observer.observe(element); }); } @@ -163,8 +162,8 @@ pub fn unwatch_height(element: &web_sys::Element) { }); if let Some(id) = height_id(element) { HEIGHTS.with(|heights| heights.borrow_mut().remove(&id)); - let _ = element.remove_attribute(HEIGHT_ID); } + HEIGHT_IDS.with(|ids| ids.delete(element.unchecked_ref())); } fn observed_height(element: &web_sys::HtmlElement) -> Option { diff --git a/e2e/kept-front.fixture.spec.ts b/e2e/kept-front.fixture.spec.ts index e66d9e95..9d82f6c6 100644 --- a/e2e/kept-front.fixture.spec.ts +++ b/e2e/kept-front.fixture.spec.ts @@ -239,6 +239,9 @@ for (const mode of ["observed", "late fractional change", "content change within && observed.style === card.getAttribute("style") && observed.class === card.getAttribute("class"); }))).toBe(true); } + // The retention cache is private: hydrated cards retain the same + // markup as early paint, including while observations are active. + await expect(page.locator("#app [data-kept-size]")).toHaveCount(0); let expectedContent: number | undefined; if (mode === "content change within a fixed border") { const delivered = await cards.first().evaluate((card) => (window as any).__retention.deliveries.get(card) as number); @@ -280,6 +283,7 @@ for (const mode of ["observed", "late fractional change", "content change within height: document.documentElement.scrollHeight, expected: (window as any).__retention.expectedHeight, })); expect(restored.same).toBe(true); + await expect(page.locator("#app [data-kept-size]")).toHaveCount(0); expect(restored.heights).toEqual([mode === "late fractional change" ? 350.375 : 350.125, 350.125, 350.125]); expect(restored.height).toBe(restored.expected); if (expectedContent !== undefined) { @@ -312,18 +316,20 @@ for (const mode of ["observed", "late fractional change", "content change within height: document.documentElement.scrollHeight, expected: (window as any).__retention.expectedHeight, })); expect(again.same).toBe(true); + await expect(page.locator("#app [data-kept-size]")).toHaveCount(0); expect(again.heights).toEqual([350.125, 350.125, 350.125]); expect(again.height).toBe(again.expected); expect(asked.length).toBe(requests); } if (mode !== "without ResizeObserver") { // Taking Home out preserves its owners; replacing its list disposes - // them and must release their observer registrations and cached IDs. + // them and must release their actual observer registrations. expect(await page.evaluate(() => (window as any).__retainedCards.some((card: Element) => (window as any).__retention.unobserved.has(card)))).toBe(false); await page.getByRole("combobox", { name: "Time range" }).selectOption({ label: "Past hour" }); await expect(cards).toHaveCount(0); await expect.poll(() => page.evaluate(() => (window as any).__retainedCards.every((card: Element) => - (window as any).__retention.unobserved.has(card) && !card.hasAttribute("data-kept-size")))).toBe(true); + (window as any).__retention.unobserved.has(card)))).toBe(true); + await expect(page.locator("#app [data-kept-size]")).toHaveCount(0); } }); } From ae5216be82a2fb5e241f7bdcae0b7aec8f583a04 Mon Sep 17 00:00:00 2001 From: ael-dev3 <183124839+ael-dev3@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:36:46 +0200 Subject: [PATCH 8/9] fix(popover): dispose previews when leaving their page --- CHANGELOG.md | 1 + crates/allthing-web/src/components/popover.rs | 7 ++- e2e/popover-lifecycle.fixture.spec.ts | 43 ++++++++++++++++++- 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc5cd56b..04785f14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ All notable changes to Allthing are recorded here. The format follows [Keep a Ch ### Fixed +- Leaving a page closes its popovers immediately, disposing nested previews as the kept feed detaches; Back does not restore fading panels while closing timers are delayed. - Leaving Home reuses observed card heights when their exact current sizes still match, reducing layout reads before a post opens while preserving Back geometry and the existing fallback for late size changes or unsupported browsers. - A hover card dismissed with Escape cannot reopen when the pointer re-enters during its closing animation. - A data center with no default 24-hour Home copy, or an expired retained one, can use a fresh public copy shared by another data center, preserving its original age and current time bounds, instead of waiting for the full upstream build. Missing or invalid copies retain the normal fallback, ordinary background refreshes still build normally, and other ranges and private feeds are unchanged. diff --git a/crates/allthing-web/src/components/popover.rs b/crates/allthing-web/src/components/popover.rs index 6965e64f..42463a97 100644 --- a/crates/allthing-web/src/components/popover.rs +++ b/crates/allthing-web/src/components/popover.rs @@ -280,7 +280,12 @@ pub fn Popover( Effect::new(move |seen: Option| { let now = pathname.get(); if seen.is_some_and(|seen| seen != now) { - close(); + // The old page is leaving, so there is no closing animation to + // show. Dispose nested previews now, including their dwell + // timers and window listeners, as the kept page detaches. + cancel(); + pressing.set_value(false); + state.set(State::Closed); } now }); diff --git a/e2e/popover-lifecycle.fixture.spec.ts b/e2e/popover-lifecycle.fixture.spec.ts index b3878f93..c36a4961 100644 --- a/e2e/popover-lifecycle.fixture.spec.ts +++ b/e2e/popover-lifecycle.fixture.spec.ts @@ -41,7 +41,7 @@ test("Escape closes a keyboard popover and returns focus to its trigger", async test("navigating from an open popover removes its window click listener", async ({ page }) => { const errors: string[] = []; - page.on("pageerror", (error) => errors.push(error.message)); + page.on("pageerror", (error) => errors.push(error.stack ?? error.message)); await page.addInitScript(() => { const tracked = (window as TrackedWindow).activeClickListeners = new Set(); const add = window.addEventListener.bind(window); @@ -86,7 +86,7 @@ test("navigating from an open popover removes its window click listener", async test("unmounting an open popover removes its window click listener", async ({ page }) => { const errors: string[] = []; - page.on("pageerror", (error) => errors.push(error.message)); + page.on("pageerror", (error) => errors.push(error.stack ?? error.message)); await page.goto("/channels"); await page.getByRole("banner").getByRole("link", { name: "Home", exact: true }).click(); await page.getByRole("button", { name: "10 likes: show who", exact: true }).hover(); @@ -96,3 +96,42 @@ test("unmounting an open popover removes its window click listener", async ({ pa await page.locator("body").click({ position: { x: 5, y: 5 } }); expect(errors).toEqual([]); }); + +// Background tabs can delay the closing-animation timer. Returning to a +// kept page must not restore a fading panel or its nested profile preview. +test("Back after leaving nested previews does not wait for their closing animations", async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", error => errors.push(error.stack ?? error.message)); + await page.addInitScript(() => { + const schedule = window.setTimeout.bind(window); + const clear = window.clearTimeout.bind(window); + const held = new Map void>(); + window.setTimeout = ((handler: TimerHandler, delay?: number, ...args: unknown[]) => { + if (delay !== 120 || typeof handler !== "function") return schedule(handler, delay, ...args); + const id = schedule(() => { held.delete(id); handler(...args); }, 60_000); + held.set(id, () => { clear(id); handler(...args); }); + return id; + }) as typeof window.setTimeout; + window.clearTimeout = ((id?: number) => { if (id !== undefined) held.delete(id); clear(id); }) as typeof window.clearTimeout; + (window as Window & { finishHeldClosings: () => void }).finishHeldClosings = () => { + const callbacks = [...held.values()]; + held.clear(); + for (const callback of callbacks) callback(); + }; + }); + await page.goto("/"); + await page.getByRole("button", { name: "10 likes: show who", exact: true }).hover(); + const likes = page.getByRole("dialog", { name: "Liked by", exact: true }); + const reader = likes.locator("a.who-link"); + await reader.hover(); + await expect(page.getByRole("dialog", { name: "Profile: reader (@reader)", exact: true })).toBeVisible(); + await reader.click(); + await expect(page).toHaveURL(/\/u\/reader$/); + await expect(page.getByRole("region", { name: "Profile", exact: true })).toBeVisible(); + await page.goBack(); + await expect(page.getByRole("button", { name: "10 likes: show who", exact: true })).toBeVisible(); + await expect(page.locator(".popover-panel")).toHaveCount(0); + await page.evaluate(() => (window as Window & { finishHeldClosings: () => void }).finishHeldClosings()); + await page.locator("body").click({ position: { x: 5, y: 5 } }); + expect(errors).toEqual([]); +}); From ccac9a8a18a773fe5b2b776f6fea10630157200a Mon Sep 17 00:00:00 2001 From: ael-dev3 <183124839+ael-dev3@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:22:37 +0200 Subject: [PATCH 9/9] fix(writes): retain dialog semantics and fast follow counts --- CHANGELOG.md | 1 + crates/allthing-web/src/components/chips.rs | 2 +- crates/allthing-web/src/components/writer.rs | 27 ++++++++--- e2e/early-paint.fixture.spec.ts | 48 +++++++++++++------- e2e/handoff-lands.fixture.spec.ts | 30 ++++++++++++ 5 files changed, 85 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 04785f14..061360ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ All notable changes to Allthing are recorded here. The format follows [Keep a Ch ### Fixed +- Write buttons retain their dialog accessibility attributes during permission discovery and key approval. A follow confirmed on the first hand-off check preserves its new follower count when the reader was known not to follow beforehand. - Leaving a page closes its popovers immediately, disposing nested previews as the kept feed detaches; Back does not restore fading panels while closing timers are delayed. - Leaving Home reuses observed card heights when their exact current sizes still match, reducing layout reads before a post opens while preserving Back geometry and the existing fallback for late size changes or unsupported browsers. - A hover card dismissed with Escape cannot reopen when the pointer re-enters during its closing animation. diff --git a/crates/allthing-web/src/components/chips.rs b/crates/allthing-web/src/components/chips.rs index 82f41aaf..95b5e6e8 100644 --- a/crates/allthing-web/src/components/chips.rs +++ b/crates/allthing-web/src/components/chips.rs @@ -310,7 +310,7 @@ fn acting_stat( // hands off (what the reader did in the app shows); signed // out, only a way to the app. aria-pressed=move || (!hands_off.get() || shows_mine.get()).then(|| mine.get().to_string()) - aria-haspopup=move || hands_off.get().then_some("dialog") + aria-haspopup=move || writer.opens_dialog().then_some("dialog") aria-busy=move || busy.get().then_some("true") on:click=move |_| writer.toggle(target, reaction, ask(mine.get_untracked()), shown.get_untracked()) > diff --git a/crates/allthing-web/src/components/writer.rs b/crates/allthing-web/src/components/writer.rs index ed80a940..17d9a070 100644 --- a/crates/allthing-web/src/components/writer.rs +++ b/crates/allthing-web/src/components/writer.rs @@ -429,6 +429,12 @@ impl Writer { self.can.get() == Can::No } + /// A write without an approved key opens a dialog, including while + /// permission discovery is pending and when approval is still needed. + pub fn opens_dialog(self) -> bool { + self.can.get() != Can::Yes + } + /// Whether what the reader liked and recasted shows: signed in, once /// it's known whether Allthing writes for them (with a key, or in the /// Farcaster app). @@ -1250,6 +1256,12 @@ impl Writer { cast.id = run; } let about = expect.about(); + // A first read may land after the reader acts in the other app. + // A known opposite state before this hand-off still makes it new. + let follow_was_done = match &expect { + Expect::Follow { fid, on } => self.mine.with_untracked(|m| m.following(*fid).map(|was| was == *on)), + _ => None, + }; // A newer hand-off of the same, taken back, goes back to where the // first began. let earlier = UNDO.with(|u| u.borrow_mut().remove(&about)).map(|(_, undo)| undo); @@ -1289,7 +1301,7 @@ impl Writer { if let Some(undo) = earlier.or(undo) { UNDO.with(|u| u.borrow_mut().insert(about.clone(), (run, undo))); } - self.watch(expect, url, run); + self.watch(expect, url, run, follow_was_done); (about, run) } @@ -1343,10 +1355,11 @@ impl Writer { /// view. Once it's there, it shows as done, the dialog closes if it's /// still open, and a notice says so. Found at once, before the reader /// can have done anything, a like or a follow was so already, and the - /// page was behind (a like older than their latest). A newer hand-off of + /// page was behind (a like older than their latest), unless a follow + /// was known to be opposite before handing it off. A newer hand-off of /// the same takes its place; signing out ends it; not done in time, what /// shows is taken back. - fn watch(self, expect: Expect, url: String, run: u64) { + fn watch(self, expect: Expect, url: String, run: u64, follow_was_done: Option) { let Some(reader) = self.fid() else { return }; let about = expect.about(); WATCHES.with(|w| w.borrow_mut().insert(about.clone(), run)); @@ -1373,7 +1386,9 @@ impl Writer { && current(&about) { UNDO.with(|u| u.borrow_mut().remove(&about)); - let already = first && matches!(expect, Expect::Reaction { .. } | Expect::Follow { .. }); + let already = first + && follow_was_done != Some(false) + && matches!(expect, Expect::Reaction { .. } | Expect::Follow { .. }); if already { self.was_so(&expect, &about) } else { @@ -2687,7 +2702,7 @@ pub fn FollowButton(fid: u64, username: String) -> impl IntoView { class:unknown=move || writer.reader().is_some() && known.get().is_none() aria-label=label.clone() aria-pressed=move || writer.reader().is_some().then(|| following().to_string()) - aria-haspopup=move || writer.hands_off().then_some("dialog") + aria-haspopup=move || writer.opens_dialog().then_some("dialog") aria-busy=move || writer.follow_busy(fid).then_some("true") on:click=move |_| writer.toggle_follow(fid, Ask::follow(url.clone(), &username, following())) > @@ -2755,7 +2770,7 @@ pub fn DeleteButton(hash: String, author: u64, username: String, noun: &'static class="reply-button delete-button" disabled=busy aria-busy=move || busy.get().then_some("true") - aria-haspopup=move || writer.hands_off().then_some("dialog") + aria-haspopup=move || writer.opens_dialog().then_some("dialog") on:click=move |_| { if writer.hands_off() { writer.delete(hash.clone(), Ask::delete(url.clone(), noun)); diff --git a/e2e/early-paint.fixture.spec.ts b/e2e/early-paint.fixture.spec.ts index 2b373e0b..e55a9911 100644 --- a/e2e/early-paint.fixture.spec.ts +++ b/e2e/early-paint.fixture.spec.ts @@ -332,22 +332,38 @@ for (const width of [1280, 390]) { await serve(page, { ...payload, pool: pool.filter((c) => c.ts > now - 20 * HOUR) }); await page.route("**/api/v1/notifications**", (route) => route.fulfill({ json: { groups: [], meta }, headers: { "cache-control": "private, no-store" } })); await signIn(page, { fid: 1234, username: "reader", display_name: "Reader" }); - // The reader follows dee, as kept from their Following tab (followed.rs). - await page.addInitScript(() => localStorage.setItem("allthing.followed", JSON.stringify({ reader: 1234, at: Date.now(), fids: [4] }))); - await page.goto("/"); - const boot = page.locator("#boot"); - // Hot, dee followed: dee's quiet pair, 20 times its 5 points, goes first. - await expect(boot.locator(".cast-card").first()).toHaveAttribute("data-hash", pair.hash); - await expect(boot.getByRole("link", { name: "For you", includeHidden: true })).toHaveAttribute("aria-current", "page"); - await expect(boot.locator(".feed-tabs a")).toHaveCount(2); - await moduleIn(page, held); - await settled(page, "#boot"); - const early = await shown(page, "#boot"); - held.js("continue"); - await expect(boot).toHaveCount(0); - await expect(page.locator("#app .cast-card").first()).toBeVisible(); - // The app's tabs and cards, in the same boxes: nothing moves. - expect((await shown(page, "#app")).slice(0, early.length)).toEqual(early); + // Permission discovery may still be pending when hydration finishes. + // Early and mounted cards must describe the same dialog action then. + let releaseSigner!: () => void; + const signer = new Promise(resolve => { releaseSigner = resolve; }); + await page.route("**/api/v1/signer", async route => { + await signer; + await route.fulfill({ status: 404, json: { error: "not set up" } }); + }); + try { + // The reader follows dee, as kept from their Following tab (followed.rs). + await page.addInitScript(() => localStorage.setItem("allthing.followed", JSON.stringify({ reader: 1234, at: Date.now(), fids: [4] }))); + await page.goto("/"); + const boot = page.locator("#boot"); + // Hot, dee followed: dee's quiet pair, 20 times its 5 points, goes first. + await expect(boot.locator(".cast-card").first()).toHaveAttribute("data-hash", pair.hash); + await expect(boot.getByRole("link", { name: "For you", includeHidden: true })).toHaveAttribute("aria-current", "page"); + await expect(boot.locator(".feed-tabs a")).toHaveCount(2); + await moduleIn(page, held); + await settled(page, "#boot"); + const early = await shown(page, "#boot"); + held.js("continue"); + await expect(boot).toHaveCount(0); + await expect(page.locator("#app .cast-card").first()).toBeVisible(); + // The app's tabs and cards, in the same boxes: nothing moves. + expect((await shown(page, "#app")).slice(0, early.length)).toEqual(early); + const permissionsRead = page.waitForResponse("**/api/v1/signer"); + releaseSigner(); + await permissionsRead; + await expect(page.locator("#app .cast-card").first().getByRole("button", { name: "Like", exact: true })).toHaveAttribute("aria-haspopup", "dialog"); + } finally { + releaseSigner(); + } }); } diff --git a/e2e/handoff-lands.fixture.spec.ts b/e2e/handoff-lands.fixture.spec.ts index 8fece717..dc6b553c 100644 --- a/e2e/handoff-lands.fixture.spec.ts +++ b/e2e/handoff-lands.fixture.spec.ts @@ -256,3 +256,33 @@ test("signed out, a hand-off asks the node nothing", async ({ page }) => { expect(asked.reaction).toBe(0); await expect(likes(page, theirs.hash)).not.toHaveClass(/\bmine\b/); }); + +test("a follow completed before the first handoff check keeps its new follower count", async ({ page }) => { + await signIn(page, me); + await serve(page, { liked: false, following: false, deleted: false }); + let handedOff = false; + let release!: () => void; + const landed = new Promise(resolve => { release = resolve; }); + await page.route(`${NODE}/linkById?*`, async route => { + if (!handedOff) return route.fulfill(notFound("link")); + await landed; + await route.fulfill({ headers: cors, json: { data: { type: "MESSAGE_TYPE_LINK_ADD", fid: me.fid, linkBody: { type: "follow", targetFid: alice.fid } } } }); + }); + try { + await page.goto("/u/alice"); + const follow = page.getByRole("button", { name: "Follow @alice", exact: true }); + await expect(follow).not.toHaveClass(/\bunknown\b/); + await expect(follow).toHaveAttribute("aria-pressed", "false"); + handedOff = true; + await follow.click(); + await expect(page.getByRole("dialog", { name: "Follow @alice in Farcaster" })).toBeVisible(); + await expect(page.getByText("13 followers", { exact: true })).toBeVisible(); + release(); + await expect(follow).not.toHaveClass(/\bpending\b/); + await expect(follow).toContainText("Following"); + await expect(page.getByText("13 followers", { exact: true })).toBeVisible(); + await expect(page.getByRole("status").filter({ hasText: "Following." })).toBeVisible(); + } finally { + release(); + } +});