Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions apps/app/benchmarks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,3 +241,15 @@ The client audit profiles also measure local mixed-content View projection over
their complete Bookmark fixtures. This protects fetch-free View navigation and
View-chip status computation with the same 50 ms operation budget as other
client state transitions.

Run the retained browser-performance audit against a production build when a
change touches hydration, synchronization, rendering, or the reader:

```sh
pnpm benchmark:client:browser
```

It runs `tests/e2e/self-hosted/client-performance-audit.spec.ts`, which is
skipped unless `SERIAL_RUN_CLIENT_PERFORMANCE=1`, and evaluates each scenario
against the budgets in `scripts/performance/client-browser-budgets.ts`. Its
generated JSON is written under the ignored `benchmarks/results/` directory.
2 changes: 2 additions & 0 deletions apps/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
"benchmark:view-matrix:gate": "dotenv -e .env.test -- node --expose-gc --import=tsx scripts/performance/run-view-matrix-benchmark.ts --gate --output benchmarks/results/view-matrix-gate.json",
"benchmark:client": "dotenv -e .env.test -- node --import=tsx scripts/performance/run-client-audit.ts",
"benchmark:client:smoke": "dotenv -e .env.test -- node --import=tsx scripts/performance/run-client-audit.ts --profile small --output benchmarks/results/client-small.json",
"benchmark:client:browser": "SERIAL_RUN_CLIENT_PERFORMANCE=1 pnpm test:e2e:self-hosted client-performance-audit.spec.ts --project self-hosted --workers 1",
"benchmark:client:gate": "pnpm benchmark:client --profile small --gate && pnpm benchmark:client --profile representative --gate && pnpm benchmark:client --profile stress --gate",
"benchmark:client:coverage": "node --import=tsx scripts/performance/inventory-client-audit.ts",
"benchmark:client:coverage:check": "node --import=tsx scripts/performance/inventory-client-audit.ts --check",
Expand Down Expand Up @@ -133,6 +134,7 @@
"drizzle-zod": "^0.8.3",
"fast-xml-parser": "^5.10.1",
"feedscout": "^1.10.0",
"highlight.js": "^11.12.0",
"html-react-parser": "^6.1.4",
"idb-keyval": "^6.3.0",
"ioredis": "^5.11.1",
Expand Down
65 changes: 63 additions & 2 deletions apps/app/scripts/performance/client-audit-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ import {
selectPersistedPages,
} from "~/lib/data/page-retention";
import { projectLocalMixedContentOrder } from "~/lib/data/mixed-content/bookmarkProjection";
import {
getEligibleSoftReadIds,
retainEligibleSoftReadPositions,
retainSoftReadPositions,
} from "~/components/feed/view-lists/softReads";

