diff --git a/PERFORMANCE.md b/PERFORMANCE.md new file mode 100644 index 000000000..0b25c14b5 --- /dev/null +++ b/PERFORMANCE.md @@ -0,0 +1,134 @@ +# Performance investigation — 2026-10-27 + +Measurement baseline: `fb05d164` (`chore: deps`). The implementation is rebased onto upstream `85376fd8845015803d74c802bae0811d2849b025`, including #1220’s automatic TSX discovery. No database, query, migration, dependency-version or request-polling changes. + +## Measured change + +Shared timestamp helpers created date/time formatters for each call. Feed cards, quoted commits, notifications, chat messages, scheduling and profile clocks use these helpers during browser rendering and SSR. Reuse formatter objects, keyed by their complete options, in a FIFO cache capped at 128 entries. Cache the fixed English relative-time formatter too. Dates and rendered labels are never cached; offsets continue to follow DST and each call uses the current reader's time zone/hour cycle. The formerly unbounded calendar-day formatter cache now shares the same bound. + +Local Node v22.16.0, macOS arm64; fixed dates and clock; ten warm-up batches, then median of 31 samples, alternating old/new order. Each sample formats 100 items. These are **helper CPU timings**, not React render, page-load, network, Core Web Vitals or production server latency measurements. + +| Workload | Original | Updated | Reduction | +| --- | ---: | ---: | ---: | +| Feed: relative label + full timestamp per card | 8.121 ms | 0.834 ms | 89.7% | +| Chat: clock time per message | 2.730 ms | 0.111 ms | 95.9% | +| Profile clocks: zoned time + UTC offset | 5.761 ms | 0.390 ms | 93.2% | + +These warm-cache results do not establish cold-start improvements. Absolute times vary by runtime/hardware. Do not enforce wall-clock thresholds in CI. The benchmark checks identical output for both versions before timing each workload. + +## Reproduce + +Install locked frontend dependencies with `bun install --frozen-lockfile`. From the repository root: + +```sh +mkdir -p node_modules/.cache/performance +git show fb05d164:resources/js/lib/utils.ts > node_modules/.cache/performance/baseline-utils.ts +bun build node_modules/.cache/performance/baseline-utils.ts --target=node --packages=external --outfile=node_modules/.cache/performance/baseline-utils.mjs +node --experimental-strip-types scripts/performance/timestamps.ts "$PWD/node_modules/.cache/performance/baseline-utils.mjs" +node --experimental-strip-types --test tests/Unit/ReaderClock.test.ts +bunx vp lint --no-ignore resources/js/lib/utils.ts tests/Unit/ReaderClock.test.ts scripts/performance/timestamps.ts +bunx tsc --noEmit --project scripts/performance/tsconfig.json +``` + +The original module lives in the ignored dependency directory so package imports resolve. Compile it to JavaScript because Node does not strip TypeScript in node_modules. The script requires a baseline argument to prevent accidentally comparing the updated code with itself. + +## Regression checks and limits + +The clock suite covers 145 time-zone entries (including revisits after cache eviction), all four hour cycles, year rollover, DST boundaries, invalid-date labels, half/quarter-hour offsets, switching reader clocks, and synced-ago boundaries. More than 20,000 exact native-output comparisons pass. Existing tests also cover calendar-day labels and explicitly selected zones. + +Broader checks were run against both original and updated utils: + +- Standalone TypeScript suite: final run has 199 passing tests and the same two failing test files seen with the original helper. `RemotePushAccountIsolation.test.ts` imports `worker_threads.locks`, unavailable in local Node 22.16. `SeriesStash.test.ts` launches the TSX suite, which fails on missing generated routes. +- TSX suite: identical original/updated results, 17 passing files and 33 failing files; 74 passing tests and one failing test. Failures reference missing generated Laravel controller/route modules. +- Full application typecheck: blocked by missing generated Laravel routes. Targeted type-aware lint/typecheck covers the changed helper, regression tests and benchmark. +- PHP dependency install: the locked security advisory rejects local PHP 8.5.7; it requires at least 8.5.12 in that release line. No security checks were bypassed and no lockfile was updated. + +Consequently, local production builds, PHP tests and authenticated end-to-end/browser performance measurements remain unverified. Public guest production traces are recorded below; they do not validate the local patch. This change is ready for review, but a clean full regression run on supported PHP/Node with generated Wayfinder files is still required before merging. Do not interpret unchanged environment failures as a fully green suite. + +## Existing upstream PRs + +Read from `dev-null-sh/dev-null.sh` on 2026-10-27. Relevant existing work was intentionally not duplicated: + +| PR | Status | Scope | +| --- | --- | --- | +| [#1205](https://github.com/dev-null-sh/dev-null.sh/pull/1205) | Open | Lazy lightbox/calendar loading; also changes composer imports. Natural first-load follow-up. | +| [#340](https://github.com/dev-null-sh/dev-null.sh/pull/340) | Open | Photo-viewer lazy loading; overlaps #1205. | +| [#1130](https://github.com/dev-null-sh/dev-null.sh/pull/1130) | Open | Guest notification polling and Product Hunt banner timers. | +| [#1150](https://github.com/dev-null-sh/dev-null.sh/pull/1150) | Merged, present locally | SSR hydration rather than rebuilding existing markup. | +| [#347](https://github.com/dev-null-sh/dev-null.sh/pull/347) | Merged | Parser fast paths and commit-body memoization. | +| [#473](https://github.com/dev-null-sh/dev-null.sh/pull/473), [#680](https://github.com/dev-null-sh/dev-null.sh/pull/680) | Open | Notification grouping/query work; overlapping approaches need review. | +| [#448](https://github.com/dev-null-sh/dev-null.sh/pull/448), [#506](https://github.com/dev-null-sh/dev-null.sh/pull/506) | Open | Org membership / remote access query batching. | +| [#271](https://github.com/dev-null-sh/dev-null.sh/pull/271) | Open | Sitemap XML rendering. | + +PR titles are not evidence that their reported gains apply to this checkout. This investigation did not merge or benchmark those PRs. Database-related PRs are recorded for awareness only. + +## Next measurements + +On a runnable production build with representative data, measure cold/warm guest feed, authenticated feed, long-thread and Remotes navigation: transferred JS, LCP, INP, layout shifts, SSR CPU and request p50/p95. Use identical data, hardware and throttling before/after. Profile rendering and scrolling before choosing further changes. Evaluate #1205 and #1130 separately to attribute gains and check photo, scheduling, notification and navigation behavior. Database tuning should follow evidence from these profiles rather than speculative changes. + + +## Additional easy gains + +The same local Node runtime, 31-sample medians, alternating original/updated order; both versions built with the same Vite settings. Raw results: [hot-paths.node22.json](scripts/performance/results/hot-paths.node22.json). No network or database work is included in these helper timings. + +| Workload | Original | Updated | Reduction | +| --- | ---: | ---: | ---: | +| Graph: 100 replies | 0.09567 ms | 0.02311 ms | 75.8% | +| Graph: 500 replies | 1.99698 ms | 0.11020 ms | 94.5% | +| Graph: 2,000 replies (stress test) | 35.65110 ms | 0.54689 ms | 98.5% | +| Graph: 500 trunk-only nodes (control) | 0.05059 ms | 0.04906 ms | approximately unchanged | +| Emoji search, `heart` | 0.09212 ms | 0.03109 ms | 66.3% | +| Emoji search, empty query | 0.66144 ms | 0.53950 ms | 18.4% | +| Inbox search: 1,000 conversations | 3.21808 ms | 0.05628 ms | 98.3% | +| Certificate check: 100 highlighted lines, no fingerprint | 0.07225 ms | 0.00090 ms | 98.8% | +| Certificate check: plain fingerprint present (control) | 0.00033 ms | 0.00023 ms | 28.9% | +| Chat newest-25 boundary: 1,000 messages | 0.00422 ms | 0.00007 ms | 98.3% | + +Graph layout builds branch indexes lazily and sorts siblings once. It preserves node/edge order, equal-height ties and drawing-trunk behavior. The trunk-only control initially regressed; moving index construction into the branch path eliminated that regression. Its small measured difference should be treated as noise. + +Emoji search precomputes shortcode entries and word boundaries, keeping ranking, aliases and usage preferences unchanged. Inbox search uses a Set for deduplication instead of a repeated linear search. Certificate checks avoid splitting highlighted HTML when no fingerprint is present, and handle plain text directly. Chat finds the 25th newest actual message from the end, avoiding old-history scans and intermediate arrays. **The chat benchmark stubs DOM geometry and CSS escaping**; it measures the boundary-selection work, not actual browser scroll latency. Large percentage improvements on tiny functions do not imply equivalent whole-app improvements. + +The shared relative-time timer also stops while the document is hidden, updates immediately on return and removes its visibility listener when the last subscriber leaves. Deterministic fake-timer tests verify **120 hidden timer ticks per hour become zero**. This changes no HTTP polling. + +Additional verification passes: 59 focused Node tests; 16 focused TSX tests (emoji, timer lifecycle, LCP image SSR markup); exact old/new edge equality for 300 seeded generated graph layouts; exact emoji-result equality for 3,096 distinct queries with usage preferences. The broader standalone run passed 203 tests with the same two environment-related failures recorded above. Pure-helper typechecking and targeted type-aware lint pass. Lint/typechecking of changed page components remains blocked by absent generated Wayfinder modules, so those are not claimed green. + +Reproduce: + +```sh +node scripts/performance/prepare-hot-paths.mjs fb05d164 +node --experimental-strip-types scripts/performance/hot-paths.ts +node --experimental-strip-types --test tests/Unit/GraphEdges.test.ts tests/Unit/ChatScroll.test.ts tests/Unit/CertificateHashes.test.ts tests/Unit/ConversationSearch.test.ts tests/Unit/ReaderClock.test.ts +bunx vp test run --config scripts/performance/vite.config.ts +``` + +The LCP regression suite lives under `tests/Unit` and is included automatically by the standard runner and test typecheck after upstream #1220 merged. The isolated performance config provides a focused shortcut; it does not replace normal CI coverage. Graph visual PRs [#1169](https://github.com/dev-null-sh/dev-null.sh/pull/1169), [#1147](https://github.com/dev-null-sh/dev-null.sh/pull/1147) and [#1126](https://github.com/dev-null-sh/dev-null.sh/pull/1126) touch the same file, but their diffs do not change `graphEdges`; review/rebase when integrating them. None of the open PRs returned in the refreshed queue changed the other optimized helper files. + +## LCP investigation + +Read-only Chrome DevTools MCP traces of the public production homepage on 2026-10-27, in isolated headless Chrome. The MCP server was started temporarily; no persistent editor configuration was changed. No authentication or production mutation was performed. Summary: [lcp.production.json](scripts/performance/results/lcp.production.json). + +| Trace | LCP | TTFB | Resource load delay | Resource duration | Render delay | CLS | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| Desktop, unthrottled, warm reload | 786 ms | 708 ms | n/a (text) | n/a | 78 ms | 0.067 | +| Mobile, 390×844 @3x, Slow 4G, 4× CPU, fresh visit | 7,524 ms | 564 ms | 2,859 ms | 2,007 ms | 2,094 ms | 0 | + +These are single lab observations, **not field percentiles or a before/after comparison**. Feed content changes between visits. The desktop LCP was text; the mobile LCP was the first card's uploaded photo. The desktop trace used a service worker on reload; the mobile trace began recording on about:blank before its first navigation, with no service-worker interception of its LCP image. INP was not measured because no user interaction was performed. + +The mobile image started at Low priority, was requested 3,423 ms after navigation, completed at 5,430 ms, and painted at 7,524 ms. Source and production HTML confirm that photos are lazy-loaded and hidden with `opacity-0` until React handles their load state. This creates two avoidable dependencies for an above-the-fold image: waiting for lazy-load eligibility and waiting for JavaScript to reveal already-downloaded pixels. Google's [LCP optimization guidance](https://web.dev/articles/optimize-lcp) describes both late discovery and hidden-until-JavaScript rendering as causes of LCP delay. + +Local fix: only the first photo of the first log card (or its pinned card), and the focused commit's first photo, receives eager loading and high fetch priority. That photo is visible in the server HTML and does not mount the loading overlay. Other photos retain lazy loading and their existing placeholders. Dimensions, alternative text, photo navigation and error fallback are preserved. SSR markup tests verify that a two-photo grid promotes exactly one image. The priorities are deliberately limited to the log and focused commit; profile feeds with large headers are not made eager indiscriminately. + +**No post-change page-level LCP gain is claimed**: production runs the deployed bundle, and the local app still cannot build/run until the PHP/Wayfinder setup is fixed. The 2,859 ms discovery delay and 2,094 ms render delay are diagnostic measurements, not promised savings: other work can become critical once these gates are removed. Test cold/warm visits, photos failing to load, navigation, hydration and collage layouts against a built patch before merging. + +Other evidence: the homepage preloads all six configured font weights. Both desktop and mobile traces estimated zero LCP savings from the render-blocking CSS insight, so no blanket CSS deferral or preload removal was applied. The desktop document latency dominated that run, but this alone does not establish a database bottleneck; no database changes were made. PR #1205 remains relevant to reducing eager JS during hydration. + + +## Tab-switching investigation and review + +The main log page explicitly disables tab prefetching (`resources/js/pages/log/show.tsx`) and flushes the entire prefetch cache when the log changes. This preserves correctness today: a GET of a different log currently persists the user's selected log in `LogController::show`, so a speculative fetch would otherwise overwrite that preference before the tab is actually opened. Enabling prefetch alone is unsafe. The helper optimizations above reduce work after a response arrives; they do not remove this network round trip. + +Existing open [#755](https://github.com/dev-null-sh/dev-null.sh/pull/755) addresses safe log prefetch, instant navigation/loading states and saving displayed-log preferences separately; [#953](https://github.com/dev-null-sh/dev-null.sh/pull/953) addresses immediate profile-tab feedback and avoiding a whole-page view transition. They should be reviewed/rebased for current polling, the README profile tab, failed/cancelled requests, rapid switches, stale cached responses, back/forward, guest Following redirects and remembering the final displayed log. No duplicate navigation implementation is included here, and their reported responsiveness was not used as a benchmark of this patch. Moving an existing preference save is different from tuning or changing the database schema; this patch does neither. + +Independent review identified one coverage issue: the new LCP unit suite was initially only in a manual runner. It was moved into the standard unit-test directory and is now covered by upstream #1220's test and typecheck globs. Revisions after the fix require exact-head independent approval before publication. + +Required repository commands were attempted: `composer test:lint`, `composer test:type-coverage`, `bun run test:unit` and the full `vendor/bin/pest --parallel --ci --coverage --exactly=100.0` command. They cannot run without the PHP vendor dependencies. `bun run test:lint` passes resource formatting, then fails on missing generated Wayfinder imports; app and test typechecks show missing generated module errors. These are recorded as blockers, not passes. No Docker runtime or additional dependency installation was attempted after the user's disk-space constraint. diff --git a/resources/js/components/commit-card.tsx b/resources/js/components/commit-card.tsx index 80b7ae126..07ccc601c 100644 --- a/resources/js/components/commit-card.tsx +++ b/resources/js/components/commit-card.tsx @@ -85,6 +85,7 @@ export function CommitCard({ initialReplies, threadActions, showImages = true, + prioritizeImage = false, loadsReplies = false, endsWithDot = false, railNodePosition = 'top', @@ -110,6 +111,7 @@ export function CommitCard({ initialReplies?: CursorPaginated; threadActions?: ReactNode; showImages?: boolean; + prioritizeImage?: boolean; loadsReplies?: boolean; endsWithDot?: boolean; railNodePosition?: 'top' | 'center' | 'bottom' | 'author'; @@ -469,7 +471,12 @@ export function CommitCard({ /> {translation.notice} - {showImages && } + {showImages && ( + + )} {commit.poll && ( ( diff --git a/resources/js/components/commit-images.tsx b/resources/js/components/commit-images.tsx index 9bc295d3d..e54e43ae0 100644 --- a/resources/js/components/commit-images.tsx +++ b/resources/js/components/commit-images.tsx @@ -80,17 +80,19 @@ function CommitImageThumbnail({ alt, className, isSingle, + priority = false, }: { image: ShownImage; alt: string; className: string; isSingle: boolean; + priority?: boolean; }) { const [isLoaded, setIsLoaded] = useState(false); return ( <> - {!isLoaded && ( + {!priority && !isLoaded && ( @@ -105,7 +107,8 @@ function CommitImageThumbnail({ alt={alt} width={image.width} height={image.height} - loading="lazy" + loading={priority ? 'eager' : 'lazy'} + fetchPriority={priority ? 'high' : undefined} decoding="async" data-single-image={isSingle || undefined} onLoad={() => setIsLoaded(true)} @@ -113,7 +116,7 @@ function CommitImageThumbnail({ className={cn( className, 'transition-opacity duration-300', - !isLoaded && 'opacity-0', + !priority && !isLoaded && 'opacity-0', )} /> @@ -123,13 +126,14 @@ function CommitImageThumbnail({ /** * A commit's images open in its photo viewer. Other images, like a message's, open wherever `onOpen` shows them. */ -type CommitImagesProps = +type CommitImagesProps = { priority?: boolean } & ( | { commit: Commit; images?: never; onOpen?: never } | { commit?: never; images: ShownImage[]; onOpen?: (index: number) => void; - }; + } +); const isModifiedClick = (event: MouseEvent): boolean => event.metaKey || event.ctrlKey || event.shiftKey || event.altKey; @@ -138,6 +142,7 @@ export function CommitImages({ commit, images: draftImages, onOpen, + priority = false, }: CommitImagesProps) { const images = commit ? (commit.images ?? []) : draftImages; @@ -167,6 +172,7 @@ export function CommitImages({ const thumbnail = ( void>(); let now = Date.now(); let interval: ReturnType | undefined; +function tick(): void { + now = Date.now(); + listeners.forEach((notify) => notify()); +} + +function start(): void { + if ( + interval === undefined && + (typeof document === 'undefined' || !document.hidden) + ) { + interval = setInterval(tick, 30_000); + } +} + +function visibilityChanged(): void { + if (document.hidden) { + clearInterval(interval); + interval = undefined; + } else { + // Refresh immediately on return rather than waiting for the next half-minute tick. + tick(); + start(); + } +} + const subscribe = (listener: () => void) => { + const wasEmpty = listeners.size === 0; listeners.add(listener); - if (interval === undefined) { + if (wasEmpty) { now = Date.now(); - interval = setInterval(() => { - now = Date.now(); - listeners.forEach((notify) => notify()); - }, 30_000); + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', visibilityChanged); + } + start(); } return () => { @@ -21,6 +47,12 @@ const subscribe = (listener: () => void) => { if (listeners.size === 0) { clearInterval(interval); interval = undefined; + if (typeof document !== 'undefined') { + document.removeEventListener( + 'visibilitychange', + visibilityChanged, + ); + } } }; }; diff --git a/resources/js/lib/certificate-hashes.ts b/resources/js/lib/certificate-hashes.ts index 4b1d9276d..8cb248e08 100644 --- a/resources/js/lib/certificate-hashes.ts +++ b/resources/js/lib/certificate-hashes.ts @@ -15,8 +15,20 @@ export const certificateHashPattern = new RegExp( 'gi', ); +const certificateHashCandidate = new RegExp(certificateHashPattern.source, 'i'); + /** Decorates only text in already escaped, server-highlighted code, never HTML attributes. */ export function withCertificateHashes(html: string): string { + if (!html.includes('<')) { + return html.replace( + certificateHashPattern, + '$1', + ); + } + + // Most highlighted code has no compromised fingerprint: avoid splitting all its tags. + if (!certificateHashCandidate.test(html)) return html; + return html .split(/(<[^>]*>)/g) .map((part, index) => diff --git a/resources/js/lib/chat-scroll.ts b/resources/js/lib/chat-scroll.ts index 379875bdd..64e184279 100644 --- a/resources/js/lib/chat-scroll.ts +++ b/resources/js/lib/chat-scroll.ts @@ -4,7 +4,14 @@ export function isBehindLatest( scroller: HTMLElement, messages: Message[], ): boolean { - const boundary = messages.filter((message) => !message.event).at(-25); + let remaining = 25; + let boundary: Message | undefined; + for (let index = messages.length - 1; index >= 0; index--) { + if (!messages[index].event && --remaining === 0) { + boundary = messages[index]; + break; + } + } const bubble = boundary ? scroller.querySelector( `[data-scroll-anchor="${CSS.escape(boundary.client_id)}"]`, diff --git a/resources/js/lib/conversation-search.ts b/resources/js/lib/conversation-search.ts index 225e1d573..70305b460 100644 --- a/resources/js/lib/conversation-search.ts +++ b/resources/js/lib/conversation-search.ts @@ -15,6 +15,7 @@ export function conversationsMatching( const isPersonMatch = ({ handle, name }: User) => isMatch(handle) || isMatch(name); + const seen = new Set(); return [ ...found, ...onHand.filter( @@ -26,8 +27,9 @@ export function conversationsMatching( isPersonMatch(user), ), ), - ].filter( - (conversation, index, all) => - all.findIndex(({ id }) => id === conversation.id) === index, - ); + ].filter((conversation) => { + if (seen.has(conversation.id)) return false; + seen.add(conversation.id); + return true; + }); } diff --git a/resources/js/lib/emoji.ts b/resources/js/lib/emoji.ts index 1cc6b1bb4..e838142ff 100644 --- a/resources/js/lib/emoji.ts +++ b/resources/js/lib/emoji.ts @@ -39,6 +39,14 @@ export type EmojiSuggestion = { emoji: string; }; +const searchableShortcodes = Object.entries(shortcodeEmoji).map( + ([name, emoji]) => ({ + name, + emoji, + words: name.split(/[_-]/), + }), +); + const emojiNames = new Map(); Object.entries(shortcodeEmoji).forEach(([name, emoji]) => { @@ -57,12 +65,12 @@ export function emojiName(emoji: string): string | undefined { /** * 0 when the shortcode starts with the query, 1 when a later word does, 2 when it only contains it. */ -function matchRank(name: string, partial: string): number { +function matchRank(name: string, words: string[], partial: string): number { if (name.startsWith(partial)) { return 0; } - return name.split(/[_-]/).some((word) => word.startsWith(partial)) ? 1 : 2; + return words.some((word) => word.startsWith(partial)) ? 1 : 2; } /** @@ -80,21 +88,19 @@ export function searchEmoji( .replace(/^:+|:+$/g, ''); const best = new Map(); - Object.keys(shortcodeEmoji) - .filter((name) => name.includes(partial)) - .forEach((name) => { - const emoji = shortcodeEmoji[name]; - const rank = matchRank(name, partial); - const current = best.get(emoji); - - if ( - current === undefined || - rank < current.rank || - (rank === current.rank && name.length < current.name.length) - ) { - best.set(emoji, { name, rank }); - } - }); + for (const { name, emoji, words } of searchableShortcodes) { + if (!name.includes(partial)) continue; + const rank = matchRank(name, words, partial); + const current = best.get(emoji); + + if ( + current === undefined || + rank < current.rank || + (rank === current.rank && name.length < current.name.length) + ) { + best.set(emoji, { name, rank }); + } + } return [...best.entries()] .sort( diff --git a/resources/js/lib/graph.ts b/resources/js/lib/graph.ts index eadfffee7..5e756bf5a 100644 --- a/resources/js/lib/graph.ts +++ b/resources/js/lib/graph.ts @@ -50,6 +50,8 @@ export function graphEdges( const byId = new Map(nodes.map((node) => [node.id, node])); const edges: GraphEdge[] = []; + let branches: ReturnType | undefined; + for (const node of nodes) { const previous = node.after === undefined ? undefined : byId.get(node.after); @@ -62,23 +64,11 @@ export function graphEdges( node.parent === undefined ? undefined : byId.get(node.parent); if (parent) { - const continues = nodes.some( - (other) => - other.after === parent.id && - other.y > node.y && - !drawing.has(trunkId(parent, other)), - ); - const above = nodes - .filter( - (other) => other.parent === parent.id && other.y < node.y, - ) - .reduce( - (closest, other) => - closest === undefined || other.y > closest.y - ? other - : closest, - undefined, - ); + branches ??= branchNeighbors(nodes, drawing); + const { continuationBottom, aboveNode } = branches; + const continues = + (continuationBottom.get(parent.id) ?? -Infinity) > node.y; + const above = aboveNode.get(node); edges.push( branch( @@ -97,6 +87,46 @@ export function graphEdges( return edges; } +/** Lazily built: a canvas with trunks alone needs no branch indexes. */ +function branchNeighbors(nodes: GraphNode[], drawing: ReadonlySet) { + // Index each parent's column once rather than scanning the whole canvas per reply. + const continuationBottom = new Map(); + const siblings = new Map(); + for (const node of nodes) { + if ( + node.after !== undefined && + !drawing.has(`${node.after}->${node.id}`) + ) { + const bottom = continuationBottom.get(node.after) ?? -Infinity; + if (node.y > bottom) continuationBottom.set(node.after, node.y); + } + if (node.parent !== undefined && !Number.isNaN(node.y)) { + const group = siblings.get(node.parent); + if (group) group.push(node); + else siblings.set(node.parent, [node]); + } + } + + const aboveNode = new Map(); + for (const group of siblings.values()) { + group.sort((a, b) => a.y - b.y); + let above: GraphNode | undefined; + for (let index = 0; index < group.length;) { + const first = group[index]; + let next = index; + // Equal-height siblings share the previous row. Keep input-order tie breaking. + while (next < group.length && group[next].y === first.y) { + if (above) aboveNode.set(group[next], above); + next++; + } + above = first; + index = next; + } + } + + return { continuationBottom, aboveNode }; +} + /** * An animation running on a node or around it: how long it has left, and, while it waits to start, how long until it * does. A node that trails its line, like an end dot, only pops once the line is drawn. diff --git a/resources/js/lib/utils.ts b/resources/js/lib/utils.ts index d63dccf6a..7dd7f292a 100644 --- a/resources/js/lib/utils.ts +++ b/resources/js/lib/utils.ts @@ -58,24 +58,47 @@ export function readerClock(): Clock { ); } -const dayFormats = new Map(); +// Bound process-wide SSR caches; keys include every option, never a reader's clock alone. +const dateFormats = new Map(); +const MAX_DATE_FORMATS = 128; + +function dateFormat(options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat { + const key = JSON.stringify(options); + let format = dateFormats.get(key); + + if (format === undefined) { + format = new Intl.DateTimeFormat(dateLocale, options); + + if (dateFormats.size >= MAX_DATE_FORMATS) { + dateFormats.delete(dateFormats.keys().next().value!); + } + + dateFormats.set(key, format); + } + + return format; +} + +// Match Date.toLocaleString's behavior for invalid dates. +function formatDate(date: Date, options: Intl.DateTimeFormatOptions): string { + return Number.isNaN(date.getTime()) + ? 'Invalid Date' + : dateFormat(options).format(date); +} + +let relativeFormat: Intl.RelativeTimeFormat | undefined; /** The calendar day a moment falls on in a time zone. */ function dayIn( date: Date, timeZone: string, ): { year: number; month: number; day: number } { - let format = dayFormats.get(timeZone); - - if (format === undefined) { - format = new Intl.DateTimeFormat(dateLocale, { - timeZone, - year: 'numeric', - month: 'numeric', - day: 'numeric', - }); - dayFormats.set(timeZone, format); - } + const format = dateFormat({ + timeZone, + year: 'numeric', + month: 'numeric', + day: 'numeric', + }); const parts = Object.fromEntries( format.formatToParts(date).map((part) => [part.type, part.value]), @@ -127,7 +150,7 @@ export function formatRelativeTime(date: string, now: Date = new Date()) { const { timeZone } = readerClock(); - return new Date(date).toLocaleDateString(dateLocale, { + return formatDate(new Date(date), { month: 'short', day: 'numeric', year: @@ -139,7 +162,7 @@ export function formatRelativeTime(date: string, now: Date = new Date()) { } export function formatFullDate(date: string): string { - return new Date(date).toLocaleDateString(dateLocale, { + return formatDate(new Date(date), { month: 'short', day: 'numeric', year: 'numeric', @@ -150,9 +173,7 @@ export function formatFullDate(date: string): string { export function formatFullDateTime(date: string, timeZone?: string): string { const zone = timeZone ?? readerClock().timeZone; - return `${formatZonedTime(date, zone)} · ${new Date( - date, - ).toLocaleDateString(dateLocale, { + return `${formatZonedTime(date, zone)} · ${formatDate(new Date(date), { month: 'short', day: 'numeric', year: 'numeric', @@ -176,10 +197,11 @@ export function formatSyncedAgo(date: string, now: Date): string { ? [Math.floor(seconds / 3600), 'hour'] : [Math.floor(seconds / 86400), 'day']; - return new Intl.RelativeTimeFormat(dateLocale, { numeric: 'auto' }).format( - -value, - unit, - ); + relativeFormat ??= new Intl.RelativeTimeFormat(dateLocale, { + numeric: 'auto', + }); + + return relativeFormat.format(-value, unit); } export function laneColor(lane: Lane): string { @@ -283,7 +305,7 @@ export function formatDayLabel(date: string, now: Date = new Date()): string { return 'Yesterday'; } - return new Date(date).toLocaleDateString(dateLocale, { + return formatDate(new Date(date), { weekday: 'short', month: 'short', day: 'numeric', @@ -298,7 +320,7 @@ export function browserTimeZone(): string { /** A time of day in a time zone, its hours running as the reader's clock does. */ export function formatZonedTime(date: string, timeZone: string): string { - return new Date(date).toLocaleTimeString(dateLocale, { + return formatDate(new Date(date), { hour: 'numeric', minute: '2-digit', hourCycle: readerClock().hourCycle, @@ -315,7 +337,7 @@ export function formatClockTime(date: string): string { export function formatClockDateTime(date: string): string { const { timeZone, hourCycle } = readerClock(); - return new Date(date).toLocaleString(dateLocale, { + return formatDate(new Date(date), { dateStyle: 'medium', timeStyle: 'short', hourCycle, @@ -325,7 +347,7 @@ export function formatClockDateTime(date: string): string { /** The month a moment falls in on the reader's calendar, like `October 2026`. */ export function formatClockMonth(date: string): string { - return new Date(date).toLocaleDateString(dateLocale, { + return formatDate(new Date(date), { month: 'long', year: 'numeric', timeZone: readerClock().timeZone, @@ -335,7 +357,7 @@ export function formatClockMonth(date: string): string { /** The hour, from 0 to 23, and the minute a moment falls on on the reader's clock. */ export function clockTimeOf(date: string): { hour: number; minute: number } { const parts = Object.fromEntries( - new Intl.DateTimeFormat(dateLocale, { + dateFormat({ hour: 'numeric', minute: 'numeric', hourCycle: 'h23', @@ -352,7 +374,7 @@ export function clockTimeOf(date: string): { hour: number; minute: number } { * The offset a timezone has at a moment, like `UTC +02:00`, or `UTC` when it has none. */ export function formatUtcOffset(date: Date, timeZone: string): string { - const offset = new Intl.DateTimeFormat('en-US', { + const offset = dateFormat({ timeZone, timeZoneName: 'longOffset', }) @@ -369,7 +391,7 @@ export function formatUtcOffset(date: Date, timeZone: string): string { export function formatZonedDate(date: string, timeZone: string): string { const value = new Date(date); - return value.toLocaleDateString(dateLocale, { + return formatDate(value, { month: 'short', day: 'numeric', year: diff --git a/resources/js/pages/commit/show.tsx b/resources/js/pages/commit/show.tsx index a2e8f6134..d1a3591a3 100644 --- a/resources/js/pages/commit/show.tsx +++ b/resources/js/pages/commit/show.tsx @@ -117,6 +117,7 @@ export default function Show({ endsWithDot={shown.length === 0} threadPosition={positionAt(focusedPosition)} isFocused + prioritizeImage /> {shown.map((next, index) => { diff --git a/scripts/performance/hot-paths.ts b/scripts/performance/hot-paths.ts new file mode 100644 index 000000000..049befb03 --- /dev/null +++ b/scripts/performance/hot-paths.ts @@ -0,0 +1,230 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { pathToFileURL } from 'node:url'; +import type { GraphNode } from '../../resources/js/lib/graph.ts'; +import type { Message } from '../../resources/js/types/index.ts'; + +const load = (version: string, name: string) => + import( + pathToFileURL( + resolve(`node_modules/.cache/performance/${version}-${name}.mjs`), + ).href + ); +const before = { + graph: (await load( + 'baseline', + 'graph', + )) as typeof import('../../resources/js/lib/graph.ts'), + chat: (await load( + 'baseline', + 'chat-scroll', + )) as typeof import('../../resources/js/lib/chat-scroll.ts'), + certificates: (await load( + 'baseline', + 'certificate-hashes', + )) as typeof import('../../resources/js/lib/certificate-hashes.ts'), + emoji: (await load( + 'baseline', + 'emoji', + )) as typeof import('../../resources/js/lib/emoji.ts'), +}; +const after = { + graph: (await load('current', 'graph')) as typeof before.graph, + chat: (await load('current', 'chat-scroll')) as typeof before.chat, + certificates: (await load( + 'current', + 'certificate-hashes', + )) as typeof before.certificates, + emoji: (await load('current', 'emoji')) as typeof before.emoji, +}; +const rows: object[] = []; +function measure( + name: string, + old: () => unknown, + updated: () => unknown, + iterations = 100, +) { + assert.deepEqual(updated(), old(), name); + for (let i = 0; i < 20; i++) { + old(); + updated(); + } + const samples = { before: [] as number[], after: [] as number[] }; + for (let sample = 0; sample < 31; sample++) { + for (const key of sample % 2 + ? (['after', 'before'] as const) + : (['before', 'after'] as const)) { + const run = key === 'before' ? old : updated; + const start = performance.now(); + for (let i = 0; i < iterations; i++) run(); + samples[key].push((performance.now() - start) / iterations); + } + } + const median = (values: number[]) => values.sort((a, b) => a - b)[15]!; + const original = median(samples.before), + current = median(samples.after); + rows.push({ + workload: name, + before_ms: +original.toFixed(5), + after_ms: +current.toFixed(5), + reduction_percent: +((1 - current / original) * 100).toFixed(1), + }); +} +const node = ( + id: string, + y: number, + rest: Partial = {}, +): GraphNode => ({ id, x: 32, y, radius: 12, lane: 'blue', ...rest }); +for (const count of [20, 100, 500, 2000]) { + const nodes = [ + node('root', 0), + ...Array.from({ length: count }, (_, i) => + node(`reply-${i}`, (i + 1) * 60, { parent: 'root', x: 96 }), + ), + ]; + measure( + `graph ${count} replies`, + () => before.graph.graphEdges(nodes), + () => after.graph.graphEdges(nodes), + count >= 500 ? 10 : 100, + ); +} +const trunk = Array.from({ length: 500 }, (_, i) => + node(`${i}`, i * 60, { after: `${i - 1}` }), +); +measure( + 'graph 500 trunk-only nodes', + () => before.graph.graphEdges(trunk), + () => after.graph.graphEdges(trunk), +); + +// Seeded shuffled layouts, including ties, multiple parents and trunks still drawing. +let seed = 42; +const random = () => + (seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0) / 2 ** 32; +for (let sample = 0; sample < 300; sample++) { + const nodes = Array.from({ length: 100 }, (_, i) => + node(`${i}`, Math.floor(random() * 20) * 10, { + x: Math.floor(random() * 4) * 32, + parent: + random() < 0.8 ? `${Math.floor(random() * 110)}` : undefined, + after: `${Math.floor(random() * 110)}`, + dashed: random() < 0.5, + dimmed: random() < 0.5, + }), + ); + const drawing = new Set( + nodes.filter(() => random() < 0.5).map((n) => `${n.after}->${n.id}`), + ); + assert.deepEqual( + after.graph.graphEdges(nodes, drawing), + before.graph.graphEdges(nodes, drawing), + `random graph ${sample}`, + ); +} + +const originalCSS = globalThis.CSS; +globalThis.CSS = { escape: (value: string) => value } as typeof CSS; +const scroller = { + querySelector: () => ({ getBoundingClientRect: () => ({ top: 700 }) }), + getBoundingClientRect: () => ({ bottom: 600 }), +} as unknown as HTMLElement; +for (const count of [100, 1000, 10000]) { + const messages = Array.from( + { length: count }, + (_, i) => + ({ + client_id: `${i}`, + event: i % 7 === 0 ? { type: 'joined' } : null, + }) as Message, + ); + measure( + `chat scroll ${count} messages`, + () => before.chat.isBehindLatest(scroller, messages), + () => after.chat.isBehindLatest(scroller, messages), + 1000, + ); +} +globalThis.CSS = originalCSS; +for (const lines of [10, 100]) { + const html = + 'const x = 42;\n'.repeat( + lines, + ); + measure( + `certificate scan ${lines} highlighted lines, no hash`, + () => before.certificates.withCertificateHashes(html), + () => after.certificates.withCertificateHashes(html), + ); +} +const fingerprint = + 'E7568FE6E12331F4FD7DE61DA4C7559D0F8EB192A218D4741A3BA75184333838'; +measure( + 'certificate match present', + () => before.certificates.withCertificateHashes(fingerprint), + () => after.certificates.withCertificateHashes(fingerprint), +); +for (const query of ['', 'a', 'heart', 'no_such_shortcode']) { + measure( + `emoji search ${JSON.stringify(query)}`, + () => before.emoji.searchEmoji(query), + () => after.emoji.searchEmoji(query), + 30, + ); +} +const oldSearch = (await load( + 'baseline', + 'conversation-search', +)) as typeof import('../../resources/js/lib/conversation-search.ts'); +const newSearch = (await load( + 'current', + 'conversation-search', +)) as typeof oldSearch; +for (const count of [100, 1000]) { + const conversations = Array.from({ length: count }, (_, i) => ({ + id: `${i}`, + partner: null, + name: `team-${i}`, + })) as import('../../resources/js/types/index.ts').ConversationSummary[]; + const found = conversations.slice(0, count / 2).map((c) => ({ ...c })); + measure( + `inbox search ${count} conversations`, + () => oldSearch.conversationsMatching('team', conversations, found), + () => newSearch.conversationsMatching('team', conversations, found), + ); +} +const shortcodes = Object.keys( + JSON.parse( + readFileSync('resources/js/lib/emoji-shortcodes.json', 'utf8'), + ) as Record, +); +const queries = new Set( + shortcodes.flatMap((name) => [name.slice(0, 3), name.slice(-3), name]), +); +const uses = new Map([ + ['😍', 10], + ['❤️', 5], + ['👍', 12], +]); +for (const query of queries) { + assert.deepEqual( + after.emoji.searchEmoji(query, uses), + before.emoji.searchEmoji(query, uses), + `emoji ${query}`, + ); +} +console.log( + JSON.stringify( + { + runtime: process.version, + samples: 31, + graph_parity_layouts: 300, + emoji_parity_queries: queries.size, + results: rows, + }, + null, + 2, + ), +); diff --git a/scripts/performance/prepare-hot-paths.mjs b/scripts/performance/prepare-hot-paths.mjs new file mode 100644 index 000000000..a8740aed6 --- /dev/null +++ b/scripts/performance/prepare-hot-paths.mjs @@ -0,0 +1,40 @@ +import { execFileSync } from 'node:child_process'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { build } from 'vite-plus'; + +const directory = resolve('node_modules/.cache/performance'); +mkdirSync(directory, { recursive: true }); +for (const name of [ + 'graph', + 'chat-scroll', + 'certificate-hashes', + 'emoji', + 'conversation-search', +]) { + writeFileSync( + `${directory}/baseline-${name}.ts`, + execFileSync('git', [ + 'show', + `${process.argv[2] ?? 'fb05d164'}:resources/js/lib/${name}.ts`, + ]), + ); + for (const version of ['baseline', 'current']) { + await build({ + configFile: false, + resolve: { alias: { '@': resolve('resources/js') } }, + build: { + outDir: directory, + emptyOutDir: false, + lib: { + entry: + version === 'baseline' + ? `${directory}/baseline-${name}.ts` + : resolve(`resources/js/lib/${name}.ts`), + formats: ['es'], + fileName: () => `${version}-${name}.mjs`, + }, + }, + }); + } +} diff --git a/scripts/performance/results/hot-paths.node22.json b/scripts/performance/results/hot-paths.node22.json new file mode 100644 index 000000000..bedc4af4a --- /dev/null +++ b/scripts/performance/results/hot-paths.node22.json @@ -0,0 +1,110 @@ +{ + "runtime": "v22.16.0", + "samples": 31, + "graph_parity_layouts": 300, + "emoji_parity_queries": 3096, + "results": [ + { + "workload": "graph 20 replies", + "before_ms": 0.00683, + "after_ms": 0.00482, + "reduction_percent": 29.4 + }, + { + "workload": "graph 100 replies", + "before_ms": 0.09567, + "after_ms": 0.02311, + "reduction_percent": 75.8 + }, + { + "workload": "graph 500 replies", + "before_ms": 1.99698, + "after_ms": 0.1102, + "reduction_percent": 94.5 + }, + { + "workload": "graph 2000 replies", + "before_ms": 35.6511, + "after_ms": 0.54689, + "reduction_percent": 98.5 + }, + { + "workload": "graph 500 trunk-only nodes", + "before_ms": 0.05059, + "after_ms": 0.04906, + "reduction_percent": 3 + }, + { + "workload": "chat scroll 100 messages", + "before_ms": 0.00031, + "after_ms": 0.00007, + "reduction_percent": 77.3 + }, + { + "workload": "chat scroll 1000 messages", + "before_ms": 0.00422, + "after_ms": 0.00007, + "reduction_percent": 98.3 + }, + { + "workload": "chat scroll 10000 messages", + "before_ms": 0.03754, + "after_ms": 0.00007, + "reduction_percent": 99.8 + }, + { + "workload": "certificate scan 10 highlighted lines, no hash", + "before_ms": 0.00819, + "after_ms": 0.00017, + "reduction_percent": 97.9 + }, + { + "workload": "certificate scan 100 highlighted lines, no hash", + "before_ms": 0.07225, + "after_ms": 0.0009, + "reduction_percent": 98.8 + }, + { + "workload": "certificate match present", + "before_ms": 0.00033, + "after_ms": 0.00023, + "reduction_percent": 28.9 + }, + { + "workload": "emoji search \"\"", + "before_ms": 0.66144, + "after_ms": 0.5395, + "reduction_percent": 18.4 + }, + { + "workload": "emoji search \"a\"", + "before_ms": 0.58159, + "after_ms": 0.37784, + "reduction_percent": 35 + }, + { + "workload": "emoji search \"heart\"", + "before_ms": 0.09212, + "after_ms": 0.03109, + "reduction_percent": 66.3 + }, + { + "workload": "emoji search \"no_such_shortcode\"", + "before_ms": 0.05143, + "after_ms": 0.01105, + "reduction_percent": 78.5 + }, + { + "workload": "inbox search 100 conversations", + "before_ms": 0.03629, + "after_ms": 0.00532, + "reduction_percent": 85.3 + }, + { + "workload": "inbox search 1000 conversations", + "before_ms": 3.21808, + "after_ms": 0.05628, + "reduction_percent": 98.3 + } + ] +} diff --git a/scripts/performance/results/lcp.production.json b/scripts/performance/results/lcp.production.json new file mode 100644 index 000000000..42ad81a8a --- /dev/null +++ b/scripts/performance/results/lcp.production.json @@ -0,0 +1,44 @@ +[ + { + "label": "desktop-warm", + "url": "https://dev-null.sh/", + "date": "2026-10-27", + "configuration": { + "cpu": 1, + "network": "none", + "viewport": "1365x768", + "visit": "reload after initial navigation; service worker active" + }, + "note": "Single production trace, not measurements of the local patch. Dynamic feed content differs across visits.", + "metrics": { + "lcp_ms": 786, + "ttfb_ms": 708, + "render_delay_ms": 78, + "cls": 0.067, + "lcp_element": "text" + } + }, + { + "label": "mobile-cold", + "url": "https://dev-null.sh/", + "date": "2026-10-27", + "configuration": { + "cpu": 4, + "network": "Slow 4G", + "viewport": "390x844 @3x", + "visit": "new isolated browser; recording started on about:blank before navigation" + }, + "note": "Single production trace, not measurements of the local patch. Dynamic feed content differs across visits.", + "metrics": { + "lcp_ms": 7524, + "ttfb_ms": 564, + "resource_load_delay_ms": 2859, + "resource_load_duration_ms": 2007, + "render_delay_ms": 2094, + "cls": 0, + "lcp_element": "first feed card photo", + "image_initial_priority": "Low", + "image_final_priority": "High" + } + } +] diff --git a/scripts/performance/timestamps.ts b/scripts/performance/timestamps.ts new file mode 100644 index 000000000..4b2a3cd15 --- /dev/null +++ b/scripts/performance/timestamps.ts @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import { performance } from 'node:perf_hooks'; +import { pathToFileURL } from 'node:url'; +import * as current from '../../resources/js/lib/utils.ts'; + +// Optional original module, saved inside node_modules so its package imports resolve. +const baselinePath = process.argv[2]; +if (!baselinePath) { + throw new Error( + 'Pass the path to the original compiled utils module; see PERFORMANCE.md.', + ); +} +const baseline = (await import( + pathToFileURL(baselinePath).href +)) as typeof current; +const now = new Date('2026-10-27T12:00:00Z'); +const dates = Array.from({ length: 100 }, (_, i) => + new Date(now.getTime() - i * 86_400_000).toISOString(), +); +const cases = { + 'feed (100 cards)': (lib: typeof current) => + dates.map((date) => [ + lib.formatRelativeTime(date, now), + lib.formatFullDateTime(date), + ]), + 'chat (100 messages)': (lib: typeof current) => + dates.map((date) => lib.formatClockTime(date)), + 'profile clocks (100)': (lib: typeof current) => + dates.map((date) => [ + lib.formatZonedTime(date, 'Europe/Lisbon'), + lib.formatUtcOffset(new Date(date), 'Europe/Lisbon'), + ]), +}; + +for (const lib of [baseline, current]) { + lib.setReaderClock({ timeZone: 'America/New_York', hourCycle: 'h12' }); +} + +const rows = []; +for (const [name, run] of Object.entries(cases)) { + assert.deepEqual(run(current), run(baseline), `${name}: output changed`); + for (let i = 0; i < 10; i++) { + run(baseline); + run(current); + } + const samples = { before: [] as number[], after: [] as number[] }; + for (let i = 0; i < 31; i++) { + // Alternate order to reduce order/thermal bias. Each sample is one 100-item batch. + for (const key of i % 2 + ? (['after', 'before'] as const) + : (['before', 'after'] as const)) { + const start = performance.now(); + run(key === 'before' ? baseline : current); + samples[key].push(performance.now() - start); + } + } + const median = (values: number[]) => values.sort((a, b) => a - b)[15]!; + const before = median(samples.before), + after = median(samples.after); + rows.push({ + workload: name, + before_ms: +before.toFixed(3), + after_ms: +after.toFixed(3), + speedup: +(before / after).toFixed(2), + reduction_percent: +((1 - after / before) * 100).toFixed(1), + }); +} +console.log( + JSON.stringify( + { + runtime: process.version, + platform: process.platform, + arch: process.arch, + samples: 31, + results: rows, + }, + null, + 2, + ), +); +baseline.setReaderClock(null); +current.setReaderClock(null); diff --git a/scripts/performance/tsconfig.json b/scripts/performance/tsconfig.json new file mode 100644 index 000000000..430cc7f8c --- /dev/null +++ b/scripts/performance/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tests/tsconfig.json", + "include": ["./*.ts", "../../resources/js/types/*.d.ts"] +} diff --git a/scripts/performance/vite.config.ts b/scripts/performance/vite.config.ts new file mode 100644 index 000000000..2a6b9e0b9 --- /dev/null +++ b/scripts/performance/vite.config.ts @@ -0,0 +1,19 @@ +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vite-plus'; + +export default defineConfig({ + resolve: { + alias: { + '@': fileURLToPath(new URL('../../resources/js', import.meta.url)), + }, + }, + test: { + env: { VITE_APP_NAME: '/dev/null' }, + include: [ + 'tests/Unit/LcpImages.test.tsx', + 'tests/Unit/UseNow.test.tsx', + 'tests/Unit/EmojiSearch.test.tsx', + 'tests/Unit/EmojiShortcodes.test.tsx', + ], + }, +}); diff --git a/tests/Unit/CertificateHashes.test.ts b/tests/Unit/CertificateHashes.test.ts index ff7314114..63e4b4c20 100644 --- a/tests/Unit/CertificateHashes.test.ts +++ b/tests/Unit/CertificateHashes.test.ts @@ -40,3 +40,19 @@ void test('preserves syntax highlighting, escaped code and HTML attributes', () `"${hash}" <script>`, ); }); + +void test('leaves fingerprints in attributes alone even when no text contains one', () => { + const html = `safe text`; + assert.equal(withCertificateHashes(html), html); +}); + +void test('preserves ordinary highlighted code and escaped angle brackets verbatim', () => { + for (const html of [ + '', + 'const x = 1;', + 'const x = <tag>', + 'x < y', + ]) { + assert.equal(withCertificateHashes(html), html); + } +}); diff --git a/tests/Unit/ChatScroll.test.ts b/tests/Unit/ChatScroll.test.ts index fe73a14ed..743ecebdb 100644 --- a/tests/Unit/ChatScroll.test.ts +++ b/tests/Unit/ChatScroll.test.ts @@ -5,17 +5,36 @@ import type { Message } from '../../resources/js/types/index.ts'; const originalCSS = globalThis.CSS; globalThis.CSS = { escape: (value: string) => value } as typeof CSS; -after(() => { globalThis.CSS = originalCSS; }); +after(() => { + globalThis.CSS = originalCSS; +}); -const messages = (count: number): Message[] => Array.from({ length: count }, (_, index) => ({ id: index + 1, client_id: `message-${index + 1}`, event: null }) as Message); +const messages = (count: number): Message[] => + Array.from( + { length: count }, + (_, index) => + ({ + id: index + 1, + client_id: `message-${index + 1}`, + event: null, + }) as Message, + ); -function scroller(tops: Record, bottom = 600, textTops = tops): HTMLElement { +function scroller( + tops: Record, + bottom = 600, + textTops = tops, +): HTMLElement { return { getBoundingClientRect: () => ({ bottom }), querySelector: (selector: string) => { const id = Number(selector.match(/\d+/)?.[0]); - const positions = selector.startsWith('[data-message-id=') ? textTops : tops; - return positions[id] === undefined ? null : { getBoundingClientRect: () => ({ top: positions[id] }) }; + const positions = selector.startsWith('[data-message-id=') + ? textTops + : tops; + return positions[id] === undefined + ? null + : { getBoundingClientRect: () => ({ top: positions[id] }) }; }, } as unknown as HTMLElement; } @@ -37,7 +56,11 @@ void test('does not offer a scroll shortcut for fewer than 25 messages or missin }); void test('counts messages rather than org events or gaps in server ids', () => { - const rows = messages(30).map((message) => ({ ...message, id: message.id * 10, client_id: `message-${message.id * 10}` })); + const rows = messages(30).map((message) => ({ + ...message, + id: message.id * 10, + client_id: `message-${message.id * 10}`, + })); rows.push({ id: 999, event: { type: 'joined' } } as unknown as Message); assert.equal(isBehindLatest(scroller({ 60: 601, 70: 599 }), rows), true); @@ -45,7 +68,10 @@ void test('counts messages rather than org events or gaps in server ids', () => void test('uses the actual viewport edge and bubble height instead of a fixed pixel distance', () => { assert.equal(isBehindLatest(scroller({ 6: 430 }, 420), messages(30)), true); - assert.equal(isBehindLatest(scroller({ 6: 430 }, 650), messages(30)), false); + assert.equal( + isBehindLatest(scroller({ 6: 430 }, 650), messages(30)), + false, + ); }); void test('offers the present when an image-only message is the newest-25 boundary', () => { @@ -57,5 +83,20 @@ void test('offers the present when an image-only message is the newest-25 bounda }); void test('keeps the shortcut hidden while a boundary image is visible above its text caption', () => { - assert.equal(isBehindLatest(scroller({ 6: 599 }, 600, { 6: 650 }), messages(30)), false); + assert.equal( + isBehindLatest(scroller({ 6: 599 }, 600, { 6: 650 }), messages(30)), + false, + ); +}); + +void test('does not inspect old history once the newest 25 messages are found', () => { + const rows = messages(10_000); + for (const row of rows.slice(0, -25)) { + Object.defineProperty(row, 'event', { + get: () => { + throw new Error('Scrolled through old history'); + }, + }); + } + assert.equal(isBehindLatest(scroller({ 9976: 601 }), rows), true); }); diff --git a/tests/Unit/GraphEdges.test.ts b/tests/Unit/GraphEdges.test.ts index 6c7c8810e..0be0cd99a 100644 --- a/tests/Unit/GraphEdges.test.ts +++ b/tests/Unit/GraphEdges.test.ts @@ -566,3 +566,22 @@ void test('keeps nothing of a line from another node, from where its node stood assert.equal(drawnStretch(toNext, [aside]), 0); assert.equal(drawnStretch(toNext, []), 0); }); + +void test('indexes unsorted siblings without changing equal-height input-order ties', () => { + const nodes = [ + node({ id: 'root', y: 0 }), + node({ id: 'last', y: 200, x: 96, parent: 'root' }), + node({ id: 'first-at-height', y: 100, x: 34, parent: 'root' }), + node({ id: 'second-at-height', y: 100, x: 96, parent: 'root' }), + ]; + const original = structuredClone(nodes); + const edges = graphEdges(nodes); + assert.deepEqual( + edges.map((edge) => edge.id), + ['branch:last', 'branch:first-at-height', 'branch:second-at-height'], + ); + assert.equal(edges[0].start.y, 98); + assert.equal(edges[1].start.y, 0); + assert.equal(edges[2].start.y, 0); + assert.deepEqual(nodes, original); +}); diff --git a/tests/Unit/LcpImages.test.tsx b/tests/Unit/LcpImages.test.tsx new file mode 100644 index 000000000..8c86fbbad --- /dev/null +++ b/tests/Unit/LcpImages.test.tsx @@ -0,0 +1,134 @@ +/// +import assert from 'node:assert/strict'; +import type { ReactNode } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { test, vi } from 'vite-plus/test'; +import { CommitFeed } from '../../resources/js/components/commit-feed'; +import { CommitImages } from '../../resources/js/components/commit-images'; +import type { Commit } from '../../resources/js/types/commit'; + +vi.mock('@/lib/commit-photo', () => ({ + commitPhotoUrl: () => '/photo', + openCommitPhoto: vi.fn(), +})); + +const images = [ + { + id: 'first', + url: 'https://example.com/first.webp', + description: 'First photo', + width: 1200, + height: 600, + }, + { + id: 'second', + url: 'https://example.com/second.webp', + description: 'Second photo', + width: 600, + height: 900, + }, +]; + +test('makes the first priority photo discoverable and visible in server HTML without JavaScript', () => { + const markup = renderToStaticMarkup( + , + ); + const tags = markup.match(/]*>/g)!; + assert.equal(tags.length, 2); + assert.match(tags[0], /loading="eager"/); + assert.match(tags[0], /fetchPriority="high"/i); + assert.doesNotMatch(tags[0], /opacity-0/); + assert.match(tags[0], /width="1200"/); + assert.match(tags[0], /height="600"/); + assert.match(tags[0], /alt="First photo"/); + assert.match(tags[1], /loading="lazy"/); + assert.doesNotMatch(tags[1], /fetchPriority="high"/i); + assert.equal( + (markup.match(/data-test="commit-image-loading"/g) ?? []).length, + 1, + ); +}); + +test('keeps ordinary photo grids lazy with their loading placeholders', () => { + const markup = renderToStaticMarkup(); + const tags = markup.match(/]*>/g)!; + assert.ok(tags.every((tag) => tag.includes('loading="lazy"'))); + assert.ok(tags.every((tag) => tag.includes('opacity-0'))); + assert.doesNotMatch(markup, /fetchPriority="high"/i); + assert.equal( + (markup.match(/data-test="commit-image-loading"/g) ?? []).length, + 2, + ); +}); + +const page = vi.hoisted(() => ({ + component: 'log/show', + props: { dimsLanes: false }, +})); +vi.mock('@inertiajs/react', () => ({ + usePage: () => page, + router: { on: vi.fn(() => () => {}) }, + InfiniteScroll: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), +})); +vi.mock('@/hooks/use-now', () => ({ + useNow: () => new Date('2026-10-27T12:00:00Z'), +})); +vi.mock('@/components/commit-card', () => ({ + CommitCard: ({ + commit, + prioritizeImage, + }: { + commit: Commit; + prioritizeImage?: boolean; + }) => ( +
+ ), +})); +vi.mock('@/components/graph-canvas', () => ({ + GraphCanvas: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), +})); +vi.mock('@/components/graph-rail', () => ({ + GraphRail: () => null, + RailSegment: () => null, +})); + +const card = (id: string) => + ({ id, created_at: '2026-10-27T10:00:00Z', user: { lane: 0 } }) as Commit; +function feedMarkup(pinnedCommit: Commit | null = null) { + return renderToStaticMarkup( + [0]['commits'] + } + empty={null} + end="End" + pinnedCommit={pinnedCommit} + />, + ); +} + +test('promotes one first log card, or the pinned card, without promoting the rest', () => { + page.component = 'log/show'; + let markup = feedMarkup(); + assert.match(markup, /data-card="first" data-priority="high"/); + assert.match(markup, /data-card="second" data-priority="lazy"/); + markup = feedMarkup(card('pinned')); + assert.match(markup, /data-card="pinned" data-priority="high"/); + assert.match(markup, /data-card="first" data-priority="lazy"/); + assert.equal((markup.match(/data-priority="high"/g) ?? []).length, 1); +}); + +test('keeps feeds below profile headers lazy', () => { + page.component = 'user/show'; + Object.assign(page.props, { user: { id: 'profile' }, tab: 'commits' }); + assert.doesNotMatch(feedMarkup(), /data-priority="high"/); +}); diff --git a/tests/Unit/ReaderClock.test.ts b/tests/Unit/ReaderClock.test.ts index 35423a09a..53f7959ba 100644 --- a/tests/Unit/ReaderClock.test.ts +++ b/tests/Unit/ReaderClock.test.ts @@ -12,6 +12,8 @@ import { formatFullDate, formatFullDateTime, formatRelativeTime, + formatSyncedAgo, + formatUtcOffset, formatZonedDate, formatZonedTime, localDayKey, @@ -102,3 +104,112 @@ void test("tells this machine's clock", () => { ); assert.match(clock.hourCycle, /^h(11|12|23|24)$/); }); + +void test('cached formats match native output across readers, DST changes and cache eviction', () => { + const dates = [ + '2025-12-31T23:30:00Z', + '2026-03-08T06:59:00Z', + '2026-03-08T07:01:00Z', + '2026-10-25T00:59:00Z', + '2026-10-25T01:01:00Z', + '2026-11-01T05:59:00Z', + '2026-11-01T06:01:00Z', + ]; + // More distinct keys than the cache holds, followed by the same zones again. + const zones = [ + 'UTC', + ...Intl.supportedValuesOf('timeZone').slice(0, 140), + 'America/New_York', + 'Europe/Lisbon', + 'Asia/Kathmandu', + 'UTC', + ]; + + for (const timeZone of zones) { + for (const hourCycle of ['h11', 'h12', 'h23', 'h24'] as const) { + setReaderClock({ timeZone, hourCycle }); + for (const date of dates) { + const value = new Date(date); + const time = value.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + timeZone, + hourCycle, + }); + const day = value.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + timeZone, + }); + assert.equal(formatClockTime(date), time); + assert.equal(formatFullDate(date), day); + assert.equal(formatFullDateTime(date), `${time} · ${day}`); + assert.equal( + formatClockDateTime(date), + value.toLocaleString('en-US', { + dateStyle: 'medium', + timeStyle: 'short', + timeZone, + hourCycle, + }), + ); + assert.equal( + formatClockMonth(date), + value.toLocaleDateString('en-US', { + month: 'long', + year: 'numeric', + timeZone, + }), + ); + } + } + } +}); + +void test('preserves invalid-date labels for locale string helpers', () => { + setReaderClock({ timeZone: 'UTC', hourCycle: 'h23' }); + assert.equal(formatClockTime('invalid'), 'Invalid Date'); + assert.equal(formatFullDate('invalid'), 'Invalid Date'); + assert.equal(formatClockDateTime('invalid'), 'Invalid Date'); + assert.equal(formatClockMonth('invalid'), 'Invalid Date'); + assert.equal(formatFullDateTime('invalid'), 'Invalid Date · Invalid Date'); +}); + +void test('reuses offset formats without freezing daylight-saving offsets', () => { + assert.equal( + formatUtcOffset(new Date('2026-03-08T06:59:00Z'), 'America/New_York'), + 'UTC -05:00', + ); + assert.equal( + formatUtcOffset(new Date('2026-03-08T07:01:00Z'), 'America/New_York'), + 'UTC -04:00', + ); + assert.equal( + formatUtcOffset(new Date('2026-03-08T07:01:00Z'), 'UTC'), + 'UTC', + ); + assert.equal( + formatUtcOffset(new Date('2026-03-08T07:01:00Z'), 'Asia/Kathmandu'), + 'UTC +05:45', + ); +}); + +void test('keeps synced-ago labels at minute, hour and day boundaries', () => { + const now = new Date('2026-10-27T12:00:00Z'); + for (const [seconds, expected] of [ + [59, 'just now'], + [60, '1 minute ago'], + [3600, '1 hour ago'], + [86400, 'yesterday'], + [172800, '2 days ago'], + ] as const) { + assert.equal( + formatSyncedAgo( + new Date(now.getTime() - seconds * 1000).toISOString(), + now, + ), + expected, + ); + } +}); diff --git a/tests/Unit/UseNow.test.tsx b/tests/Unit/UseNow.test.tsx index 57e5008d5..0d6f0f559 100644 --- a/tests/Unit/UseNow.test.tsx +++ b/tests/Unit/UseNow.test.tsx @@ -1,7 +1,7 @@ /// import assert from 'node:assert/strict'; import * as React from 'react'; -import { afterAll, test, vi } from 'vite-plus/test'; +import { afterAll, afterEach, test, vi } from 'vite-plus/test'; type Subscribe = (listener: () => void) => () => void; @@ -23,6 +23,12 @@ vi.doMock('react', () => ({ }, })); +afterEach(() => { + while (unsubscribes.length) unsubscribes.pop()?.(); + listener.mockClear(); + vi.unstubAllGlobals(); +}); + afterAll(() => { vi.useRealTimers(); vi.doUnmock('react'); @@ -57,3 +63,43 @@ test('counts from the moment the server rendered the page, on the server and whi assert.equal(getServerSnapshot?.(), renderedAt); }); + +test('does no timer work while hidden and refreshes immediately when visible', async () => { + vi.useFakeTimers(); + const document = Object.assign(new EventTarget(), { hidden: false }); + vi.stubGlobal('document', document); + const start = new Date('2026-10-27T12:00:00Z').getTime(); + vi.setSystemTime(start); + const { useNow } = await import('../../resources/js/hooks/use-now'); + useNow(); + assert.equal(vi.getTimerCount(), 1); + + document.hidden = true; + document.dispatchEvent(new Event('visibilitychange')); + assert.equal(vi.getTimerCount(), 0); + vi.advanceTimersByTime(60 * 60_000); + assert.equal(listener.mock.calls.length, 0); + + document.hidden = false; + document.dispatchEvent(new Event('visibilitychange')); + assert.equal(listener.mock.calls.length, 1); + assert.equal(useNow().getTime(), start + 60 * 60_000); + assert.equal(vi.getTimerCount(), 1); + vi.advanceTimersByTime(30_000); + assert.equal(listener.mock.calls.length, 2); +}); + +test('subscribing in a hidden tab creates no timer and cleans up on the last unsubscribe', async () => { + vi.useFakeTimers(); + const document = Object.assign(new EventTarget(), { hidden: true }); + vi.stubGlobal('document', document); + const { useNow } = await import('../../resources/js/hooks/use-now'); + useNow(); + useNow(); + assert.equal(vi.getTimerCount(), 0); + while (unsubscribes.length) unsubscribes.pop()?.(); + document.hidden = false; + document.dispatchEvent(new Event('visibilitychange')); + assert.equal(listener.mock.calls.length, 0); + assert.equal(vi.getTimerCount(), 0); +});