+ {content}
+
+ );
+}
+
+export function replaceReaderCodeBlock(node: DOMNode) {
+ if (!(node instanceof Element) || node.name !== "pre") return;
+ const code = node.children.find(
+ (child) => child instanceof Element && child.name === "code",
+ );
+ return (
+ Captured Bookmark body
diff --git a/apps/app/tests/e2e/self-hosted/article-code-blocks.spec.ts b/apps/app/tests/e2e/self-hosted/article-code-blocks.spec.ts
new file mode 100644
index 000000000..82142888f
--- /dev/null
+++ b/apps/app/tests/e2e/self-hosted/article-code-blocks.spec.ts
@@ -0,0 +1,140 @@
+import { expect, test } from "@playwright/test";
+import { signIn } from "../fixtures/auth";
+import {
+ SELF_HOSTED_APP_PORT,
+ SELF_HOSTED_TURSO_PORT,
+} from "../fixtures/ports";
+import {
+ cleanupUser,
+ seedArticleData,
+ seedBookmarkProjectionData,
+ setFeedItemContent,
+} from "../fixtures/seed-db";
+
+const longLine = `const message = "${"A long line of code. ".repeat(18)}";`;
+const articleHtml = `Code examples retain their formatting in the reader.
+const answer = 42;\nconsole.log(answer);
+ ${longLine}
+ Images that cannot load keep their place in the article.
+
`;
+
+test.describe("article code and image layout", () => {
+ test.use({ serviceWorkers: "block" });
+ let email: string;
+ test.afterEach(async () => {
+ if (email) await cleanupUser(SELF_HOSTED_TURSO_PORT, email);
+ });
+
+ for (const kind of ["full", "simplified", "bookmark"] as const) {
+ test(`${kind} code fills the reader and scrolls within its block`, async ({
+ page,
+ }) => {
+ const fixture = await seedArticleData(
+ SELF_HOSTED_TURSO_PORT,
+ SELF_HOSTED_APP_PORT,
+ );
+ email = fixture.email;
+ await setFeedItemContent(
+ SELF_HOSTED_TURSO_PORT,
+ fixture.feedItemId,
+ articleHtml,
+ );
+ const contentId =
+ kind === "bookmark"
+ ? (
+ await seedBookmarkProjectionData(
+ SELF_HOSTED_TURSO_PORT,
+ email,
+ fixture.feedItemId,
+ articleHtml,
+ )
+ ).bookmarkId
+ : fixture.feedItemId;
+ await page.addInitScript(
+ (style) => {
+ localStorage.setItem(
+ "serial-flag-article-style",
+ JSON.stringify(style),
+ );
+ },
+ kind === "simplified" ? "simplified" : "full",
+ );
+ await page.route("https://images.example.com/**", (route) =>
+ route.abort(),
+ );
+ await signIn({ page, email, password: fixture.password });
+ await page.goto(`/read/${contentId}`);
+ const blocks = page.locator("pre");
+ await expect(blocks).toHaveCount(2);
+ await expect(blocks.first().locator(".hljs-keyword")).toBeVisible();
+ await expect(blocks.last()).toHaveText(longLine);
+
+ for (const width of [1280, 390]) {
+ await page.setViewportSize({ width, height: 900 });
+ const geometry = await blocks.first().evaluate((short) => {
+ const wide = document.querySelectorAll("pre")[1]!;
+ const parent = short.parentElement!;
+ const style = getComputedStyle(parent);
+ const available =
+ parent.clientWidth -
+ parseFloat(style.paddingLeft) -
+ parseFloat(style.paddingRight);
+ wide.scrollLeft = 100;
+ const scrollContainer = document.querySelector(
+ '[data-slot="sidebar-inset"]',
+ )!;
+ return {
+ short: short.getBoundingClientRect().width,
+ wide: wide.getBoundingClientRect().width,
+ available,
+ scrollLeft: wide.scrollLeft,
+ pageOverflow:
+ scrollContainer.scrollWidth - scrollContainer.clientWidth,
+ whiteSpace: getComputedStyle(wide).whiteSpace,
+ };
+ });
+ expect(
+ Math.abs(geometry.short - geometry.available),
+ ).toBeLessThanOrEqual(1);
+ expect(geometry.wide).toBe(geometry.short);
+ expect(geometry.scrollLeft).toBeGreaterThan(0);
+ expect(geometry.pageOverflow).toBeLessThanOrEqual(1);
+ expect(geometry.whiteSpace).toBe("pre");
+
+ if (kind !== "simplified") {
+ const placeholder = page.locator("[data-image-fallback]");
+ await placeholder.scrollIntoViewIfNeeded();
+ await expect(placeholder).toBeVisible();
+ const box = await placeholder.boundingBox();
+ expect(box).not.toBeNull();
+ expect(Math.abs(box!.width - geometry.available)).toBeLessThanOrEqual(
+ 1,
+ );
+ expect(Math.abs(box!.width / box!.height - 16 / 9)).toBeLessThan(
+ 0.02,
+ );
+ }
+ }
+ const token = blocks.first().locator(".hljs-keyword");
+ await page.evaluate(() =>
+ document.documentElement.classList.remove("dark"),
+ );
+ const light = await token.evaluate(
+ (node) => getComputedStyle(node).color,
+ );
+ await page.evaluate(() => document.documentElement.classList.add("dark"));
+ expect(
+ await token.evaluate((node) => getComputedStyle(node).color),
+ ).not.toBe(light);
+
+ await blocks.last().evaluate((node) => {
+ node.scrollLeft = 0;
+ });
+ await blocks.last().focus();
+ await page.keyboard.press("ArrowRight");
+ await expect
+ .poll(() => blocks.last().evaluate((node) => node.scrollLeft))
+ .toBeGreaterThan(0);
+ });
+ }
+});
diff --git a/apps/app/tests/unit/components/code-highlighting-worker.test.ts b/apps/app/tests/unit/components/code-highlighting-worker.test.ts
new file mode 100644
index 000000000..a44de2bac
--- /dev/null
+++ b/apps/app/tests/unit/components/code-highlighting-worker.test.ts
@@ -0,0 +1,86 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+class TestWorker {
+ static instances: TestWorker[] = [];
+ onmessage?: (event: { data: { id: number; html: string | null } }) => void;
+ onerror?: () => void;
+ postMessage = vi.fn();
+ terminate = vi.fn();
+ constructor() {
+ TestWorker.instances.push(this);
+ }
+}
+
+beforeEach(() => {
+ vi.resetModules();
+ TestWorker.instances = [];
+ vi.stubGlobal("Worker", TestWorker);
+});
+afterEach(() => vi.unstubAllGlobals());
+
+describe("code highlighting worker", () => {
+ it("shares one worker and delivers each result to its block", async () => {
+ const { requestCodeHighlight } =
+ await import("~/components/content-reader/highlightCodeClient");
+ const first = vi.fn();
+ const second = vi.fn();
+ requestCodeHighlight("first", "js", first);
+ requestCodeHighlight("second", undefined, second);
+ expect(TestWorker.instances).toHaveLength(1);
+ const worker = TestWorker.instances[0]!;
+ const [firstMessage, secondMessage] = worker.postMessage.mock.calls.map(
+ ([message]) => message as { id: number },
+ );
+ worker.onmessage?.({
+ data: { id: secondMessage!.id, html: "second result" },
+ });
+ worker.onmessage?.({
+ data: { id: firstMessage!.id, html: "first result" },
+ });
+ expect(first).toHaveBeenCalledWith("first result");
+ expect(second).toHaveBeenCalledWith("second result");
+ });
+
+ it("drops cancelled results and resolves pending blocks as plain text on failure", async () => {
+ const { requestCodeHighlight } =
+ await import("~/components/content-reader/highlightCodeClient");
+ const cancelled = vi.fn();
+ const pending = vi.fn();
+ const cancel = requestCodeHighlight("old", "js", cancelled);
+ requestCodeHighlight("current", "js", pending);
+ cancel();
+ const worker = TestWorker.instances[0]!;
+ const message = worker.postMessage.mock.calls[0]![0] as { id: number };
+ worker.onmessage?.({ data: { id: message.id, html: "stale" } });
+ expect(cancelled).not.toHaveBeenCalled();
+ worker.onerror?.();
+ expect(pending).toHaveBeenCalledWith(null);
+ expect(worker.terminate).toHaveBeenCalledOnce();
+ requestCodeHighlight("retry", "js", vi.fn());
+ expect(TestWorker.instances).toHaveLength(2);
+ });
+
+ it("keeps plain code when workers are unavailable", async () => {
+ vi.stubGlobal("Worker", undefined);
+ const { requestCodeHighlight } =
+ await import("~/components/content-reader/highlightCodeClient");
+ const callback = vi.fn();
+ requestCodeHighlight("const x = 1;", "js", callback);
+ expect(callback).toHaveBeenCalledWith(null);
+ });
+
+ it("terminates abandoned work before highlighting the next article", async () => {
+ const { requestCodeHighlight } =
+ await import("~/components/content-reader/highlightCodeClient");
+ const cancel = requestCodeHighlight("old", "js", vi.fn());
+ const abandonedWorker = TestWorker.instances[0]!;
+ cancel();
+ expect(abandonedWorker.terminate).toHaveBeenCalledOnce();
+ const next = vi.fn();
+ requestCodeHighlight("new", "js", next);
+ expect(TestWorker.instances).toHaveLength(2);
+ abandonedWorker.onerror?.();
+ expect(next).not.toHaveBeenCalled();
+ expect(TestWorker.instances[1]!.terminate).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/app/tests/unit/components/offline-ui.test.ts b/apps/app/tests/unit/components/offline-ui.test.ts
index 667309327..708ee18a7 100644
--- a/apps/app/tests/unit/components/offline-ui.test.ts
+++ b/apps/app/tests/unit/components/offline-ui.test.ts
@@ -46,7 +46,7 @@ describe("Offline banner", () => {
});
describe("article image fallback", () => {
- it("keeps a failed image in flow as a square muted block", () => {
+ it("keeps a failed image in flow as a full-width landscape block", () => {
const container = render(
createElement(ArticleImageLightbox, {
src: "https://example.com/unavailable.jpg",
@@ -59,7 +59,8 @@ describe("article image fallback", () => {
act(() => image?.dispatchEvent(new Event("error")));
const fallback = container.querySelector("[data-image-fallback]");
- expect(fallback?.className).toContain("aspect-square");
+ expect(fallback?.className).toContain("aspect-video");
+ expect(fallback?.className).toContain("w-full");
expect(fallback?.className).toContain("bg-muted");
expect(fallback?.getAttribute("aria-label")).toBe(
"Unavailable illustration",
diff --git a/apps/app/tests/unit/components/reader-code-blocks.test.ts b/apps/app/tests/unit/components/reader-code-blocks.test.ts
new file mode 100644
index 000000000..d66ac62e9
--- /dev/null
+++ b/apps/app/tests/unit/components/reader-code-blocks.test.ts
@@ -0,0 +1,121 @@
+// @vitest-environment jsdom
+
+import { act, createElement } from "react";
+import { createRoot } from "react-dom/client";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { ArticleContent } from "~/components/feed/read/ArticleContent";
+import { BookmarkArticleContent } from "~/components/bookmarks/BookmarkArticleContent";
+import * as highlighter from "~/components/content-reader/highlightCode";
+
+vi.mock("~/components/content-reader/highlightCodeClient", () => ({
+ requestCodeHighlight: (
+ code: string,
+ language: string | undefined,
+ onResult: (html: string | null) => void,
+ ) => {
+ onResult(highlighter.highlightCode(code, language));
+ return () => {};
+ },
+}));
+
+vi.mock("~/lib/hooks/useFlagState", () => ({ useFlagState: () => ["iframe"] }));
+vi.mock("~/components/CustomVideoPlayer", () => ({
+ CustomVideoPlayer: () => null,
+}));
+Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+
+const roots: Arrayconst value = 42;
Use value.
const value = "<img src=x onerror=alert(1)>&";\n console.log(value);',
+ kind,
+ );
+ expect(container.querySelector("pre")?.textContent).toBe(
+ 'const value = "def greet(name):
return "Hello " + name',
+ );
+ expect(container.querySelector("pre")?.textContent).toBe(
+ 'def greet(name):\n return "Hello " + name',
+ );
+ expect(container.querySelector(".hljs-keyword")?.textContent).toBe("def");
+ });
+
+ it.each(["language-unknown", "nohighlight", "language-text"])(
+ "keeps %s blocks as plain text",
+ async (className) => {
+ const { container } = await render(
+ `const value = 42;`,
+ );
+ expect(container.querySelector("pre")?.textContent).toBe(
+ "const value = 42;",
+ );
+ expect(container.querySelector("pre span")).toBeNull();
+ },
+ );
+
+ it("does not tokenize again on unrelated rerenders and discards stale highlighted content", async () => {
+ const spy = vi.spyOn(highlighter, "highlightCode");
+ const { container, root, element } = await render(
+ 'const first = 42;', + ); + expect(spy).toHaveBeenCalledTimes(1); + await act(async () => { + root.render({ ...element }); + }); + expect(spy).toHaveBeenCalledTimes(1); + await act(async () => { + root.render( + createElement(ArticleContent, { + content: '
second', + }), + ); + }); + expect(container.querySelector("pre")?.innerHTML).toBe( + "
second",
+ );
+ });
+});
diff --git a/apps/app/tests/unit/performance/reader-highlighting.test.ts b/apps/app/tests/unit/performance/reader-highlighting.test.ts
new file mode 100644
index 000000000..77f1ae3b9
--- /dev/null
+++ b/apps/app/tests/unit/performance/reader-highlighting.test.ts
@@ -0,0 +1,41 @@
+import hljs from "highlight.js/lib/core";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { canHighlightCode } from "~/components/content-reader/codeHighlightingPolicy";
+import { highlightCode } from "~/components/content-reader/highlightCode";
+
+afterEach(() => vi.restoreAllMocks());
+
+describe("reader highlighting work bounds", () => {
+ it("skips oversized, empty and explicitly plain code before tokenization", () => {
+ const explicit = vi.spyOn(hljs, "highlight");
+ const automatic = vi.spyOn(hljs, "highlightAuto");
+ for (const [code, language] of [
+ ["x".repeat(20_001), "js"],
+ ["x".repeat(5_001), undefined],
+ ["const x = 1", "text"],
+ [" ", undefined],
+ ] as const) {
+ expect(canHighlightCode(code, language)).toBe(false);
+ expect(highlightCode(code, language)).toBeNull();
+ }
+ expect(explicit).not.toHaveBeenCalled();
+ expect(automatic).not.toHaveBeenCalled();
+ });
+
+ it("restricts detection to a fixed grammar set and leaves ambiguous prose plain", () => {
+ const automatic = vi.spyOn(hljs, "highlightAuto");
+ expect(highlightCode("hello world")).toBeNull();
+ expect(automatic.mock.calls[0]?.[1]).toHaveLength(7);
+ expect(highlightCode("const value = 42;\nconsole.log(value);")).toContain(
+ "hljs",
+ );
+ });
+
+ it("falls back when a grammar throws or a language is unavailable", () => {
+ expect(highlightCode("unknown", "nonexistent")).toBeNull();
+ vi.spyOn(hljs, "highlight").mockImplementation(() => {
+ throw new Error("grammar failure");
+ });
+ expect(highlightCode("const x = 1;", "js")).toBeNull();
+ });
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index be94e654c..4c7148596 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -220,6 +220,9 @@ importers:
feedscout:
specifier: ^1.10.0
version: 1.10.0
+ highlight.js:
+ specifier: ^11.12.0
+ version: 11.12.0
html-react-parser:
specifier: ^6.1.4
version: 6.1.4(@types/react@19.2.17)(react@19.2.7)
@@ -6670,6 +6673,10 @@ packages:
resolution: {integrity: sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==}
engines: {node: '>=6'}
+ highlight.js@11.12.0:
+ resolution: {integrity: sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==}
+ engines: {node: '>=12.0.0'}
+
hookable@6.1.1:
resolution: {integrity: sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==}
@@ -16537,6 +16544,8 @@ snapshots:
hex-rgb@4.3.0: {}
+ highlight.js@11.12.0: {}
+
hookable@6.1.1: {}
html-dom-parser@8.0.0:
From 4a0a2fa74684de7db7939045cc85a68b687ac081 Mon Sep 17 00:00:00 2001
From: Henry <48483883+hfellerhoff@users.noreply.github.com>
Date: Fri, 18 Sep 2026 18:34:26 -0400
Subject: [PATCH 09/12] reduce code text and preserve code block boundaries
---
.../components/content-reader/ReaderCodeBlock.tsx | 2 +-
.../src/components/feed/read/article.module.css | 1 +
.../unit/components/reader-code-blocks.test.ts | 14 ++++++++++++++
3 files changed, 16 insertions(+), 1 deletion(-)
diff --git a/apps/app/src/components/content-reader/ReaderCodeBlock.tsx b/apps/app/src/components/content-reader/ReaderCodeBlock.tsx
index b71e7fa1d..397d2685d 100644
--- a/apps/app/src/components/content-reader/ReaderCodeBlock.tsx
+++ b/apps/app/src/components/content-reader/ReaderCodeBlock.tsx
@@ -87,7 +87,7 @@ export function replaceReaderCodeBlock(node: DOMNode) {
);
return (
\n const value = 42;\n\n',
+ kind,
+ );
+ expect(container.querySelector("pre")?.textContent).toBe(
+ " const value = 42;\n",
+ );
+ expect(container.querySelector("button")).toBeNull();
+ },
+ );
+
it("preserves code direction and an explicit accessible label", async () => {
const { container } = await render(
'const value = 42;