const PAGE_SIZE = 30;
const CONTENT_STATUSES: readonly ContentStatusFilter[] = CONTENT_STATUS_FILTERS;
Expand Down Expand Up @@ -85,6 +90,7 @@ export type ClientAuditResult = {
bookmarkBurstSingleFrame: ClientAuditOperation;
bookmarkBurstSeparateFrames: ClientAuditOperation;
localViewProjection: ClientAuditOperation;
softReadProjection: ClientAuditOperation;
normalizedPersistenceMutation: ClientAuditOperation;
};
};
Expand Down Expand Up @@ -612,11 +618,65 @@ export function runClientAuditProfile(
): ClientAuditResult {
let fixture = seedClientFixture(profileName);
const profile = fixture.profile;
const savedIds = [
...fixture.feedItems
.filter((item) => item.isWatchLater)
.map((item) => item.id),
...fixture.bookmarks.filter((item) => item.isSaved).map((item) => item.id),
];
const softReadPositions = new Map(
savedIds.flatMap((id, index) =>
index % 2 === 0
? [[id, { sectionKey: "uncategorized", index }] as const]
: [],
),
);
const softReadView = {
...makeView(0),
feedIds: [...new Set(fixture.feedItems.map((item) => item.feedId))],
categoryIds: fixture.views.map((view) => view.id),
};
const softReadViews = fixture.views.map((view) =>
view.id === softReadView.id ? softReadView : view,
);
const softReadProjection = measure(() => {
const eligibleIds = getEligibleSoftReadIds({
positions: softReadPositions,
bookmarksById: bookmarksStore.getState().snapshot(),
feedItemsById: feedItemsStore.getState().feedItemsDict,
feedCategories: [],
views: softReadViews,
currentView: softReadView,
categoryFilter: -1,
feedFilter: -1,
saveStatus: "saved",
});
const section = {
name: "Saved",
layout: "list" as const,
startIndex: 0,
isUncategorized: true,
placement: null,
};
const activePositions = retainEligibleSoftReadPositions(softReadPositions, [
{ ...section, items: eligibleIds },
]);
retainSoftReadPositions(
[
{
...section,
items: savedIds.filter((id) => !softReadPositions.has(id)),
},
],
activePositions,
);
return 0;
});
const localProjectionView = fixture.views.at(-1)!;
const localProjectionFeedIds = new Set(localProjectionView.feedIds);
const localProjectionFeedItemIds = fixture.feedItems
.filter(
(item) =>
item.isWatchLater && localProjectionView.feedIds.includes(item.feedId),
(item) => item.isWatchLater && localProjectionFeedIds.has(item.feedId),
)
.map((item) => item.id);
const localViewProjection = measure(() => {
Expand Down Expand Up @@ -790,6 +850,7 @@ export function runClientAuditProfile(
bookmarkBurstSingleFrame,
bookmarkBurstSeparateFrames,
localViewProjection,
softReadProjection,
normalizedPersistenceMutation,
},
};
Expand Down
17 changes: 5 additions & 12 deletions apps/app/src/app/_app.read.$id.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ function FeedReader({
return (
<div
className={clsx(
"mx-auto grid h-full w-full place-items-center",
"mx-auto grid h-full w-full grid-cols-1 place-items-center",
articleWidthLayout.className,
)}
style={articleWidthLayout.style}
Expand Down Expand Up @@ -207,17 +207,10 @@ function FeedReader({
>
<h1 data-serial-header>{feedItem?.title}</h1>
<h6 data-serial-header>{feedItem?.author || feed?.name || ""}</h6>
{articleStyle === "simplified" ? (
// Content is sanitized by the module-level rehype pipeline above.
// react-doctor-disable-next-line react-doctor/dangerous-html-sink
<div
dangerouslySetInnerHTML={{
__html: content,
}}
/>
) : (
<ArticleContent content={content} />
)}
<ArticleContent
content={content}
simplified={articleStyle === "simplified"}
/>
</div>
</div>
{shouldShowTruncationAlert && (
Expand Down
3 changes: 3 additions & 0 deletions apps/app/src/components/bookmarks/BookmarkArticleContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useMemo, useSyncExternalStore } from "react";
import type { HTMLReactParserOptions } from "html-react-parser";
import { CustomVideoPlayer } from "~/components/CustomVideoPlayer";
import { flattenReaderImages } from "~/components/content-reader/flattenReaderImages";
import { replaceReaderCodeBlock } from "~/components/content-reader/ReaderCodeBlock";
import { ArticleImageLightbox } from "~/components/feed/read/ArticleImageLightbox";
import { useFlagState } from "~/lib/hooks/useFlagState";
import {
Expand Down Expand Up @@ -43,6 +44,8 @@ export function BookmarkArticleContent({ content }: { content: string }) {

const options: HTMLReactParserOptions = {
replace: (node) => {
const codeBlock = replaceReaderCodeBlock(node);
if (codeBlock) return codeBlock;
if (!(node instanceof Element)) return;
if (node.name === "a" && node.attribs.href?.startsWith("http")) {
node.attribs.target = "_blank";
Expand Down
2 changes: 1 addition & 1 deletion apps/app/src/components/content-reader/BookmarkReader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ export function BookmarkReader({ id }: { id: string }) {
return (
<div
className={clsx(
"mx-auto grid h-full w-full place-items-center",
"mx-auto grid h-full w-full grid-cols-1 place-items-center",
articleWidthLayout.className,
)}
style={articleWidthLayout.style}
Expand Down
100 changes: 100 additions & 0 deletions apps/app/src/components/content-reader/ReaderCodeBlock.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import parse, { Element, Text } from "html-react-parser";
import { useEffect, useMemo, useState } from "react";
import { canHighlightCode } from "./codeHighlightingPolicy";
import type { DOMNode } from "html-react-parser";

function codeText(node: DOMNode): string {
if (node instanceof Text) return node.data;
if (!(node instanceof Element)) return "";
if (node.name === "br") return "\n";
return node.children.map((child) => codeText(child as DOMNode)).join("");
}

function codeLanguage(node: Element): string | undefined {
const classes = node.attribs.class ?? "";
if (/(?:^|\s)(?:no-highlight|nohighlight)(?:\s|$)/i.test(classes))
return "text";
return (
/(?:^|\s)lang(?:uage)?-([\w+-]+)/i.exec(classes)?.[1]?.toLowerCase() ??
node.attribs["data-language"]?.toLowerCase()
);
}

function ReaderCodeBlock({
code,
language,
id,
dir,
label,
}: {
code: string;
language?: string;
id?: string;
dir?: string;
label?: string;
}) {
const [highlighted, setHighlighted] = useState<{
code: string;
language?: string;
html: string | null;
}>();

useEffect(() => {
let active = true;
let cancel = () => {};
if (!canHighlightCode(code, language)) return;
// Load only for articles with code. The plain block is readable immediately.
void import("./highlightCodeClient")
.then(({ requestCodeHighlight }) => {
if (active)
cancel = requestCodeHighlight(code, language, (html) => {
setHighlighted({ code, language, html });
});
})
.catch(() => {
/* Keep plain text when offline or a chunk cannot load. */
});
return () => {
active = false;
cancel();
};
}, [code, language]);

const html =
highlighted?.code === code && highlighted.language === language
? highlighted.html
: null;
const content = useMemo(() => (html ? parse(html) : code), [html, code]);

return (
<pre
id={id}
dir={dir}
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- Overflowing code must be scrollable with the keyboard.
tabIndex={0}
role="region"
aria-label={label ?? "Code block"}
>
<code>{content}</code>
</pre>
);
}

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 (
<ReaderCodeBlock
code={codeText(code instanceof Element ? code : node)}
language={
(code instanceof Element ? codeLanguage(code) : undefined) ??
codeLanguage(node)
}
id={node.attribs.id}
dir={node.attribs.dir}
label={node.attribs["aria-label"]}
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export function canHighlightCode(code: string, language?: string): boolean {
return (
Boolean(code.trim()) &&
code.length <= (language ? 20_000 : 5_000) &&
!/^(?:text|plain|plaintext|none)$/.test(language ?? "")
);
}
57 changes: 57 additions & 0 deletions apps/app/src/components/content-reader/highlightCode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import hljs from "highlight.js/lib/core";
import bash from "highlight.js/lib/languages/bash";
import css from "highlight.js/lib/languages/css";
import go from "highlight.js/lib/languages/go";
import java from "highlight.js/lib/languages/java";
import javascript from "highlight.js/lib/languages/javascript";
import json from "highlight.js/lib/languages/json";
import python from "highlight.js/lib/languages/python";
import rust from "highlight.js/lib/languages/rust";
import sql from "highlight.js/lib/languages/sql";
import typescript from "highlight.js/lib/languages/typescript";
import xml from "highlight.js/lib/languages/xml";
import yaml from "highlight.js/lib/languages/yaml";
import { canHighlightCode } from "./codeHighlightingPolicy";

for (const [name, grammar] of Object.entries({
bash,
css,
go,
java,
javascript,
json,
python,
rust,
sql,
typescript,
xml,
yaml,
})) {
hljs.registerLanguage(name, grammar);
}

const DETECT_LANGUAGES = [
"javascript",
"python",
"bash",
"css",
"xml",
"json",
"sql",
];

export function highlightCode(code: string, language?: string): string | null {
// Bound work on imported content. Oversized blocks remain selectable text.
if (!canHighlightCode(code, language)) return null;
if (language && !hljs.getLanguage(language)) return null;

try {
const result = language
? hljs.highlight(code, { language, ignoreIllegals: true })
: hljs.highlightAuto(code, DETECT_LANGUAGES);
if (!language && result.relevance < 2) return null;
return result.value;
} catch {
return null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { highlightCode } from "./highlightCode";

self.onmessage = (
event: MessageEvent<{ id: number; code: string; language?: string }>,
) => {
const { id, code, language } = event.data;
self.postMessage({ id, html: highlightCode(code, language) });
};
Loading
Loading