Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7e036abef | ||
|
|
f2de8f73aa | ||
|
|
643f305814 | ||
|
|
cbe1b3bd63 | ||
|
|
ae775b383c |
@@ -7,6 +7,12 @@ edit the release notes and re-run the generator.
|
||||
|
||||
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
|
||||
|
||||
## 0.116.3 — 2026-11-04
|
||||
|
||||
### Fixed
|
||||
|
||||
- If a stylesheet is lost on the way, the app now asks for it again, so a poor connection no longer leaves the page unstyled until a reload.
|
||||
|
||||
## 0.116.2 — 2026-11-03
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -24,6 +24,10 @@ outlives an `expect` timeout); Node does not, so a request from the test side fa
|
||||
that one error again and passes every other failure straight on. Don't run two e2e suites on the
|
||||
same machine at once.
|
||||
|
||||
**A page load the machine had no buffer for** — on the same host a page load can fail at once with
|
||||
`net::ERR_NO_BUFFER_SPACE`. Every page load goes through `gotoRetrying` (same file), which tries that
|
||||
one error again; a test that calls `page.goto` itself fails `src/lib/e2eRetryConnect.test.ts`.
|
||||
|
||||
**Browsers** — every flow runs in three projects: `chromium` (Desktop Chrome), `webkit` (Desktop
|
||||
Safari) and `ipad` (iPad gen 7 landscape: 1080×810, touch, mobile) — except `a11y`, `member` and
|
||||
`devices`, which run on chromium only (`CHROMIUM_ONLY`; why is in the `docs/gates.md` row). Install the engines once per
|
||||
|
||||
@@ -4,7 +4,7 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { MEMBER_STORAGE, SEED_FILE } from "./helpers";
|
||||
import { retryConnect } from "./retryConnect";
|
||||
import { gotoRetrying, retryConnect } from "./retryConnect";
|
||||
|
||||
/**
|
||||
* One-time setup for the characterization net (the `setup` project the tests depend on):
|
||||
@@ -52,7 +52,7 @@ async function signIn(page: Page, email: string, path: string): Promise<void> {
|
||||
// and player flows this net locks all open "/" expecting the feed; the opt-out is the one
|
||||
// Settings → Appearance → Startup writes. Both flags are the account's local state, held for the
|
||||
// account the browser records as their owner (`siftlode.owner`, see storage.ts claimLocalData).
|
||||
await page.goto("/");
|
||||
await gotoRetrying(page, "/");
|
||||
await page.evaluate((id: number) => {
|
||||
localStorage.setItem("siftlode.owner", String(id));
|
||||
localStorage.setItem("siftlode.lang", "en");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { gotoRetrying } from "./retryConnect";
|
||||
|
||||
/** The plain member's saved session (global.setup.ts signs it in); `member.spec.ts` and
|
||||
* `devices.spec.ts` run from it. */
|
||||
@@ -16,7 +17,7 @@ export function seeded(key: string): string[] {
|
||||
|
||||
/** Open the app and wait for the authenticated shell (the nav rail) to be ready. */
|
||||
export async function openApp(page: Page, path = "/"): Promise<void> {
|
||||
await page.goto(path);
|
||||
await gotoRetrying(page, path);
|
||||
await expect(page.getByTestId("nav-feed")).toBeVisible();
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,19 @@ const METHODS = new Set(["get", "post", "put", "patch", "delete", "head", "fetch
|
||||
const NO_CONNECTION = /\bconnect ETIMEDOUT\b/;
|
||||
const WAITS_MS = [500, 1000, 2000, 4000, 8000];
|
||||
|
||||
/** `attempt` again after each wait while it fails with `transient`; any other failure goes on. */
|
||||
async function tryAgain<R>(attempt: () => Promise<R>, transient: RegExp): Promise<R> {
|
||||
for (let i = 0; ; i++) {
|
||||
try {
|
||||
return await attempt();
|
||||
} catch (e) {
|
||||
const wait = WAITS_MS[i];
|
||||
if (wait === undefined || !transient.test(String((e as Error)?.message))) throw e;
|
||||
await new Promise((r) => setTimeout(r, wait));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** `ctx` with its request methods trying a connect that timed out again (see above). */
|
||||
export function retryConnect<T extends object>(ctx: T): T {
|
||||
return new Proxy(ctx, {
|
||||
@@ -18,17 +31,19 @@ export function retryConnect<T extends object>(ctx: T): T {
|
||||
const v: unknown = Reflect.get(target, key, target);
|
||||
if (typeof v !== "function") return v;
|
||||
if (typeof key !== "string" || !METHODS.has(key)) return v.bind(target);
|
||||
return async (...args: unknown[]) => {
|
||||
for (let i = 0; ; i++) {
|
||||
try {
|
||||
return await v.apply(target, args);
|
||||
} catch (e) {
|
||||
const wait = WAITS_MS[i];
|
||||
if (wait === undefined || !NO_CONNECTION.test(String((e as Error)?.message))) throw e;
|
||||
await new Promise((r) => setTimeout(r, wait));
|
||||
}
|
||||
}
|
||||
};
|
||||
return (...args: unknown[]) =>
|
||||
tryAgain(() => Promise.resolve(v.apply(target, args)), NO_CONNECTION);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// A page load has a failure of its own on the same machine: `net::ERR_NO_BUFFER_SPACE`, the socket
|
||||
// call refused for the moment. It stopped a release's e2e at its first page (0.116.3, with nothing
|
||||
// else running; 0.107.3 aborted on it too), and the unchanged rerun passed. A browser does not try
|
||||
// a main document again by itself, and the request never left, so this does. The suite's every page
|
||||
// load goes through here (`e2eRetryConnect.test.ts`).
|
||||
const NO_BUFFER = /\bERR_NO_BUFFER_SPACE\b/;
|
||||
|
||||
export function gotoRetrying<R>(page: { goto(url: string): Promise<R> }, url: string): Promise<R> {
|
||||
return tryAgain(() => page.goto(url), NO_BUFFER);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { test, type Page } from "@playwright/test";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { retryConnect } from "./retryConnect";
|
||||
import { gotoRetrying, retryConnect } from "./retryConnect";
|
||||
|
||||
/**
|
||||
* The speed baseline: numbers, not assertions — read by `scripts/audit_metrics.py --speed`, which
|
||||
@@ -78,7 +78,7 @@ test("speed baseline", async ({ browser, page: page0 }) => {
|
||||
for (let i = 0; i < RUNS; i++) {
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
await page.goto("/");
|
||||
await gotoRetrying(page, "/");
|
||||
// Timed inside the page, every frame: an `expect` would read the clock on its own retry
|
||||
// schedule (100 ms … 1 s steps), not when the card appeared.
|
||||
const at = await page.waitForFunction(
|
||||
@@ -108,7 +108,7 @@ test("speed baseline", async ({ browser, page: page0 }) => {
|
||||
for (let i = 0; i < RUNS; i++) {
|
||||
const ctx = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
||||
const page = await ctx.newPage();
|
||||
await page.goto(`/watch/${token}`);
|
||||
await gotoRetrying(page, `/watch/${token}`);
|
||||
await page.waitForFunction(() => document.querySelector("video, audio"), null, {
|
||||
polling: "raf",
|
||||
timeout: 30_000,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
import { openApp } from "./helpers";
|
||||
import { gotoRetrying } from "./retryConnect";
|
||||
|
||||
// A request can be lost on the way on a loaded machine or a poor line. A lost stylesheet leaves the
|
||||
// whole page unstyled, and every test that finds its elements by testid still passes on it: only an
|
||||
// axe run ever noticed (8 controls under the target size). So this looks at the styles themselves.
|
||||
|
||||
/** Lose the first request for every stylesheet of the page; the ones after it go through. */
|
||||
async function loseEachStylesheetOnce(page: Page): Promise<string[]> {
|
||||
const lost: string[] = [];
|
||||
await page.route(/\/assets\/[^/]+\.css/, async (route) => {
|
||||
const url = route.request().url();
|
||||
if (lost.includes(url)) return route.continue();
|
||||
lost.push(url);
|
||||
return route.abort("failed");
|
||||
});
|
||||
return lost;
|
||||
}
|
||||
|
||||
/** Every stylesheet link of the page holds rules (a lost one still has a `sheet`, whose rules throw
|
||||
* on reading), and the page's own tokens are in force: `--bg` comes from the stylesheet alone, while
|
||||
* `--font-scale` would prove nothing, the app also sets it from script. */
|
||||
async function expectStyled(page: Page): Promise<void> {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => {
|
||||
const hasRules = (l: HTMLLinkElement) => {
|
||||
try {
|
||||
return (l.sheet?.cssRules.length ?? 0) > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const links = [...document.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]')];
|
||||
const root = getComputedStyle(document.documentElement);
|
||||
return {
|
||||
links: links.length > 0,
|
||||
rules: links.every(hasRules),
|
||||
tokens: root.getPropertyValue("--bg").trim() !== "",
|
||||
};
|
||||
}),
|
||||
)
|
||||
.toEqual({ links: true, rules: true, tokens: true });
|
||||
}
|
||||
|
||||
test("the app gets back a stylesheet lost on the way", async ({ page }) => {
|
||||
const lost = await loseEachStylesheetOnce(page);
|
||||
await openApp(page);
|
||||
expect(lost.length).toBeGreaterThan(0);
|
||||
await expectStyled(page);
|
||||
});
|
||||
|
||||
test("a public /watch link gets back a stylesheet lost on the way", async ({ page }) => {
|
||||
const lost = await loseEachStylesheetOnce(page);
|
||||
// No such link: the page says so, and it is the same entry with the same stylesheet.
|
||||
await gotoRetrying(page, "/watch/no-such-token");
|
||||
await expect(page.locator("#root")).not.toBeEmpty();
|
||||
expect(lost.length).toBeGreaterThan(0);
|
||||
await expectStyled(page);
|
||||
});
|
||||
@@ -1,4 +1,12 @@
|
||||
[
|
||||
{
|
||||
"version": "0.116.3",
|
||||
"date": "2026-11-04",
|
||||
"new": [],
|
||||
"fixed": [
|
||||
"If a stylesheet is lost on the way, the app now asks for it again, so a poor connection no longer leaves the page unstyled until a reload."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.116.2",
|
||||
"date": "2026-11-03",
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
[
|
||||
{
|
||||
"version": "0.116.3",
|
||||
"date": "2026-11-04",
|
||||
"new": [],
|
||||
"fixed": [
|
||||
"Ha egy stíluslap elvész útközben, az app most újra lekéri, így a rossz kapcsolat miatt nem marad formázatlan az oldal újratöltésig."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.116.2",
|
||||
"date": "2026-11-03",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// The e2e suite's request wrapper (e2e/retryConnect.ts): a connect that never came about is tried
|
||||
// again, as every TCP stack would; any other failure, or an answer, goes straight to the test.
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { retryConnect } from "../../e2e/retryConnect";
|
||||
import { gotoRetrying, retryConnect } from "../../e2e/retryConnect";
|
||||
|
||||
const noConnection = () => new Error("apiRequestContext.get: connect ETIMEDOUT 127.0.0.1:8080");
|
||||
|
||||
@@ -39,3 +40,43 @@ describe("retryConnect", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
// The text Playwright gave when this stopped a release's e2e at its first page load (2026-11-04).
|
||||
const noBuffer = () => new Error("page.goto: net::ERR_NO_BUFFER_SPACE at http://127.0.0.1:8080/");
|
||||
|
||||
describe("gotoRetrying", () => {
|
||||
it("tries a page load the machine had no buffer for again", async () => {
|
||||
vi.useFakeTimers();
|
||||
const goto = vi.fn().mockRejectedValueOnce(noBuffer()).mockResolvedValue("response");
|
||||
const answer = gotoRetrying({ goto }, "/feed");
|
||||
await vi.runAllTimersAsync();
|
||||
await expect(answer).resolves.toBe("response");
|
||||
expect(goto).toHaveBeenCalledTimes(2);
|
||||
expect(goto).toHaveBeenLastCalledWith("/feed");
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("passes any other failure straight on, untried", async () => {
|
||||
const goto = vi.fn().mockRejectedValue(new Error("page.goto: net::ERR_CONNECTION_REFUSED"));
|
||||
await expect(gotoRetrying({ goto }, "/")).rejects.toThrow("ERR_CONNECTION_REFUSED");
|
||||
expect(goto).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("gives up after its last wait, with the error", async () => {
|
||||
vi.useFakeTimers();
|
||||
const goto = vi.fn().mockRejectedValue(noBuffer());
|
||||
const settled = expect(gotoRetrying({ goto }, "/")).rejects.toThrow("ERR_NO_BUFFER_SPACE");
|
||||
await vi.runAllTimersAsync();
|
||||
await settled;
|
||||
expect(goto).toHaveBeenCalledTimes(6);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("is the only way the suite loads a page", () => {
|
||||
const dir = new URL("../../e2e/", import.meta.url);
|
||||
const direct = readdirSync(dir)
|
||||
.filter((f) => f.endsWith(".ts") && f !== "retryConnect.ts")
|
||||
.filter((f) => /\.(goto|reload)\(/.test(readFileSync(new URL(f, dir), "utf-8")));
|
||||
expect(direct).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { clear, events } from "./log";
|
||||
import { retryLostStylesheets } from "./stylesheetRetry";
|
||||
|
||||
const LOADED = { cssRules: [{}] };
|
||||
// A lost stylesheet still has a `sheet`, and reading its rules throws (measured: Chromium, WebKit).
|
||||
const LOST = {
|
||||
get cssRules(): never {
|
||||
throw new DOMException("Cannot access rules", "SecurityError");
|
||||
},
|
||||
};
|
||||
|
||||
function link(href: string, loaded: boolean, sheet: unknown = loaded ? LOADED : LOST) {
|
||||
const el = document.createElement("link");
|
||||
el.rel = "stylesheet";
|
||||
el.href = href;
|
||||
el.setAttribute("crossorigin", "");
|
||||
Object.defineProperty(el, "sheet", { value: sheet, configurable: true });
|
||||
document.head.append(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
const links = () => [...document.head.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]')];
|
||||
const logged = () => events().map((e) => e.event);
|
||||
|
||||
describe("retryLostStylesheets", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
clear();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
document.head.innerHTML = "";
|
||||
});
|
||||
|
||||
it("leaves a loaded stylesheet alone", () => {
|
||||
const fine = link("/a.css", true);
|
||||
retryLostStylesheets();
|
||||
vi.runAllTimers();
|
||||
expect(links()).toEqual([fine]);
|
||||
expect(logged()).toEqual([]);
|
||||
});
|
||||
|
||||
it("asks again for a lost one, in its place, keeping its attributes", () => {
|
||||
const first = link("/a.css", true);
|
||||
const lost = link("/b.css", false);
|
||||
const last = link("/c.css", true);
|
||||
retryLostStylesheets();
|
||||
vi.advanceTimersByTime(0);
|
||||
const [a, b, c] = links();
|
||||
expect(a).toBe(first);
|
||||
expect(b).not.toBe(lost);
|
||||
expect(b!.getAttribute("href")).toBe("/b.css");
|
||||
expect(b!.hasAttribute("crossorigin")).toBe(true);
|
||||
expect(c).toBe(last);
|
||||
expect(logged()).toEqual(["app.stylesheet_lost"]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["no sheet at all", null],
|
||||
["an empty sheet", { cssRules: [] }],
|
||||
])("takes a link with %s for lost too", (_, sheet) => {
|
||||
const lost = link("/a.css", false, sheet);
|
||||
retryLostStylesheets();
|
||||
vi.advanceTimersByTime(0);
|
||||
expect(links()[0]).not.toBe(lost);
|
||||
});
|
||||
|
||||
it("says so once the new request has loaded", () => {
|
||||
link("/a.css", false);
|
||||
retryLostStylesheets();
|
||||
vi.advanceTimersByTime(0);
|
||||
links()[0]!.dispatchEvent(new Event("load"));
|
||||
vi.runAllTimers();
|
||||
expect(logged()).toEqual(["app.stylesheet_lost", "app.stylesheet_recovered"]);
|
||||
});
|
||||
|
||||
it("waits longer each time and then gives up, reporting it", () => {
|
||||
link("/a.css", false);
|
||||
retryLostStylesheets();
|
||||
const fail = () => links()[0]!.dispatchEvent(new Event("error"));
|
||||
|
||||
vi.advanceTimersByTime(0);
|
||||
const second = links()[0];
|
||||
fail();
|
||||
vi.advanceTimersByTime(999);
|
||||
expect(links()[0]).toBe(second);
|
||||
vi.advanceTimersByTime(1);
|
||||
const third = links()[0];
|
||||
expect(third).not.toBe(second);
|
||||
|
||||
fail();
|
||||
vi.advanceTimersByTime(2999);
|
||||
expect(links()[0]).toBe(third);
|
||||
vi.advanceTimersByTime(1);
|
||||
const fourth = links()[0];
|
||||
expect(fourth).not.toBe(third);
|
||||
|
||||
fail();
|
||||
vi.runAllTimers();
|
||||
expect(links()[0]).toBe(fourth);
|
||||
expect(logged()).toEqual(["app.stylesheet_lost", "app.stylesheet_gave_up"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
// A stylesheet can be lost on the way (a poor line, a loaded machine), and a browser never asks again
|
||||
// for one by itself: the page then stays unstyled until a reload, every control a bare browser button.
|
||||
// A module script waits for the document's stylesheets (measured: a sheet held 3 s held the module
|
||||
// too), so by the time this runs each link has either loaded or failed, and no event is left to wait
|
||||
// for. A lost one is asked for again in its own place, so the cascade's order stays as it was built.
|
||||
//
|
||||
// All of these sheets are this site's own: reading the rules of a loaded one is allowed, so a throw
|
||||
// says it failed (measured: Chromium, WebKit). Three tries, then the page stays as it is. A reload here
|
||||
// could loop, and the unstyled page still works.
|
||||
import { log } from "./log";
|
||||
|
||||
const RETRY_AFTER_MS = [0, 1000, 3000];
|
||||
|
||||
function holdsRules(link: HTMLLinkElement): boolean {
|
||||
try {
|
||||
return (link.sheet?.cssRules.length ?? 0) > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function askAgain(link: HTMLLinkElement, attempt: number): void {
|
||||
const wait = RETRY_AFTER_MS[attempt];
|
||||
if (wait === undefined) {
|
||||
log.error("app.stylesheet_gave_up", "a stylesheet could not be loaded", { href: link.href });
|
||||
return;
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
const next = link.cloneNode(false) as HTMLLinkElement;
|
||||
next.addEventListener("load", () =>
|
||||
log.info("app.stylesheet_recovered", "a lost stylesheet loaded on a new request", {
|
||||
href: next.href,
|
||||
attempt: attempt + 1,
|
||||
}),
|
||||
);
|
||||
next.addEventListener("error", () => askAgain(next, attempt + 1));
|
||||
link.replaceWith(next);
|
||||
}, wait);
|
||||
}
|
||||
|
||||
/** Run once, from a page's entry module. */
|
||||
export function retryLostStylesheets(): void {
|
||||
for (const link of document.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]')) {
|
||||
if (holdsRules(link)) continue;
|
||||
log.warn("app.stylesheet_lost", "a stylesheet was lost on the way; asking again", {
|
||||
href: link.href,
|
||||
});
|
||||
askAgain(link, 0);
|
||||
}
|
||||
}
|
||||
@@ -17,12 +17,15 @@ import "flag-icons/css/flag-icons.min.css";
|
||||
import { lazyWithPreload } from "./lib/lazyWithPreload";
|
||||
import { installClientLog } from "./lib/logHooks";
|
||||
import { installStaleBuildReload } from "./lib/staleBuild";
|
||||
import { retryLostStylesheets } from "./lib/stylesheetRetry";
|
||||
import { appCopyOf } from "./i18n/appCopy";
|
||||
|
||||
// First, before any render: a crash or a navigation during boot belongs in the log too.
|
||||
installClientLog(appCopyOf, typedUrlKeys());
|
||||
// A tab from before the last release reloads onto the new build instead of crashing on a lazy chunk.
|
||||
installStaleBuildReload();
|
||||
// A stylesheet lost on the way is asked for again rather than leaving the page unstyled.
|
||||
retryLostStylesheets();
|
||||
|
||||
// Split by top-level route so each entry point is its own chunk: a legal page never downloads the
|
||||
// authenticated app bundle, and the app never carries it either. The legal pages live outside the
|
||||
|
||||
@@ -5,6 +5,7 @@ import WatchPage from "./watch/WatchPage";
|
||||
import { T } from "./watch/watchStrings";
|
||||
import "./index.css";
|
||||
import { installStaleBuildReload } from "./lib/staleBuild";
|
||||
import { retryLostStylesheets } from "./lib/stylesheetRetry";
|
||||
|
||||
// The entry of a public /watch/<token> link (watch.html). Whoever opens it got the link from a
|
||||
// friend: signed out, often on a phone, often on mobile data — so it imports the player page and
|
||||
@@ -14,6 +15,8 @@ import { installStaleBuildReload } from "./lib/staleBuild";
|
||||
|
||||
// A tab left open across a release reloads onto the new build instead of failing on a chunk.
|
||||
installStaleBuildReload();
|
||||
// A stylesheet lost on the way is asked for again rather than leaving the page unstyled.
|
||||
retryLostStylesheets();
|
||||
|
||||
const crashCopy = () => ({ title: T.crashTitle, subtitle: T.crashHint, reload: T.reload });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user