From d5d56413f5bbe1a68fc72fb38719f65aeb66c850 Mon Sep 17 00:00:00 2001 From: ael-dev3 <183124839+ael-dev3@users.noreply.github.com> Date: Sat, 24 Oct 2026 17:59:25 +0200 Subject: [PATCH 1/2] fix(web): retain presence refreshes during in-flight beats --- CHANGELOG.md | 3 ++ .../allthing-web/src/components/presence.rs | 8 ++++ e2e/allthing.spec.ts | 12 +++++- e2e/presence.fixture.spec.ts | 22 ++++++++++ e2e/profile-website.fixture.spec.ts | 40 ++++++++++++++++++- 5 files changed, 81 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 061360ab..7f27c722 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 +- Changing presence visibility or session while a heartbeat is in flight queues one refresh with the latest state, so the update is not lost behind the older response. - 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. @@ -36,6 +37,8 @@ All notable changes to Allthing are recorded here. The format follows [Keep a Ch ### Development +- Live sort checks wait for the interactive app; deep Back checks use Past week so posts aging out of a short range cannot masquerade as lost retained cards. +- Profile website checks retain bounded browser errors and failed-request diagnostics when the app fails to leave its boot shell. - 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. diff --git a/crates/allthing-web/src/components/presence.rs b/crates/allthing-web/src/components/presence.rs index 60cddace..76b99dcf 100644 --- a/crates/allthing-web/src/components/presence.rs +++ b/crates/allthing-web/src/components/presence.rs @@ -89,6 +89,9 @@ thread_local! { static JITTER: Cell = const { Cell::new(0.0) }; /// A beat of this tab's is on its way. static BEATING: Cell = const { Cell::new(false) }; + /// A reader changed visibility or session while a beat was in flight. + /// Coalesce changes, then send the latest state after that beat finishes. + static REFRESH_PENDING: Cell = const { Cell::new(false) }; /// The first beat's wait is over. static STARTED: Cell = const { Cell::new(false) }; } @@ -176,6 +179,9 @@ impl Presence { /// again when the turn comes. Nothing while the tab is hidden, offline /// or idle: the listeners look again. fn tick(self, now_please: bool) { + if now_please { + REFRESH_PENDING.set(true); + } clear_timer(); let now = js_sys::Date::now(); let kept = read(); @@ -198,7 +204,9 @@ impl Presence { let at = kept.at as f64; let last = (kept.at > 0).then_some(at); let due = should_beat(now, last, LAST_INPUT.get(), visible, online, next_ms); + let now_please = REFRESH_PENDING.get(); if (due || now_please) && visible && online { + REFRESH_PENDING.set(false); self.beat(kept, now, now_please); return; } diff --git a/e2e/allthing.spec.ts b/e2e/allthing.spec.ts index 8ed082fe..e7a61cab 100644 --- a/e2e/allthing.spec.ts +++ b/e2e/allthing.spec.ts @@ -175,6 +175,7 @@ test.describe("front page", () => { // This live read checks feed navigation without recording a presence beat. await page.route("**/api/v1/presence", (route) => route.fulfill({ status: 404, json: { error: "Read-only test" } })); await page.goto(at("/")); + await appCame(page); await expect(firstCard(page)).toBeVisible(); const latestRead = PAGES ? null : page.waitForResponse((response) => new URL(response.url()).pathname === "/api/v1/latest" && response.request().method() === "GET", @@ -898,11 +899,18 @@ test.describe("threads and navigation", () => { await new Promise((done) => setTimeout(done, 2500)); await route.continue(); }); - await page.goto(at("/")); + // Keep this geometry test independent of posts aging out of a short + // time window while the slow-navigation and hold checks are running. + await page.goto(at("/?t=week")); + await appCame(page); + const home = page.url(); + await expect(page.getByRole("combobox", { name: "Time range", exact: true })).toHaveValue("week"); await expect(firstCard(page)).toBeVisible(); // Several pages deep: more than one server page of trending casts. for (let i = 0; i < 40 && (await page.locator(".cast-card").count()) < atLeast + 10; i++) { await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); + const more = page.getByRole("button", { name: "Load more posts", exact: true }); + if (await more.isVisible() && await more.isEnabled()) await more.click(); await page.waitForTimeout(600); } const loaded = await page.locator(".cast-card").count(); @@ -937,7 +945,7 @@ test.describe("threads and navigation", () => { await openAnchor(); await page.waitForTimeout(500); await page.goBack(); - await expect(page).toHaveURL(new RegExp(`${at("/")}$`)); + await expect(page).toHaveURL(home); // Where things are, for a failure's message: the anchor card's top and // place in the list, the scroll offset and how much of the page exists. const where = () => diff --git a/e2e/presence.fixture.spec.ts b/e2e/presence.fixture.spec.ts index bec62eae..60d37467 100644 --- a/e2e/presence.fixture.spec.ts +++ b/e2e/presence.fixture.spec.ts @@ -145,6 +145,28 @@ const kept = (page: Page | BrowserContext, answer: Answer, at = Date.now()) => test.describe("in front of the Worker", () => { test.skip(PAGES, "the static copy has no Worker to beat to"); + test("a visibility change during an in-flight beat refreshes after its answer", async ({ page }) => { + await signIn(page, me); + await kept(page, { online: 2, readers: [ann], next: 240 }, 0); + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + const { beats } = await serve(page, () => ok(2, [ann]), gate); + await page.goto("/"); + const chip = chipOf(page); + await expect(chip).toBeVisible(); + await expect.poll(() => beats.length).toBe(1); + await chip.click(); + const show = panelOf(page).getByRole("switch", { name: "Show me here" }); + await show.click(); + await expect(show).toHaveAttribute("aria-checked", "false"); + expect(beats[0].body.hidden).toBe(false); + release(); + await expect.poll(() => beats.length).toBe(2); + expect(beats[1].body).toEqual({ ...beats[0].body, hidden: true, refresh: true }); + await expect(panelOf(page).locator(".presence-readers a")).toHaveCount(1); + await expect(panelOf(page).getByRole("link", { name: "@ann", exact: true })).toBeVisible(); + }); + for (const width of [1280, 921, 920, 390]) { test(`the chip comes after the page's own call and moves nothing at width ${width}`, async ({ browser, page: desktop }, info) => { const { page, close } = await opened(browser, desktop, info, width); diff --git a/e2e/profile-website.fixture.spec.ts b/e2e/profile-website.fixture.spec.ts index dda7970d..06230889 100644 --- a/e2e/profile-website.fixture.spec.ts +++ b/e2e/profile-website.fixture.spec.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import { expect, test, type Browser, type Locator, type Page, type TestInfo } from "@playwright/test"; +import { expect, test, type Browser, type Locator, type Page, type Request, type TestInfo } from "@playwright/test"; import { NOW } from "./fixture-time"; /* @@ -21,6 +21,19 @@ const REL = "noopener noreferrer nofollow ugc"; const longHref = `https://www.example.com/${"a-rather-long-path/".repeat(10)}end/`; const longLabel = longHref.replace("https://www.", "").replace(/\/$/, ""); +// A profile stuck at the boot shell needs its original browser failure, +// rather than only the later missing-name assertion. Keep diagnostics bounded +// and omit URL queries, which can contain account or authentication data. +const bootDiagnostics = new Map }>(); +test.afterEach(async ({}, info) => { + const diagnostics = bootDiagnostics.get(info); + bootDiagnostics.delete(info); + if (diagnostics && info.status !== info.expectedStatus) { + const rows = [...diagnostics.rows, ...[...diagnostics.pending.values()].map((url) => `still pending: ${url}`)]; + await info.attach("profile-boot-diagnostics", { body: rows.join("\n"), contentType: "text/plain" }); + } +}); + type Website = { href: string; label: string }; // What each profile's payload says of the account's website: as Allthing's // build makes it, or, cleared and hostile, as another server might send it. @@ -84,7 +97,30 @@ async function at(browser: Browser, info: TestInfo, width: number) { const context = await browser.newContext({ baseURL: info.project.use.baseURL, viewport: { width, height: phone ? 844 : 900 }, isMobile: phone, hasTouch: phone, }); - return { context, page: await context.newPage() }; + const page = await context.newPage(); + const rows: string[] = []; + const pending = new Map(); + bootDiagnostics.set(info, { rows, pending }); + const record = (row: string) => { + if (rows.length < 40) rows.push(row.replace(/https?:\/\/[^\s)]+/g, (raw) => { + try { + const url = new URL(raw); + return `${url.origin}${url.pathname}`; + } catch { return "[invalid URL]"; } + }).slice(0, 4000)); + }; + page.on("pageerror", (error) => record(`pageerror: ${error.stack ?? error.message}`)); + page.on("request", (request) => { + const url = new URL(request.url()); + if (pending.size < 40 && /\.(?:wasm|js)$/.test(url.pathname)) pending.set(request, `${url.origin}${url.pathname}`); + }); + page.on("requestfinished", (request) => pending.delete(request)); + page.on("requestfailed", (request) => { + pending.delete(request); + const url = new URL(request.url()); + record(`requestfailed: ${url.origin}${url.pathname} ${request.failure()?.errorText}`); + }); + return { context, page }; } async function open(page: Page, name: string) { From c320e8112343ebecaad461f9d8bfa47c703dc4e5 Mon Sep 17 00:00:00 2001 From: ael-dev3 <183124839+ael-dev3@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:38:28 +0200 Subject: [PATCH 2/2] fix(web): activate video sound on Space release --- CHANGELOG.md | 1 + crates/allthing-web/src/components/player.rs | 31 ++++++++++++++++++-- e2e/video-sound-focus.fixture.spec.ts | 17 ++++++++++- 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f27c722..1fb2cb2d 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 +- A video sound badge handles Space release explicitly after touch input, retaining focus and playback without depending on a synthesized native click. Held-key repeats do not toggle sound, and Enter retains its native button action. - Changing presence visibility or session while a heartbeat is in flight queues one refresh with the latest state, so the update is not lost behind the older response. - 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. diff --git a/crates/allthing-web/src/components/player.rs b/crates/allthing-web/src/components/player.rs index fc4f94ca..e8ddcdd6 100644 --- a/crates/allthing-web/src/components/player.rs +++ b/crates/allthing-web/src/components/player.rs @@ -575,7 +575,7 @@ pub fn HlsVideo( // so it decides, and changes at once rather than when the element's // volumechange event comes back (a queued event could leave a quick // second press reading a stale state). - let toggle_sound = move |_: ev::MouseEvent| { + let toggle_sound = move || { let Some(video) = video_ref.get_untracked() else { return }; let sound = muted.get_untracked(); set_sound(sound); @@ -589,6 +589,11 @@ pub fn HlsVideo( show_sound(); } }; + // After touch input, a browser can deliver Space down/up to this + // focused badge without synthesizing its native click. Handle that + // release explicitly, cancelling the default to avoid two toggles in + // browsers that do synthesize it. Enter keeps the button's native action. + let sound_key_down = StoredValue::new(false); // Media events are queued. A pause/ended event from an earlier state // can arrive after play has resumed; it must not hide active controls. @@ -675,7 +680,29 @@ pub fn HlsVideo( > - diff --git a/e2e/video-sound-focus.fixture.spec.ts b/e2e/video-sound-focus.fixture.spec.ts index c9b7faa1..83c839f7 100644 --- a/e2e/video-sound-focus.fixture.spec.ts +++ b/e2e/video-sound-focus.fixture.spec.ts @@ -94,7 +94,7 @@ for (const width of [1280, 390]) { await expect.poll(() => video.evaluate((v) => !v.muted && !v.paused)).toBe(true); expect(await page.evaluate(() => localStorage.getItem("allthing.sound"))).toBe("on"); // Hold Space on the pointer-focused control until its keydown has - // been processed, then release it for the native button activation. + // been processed, then release it to activate once. // Do not refocus: the same node must keep focus throughout. const pointerFocused = await enabled.elementHandle(); await page.keyboard.down("Space"); @@ -103,6 +103,10 @@ for (const width of [1280, 390]) { expect(await enabled.evaluate((button, original) => button === original, pointerFocused)).toBe(true); await expect(enabled).toHaveAttribute("data-press", "down"); await expect.poll(() => video.evaluate((v) => !v.muted && !v.paused)).toBe(true); + // Repeated keydown must not toggle while Space remains held. + await page.keyboard.down("Space"); + await expect(enabled).toBeFocused(); + await expect.poll(() => video.evaluate((v) => !v.muted && !v.paused)).toBe(true); } finally { await page.keyboard.up("Space"); } @@ -111,6 +115,17 @@ for (const width of [1280, 390]) { expect(await page.evaluate(() => localStorage.getItem("allthing.sound"))).toBeNull(); expect(await page.evaluate(() => window.scrollY)).toBe(before); expect(await video.getAttribute("src")).toBe(source); + // Enter retains the native button action, and another Space release + // toggles once on the same pointer-focused node. + await page.keyboard.press("Enter"); + await expect(enabled).toBeFocused(); + await expect.poll(() => video.evaluate((v) => !v.muted && !v.paused)).toBe(true); + await page.keyboard.press("Space"); + await expect(sound).toBeFocused(); + await expect.poll(() => video.evaluate((v) => v.muted && !v.paused)).toBe(true); + expect(await page.evaluate(() => localStorage.getItem("allthing.sound"))).toBeNull(); + expect(await page.evaluate(() => window.scrollY)).toBe(before); + expect(await video.getAttribute("src")).toBe(source); failed = false; } finally { if (failed) {