Skip to content

Commit 45577d0

Browse files
Preload libs (#28)
* Improving page load performance and applying coderabbit fixes * Fixed DOT graph load issue * Fixed DOT graph load issue and applied coderabbit fixes * Fixed DOT graph load issue and applied coderabbit fixes * applied coderabbit fixes * Clarify widget readiness test name and comment to match its direct bridge check * applied coderabbit fixes --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent fba0d3d commit 45577d0

11 files changed

Lines changed: 752 additions & 37 deletions

File tree

‎AGENTS.md‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,9 @@ components/ # client components: report-view, repo-list,
116116
# hero-search, menu-search, menu-toggle, breadcrumbs
117117
# (route trail under the menu), workflow-sample,
118118
# platform-config, sentry-provider,
119-
# toast-notification (copy feedback live region)
119+
# toast-notification (copy feedback live region),
120+
# report-warmup (idle-time rendering-library
121+
# warming on directory pages)
120122
lib/ # shared logic (client + RSC): routes, fetcher,
121123
# renderer, search, utils, host, rate-limiter,
122124
# cache-manager, error-handler, repo-submission,
@@ -281,7 +283,15 @@ coverage there when introducing new markup patterns.
281283
the report's arrival causes no layout shift. The vizdom bridge
282284
(`public/widgets/vizdom-bridge.js`) serves the wasm from Cache Storage
283285
(versioned cache name) so repeat visits never re-download it regardless
284-
of the host's HTTP cache headers.
286+
of the host's HTTP cache headers. Directory pages (landing + user
287+
listing) mount `components/report-warmup.jsx`, which — after window
288+
load, at main-thread idle — warms the shared report rendering libraries
289+
only (the vizdom bridge module/wasm, Chart.js + svg-pan-zoom no-cors
290+
into the HTTP cache, the bundled template), never report data (listings
291+
can hold thousands of repositories), so the first report a visitor opens
292+
renders from local caches. The landing bundle budget e2e measures
293+
through the window load event for this reason: post-load prefetches and
294+
warm-up fetches never delay the landing's own first paint.
285295
- Search `<input>`s are injected by table-enhancer — `<input>` is FORBID in
286296
the renderer's sanitization allow-list, so it must never appear in the
287297
mustache template.

‎README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ app/ # Next.js App Router (static export): layout
4747
components/ # Client components (search combobox, hero/menu
4848
# search, menu toggle, workflow sample, user
4949
# listing, report view, submission form,
50-
# platform-config context, sentry provider)
50+
# platform-config context, sentry provider,
51+
# report warm-up for directory pages)
5152
lib/ # Shared logic consumed by client components and
5253
# RSC alike (routes, fetcher, renderer, search,
5354
# repo-submission, error-handler, rate-limiter,

‎app/[username]/page.jsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { userStaticParams } from '../../lib/static-params';
99
import { reposForUser } from '../../lib/utils';
1010
import { isValidGitHubName } from '../../lib/routes';
1111
import RepoList from '../../components/repo-list';
12+
import ReportWarmup from '../../components/report-warmup';
1213

1314
export const dynamicParams = false;
1415

@@ -30,6 +31,11 @@ export default async function UserPage({ params }) {
3031
return (
3132
<Suspense fallback={<p className="loading" role="status">Loading&hellip;</p>}>
3233
<RepoList username={username} initialRepositories={repositories} />
34+
{/* Directory page: warm the shared report rendering libraries during
35+
idle time (vizdom wasm, Chart.js, svg-pan-zoom, the template —
36+
never report data) so the first report a visitor opens renders
37+
from local caches. */}
38+
<ReportWarmup />
3339
</Suspense>
3440
);
3541
}

‎components/landing.jsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import Link from 'next/link';
22
import HeroSearch from './hero-search';
3+
import ReportWarmup from './report-warmup';
34
import { buildRepositoryListUrl } from '../lib/routes.js';
45

56
export const FEATURED_COUNT = 6;
@@ -30,6 +31,11 @@ export default function Landing({ repositories }) {
3031
</ul>
3132
</div>
3233
</section>
34+
{/* Directory page: warm the shared report rendering libraries during
35+
idle time (vizdom wasm, Chart.js, svg-pan-zoom, the template —
36+
never report data) so the first report a visitor opens renders
37+
from local caches. Renders nothing; never delays this page. */}
38+
<ReportWarmup />
3339
</>
3440
);
3541
}

‎components/report-warmup.jsx‎

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
'use client';
2+
3+
// Report rendering-library warm-up for directory pages (landing, user
4+
// listing). Renders nothing; once the page has fully loaded AND the main
5+
// thread is idle, it warms the shared rendering libraries the first report
6+
// a visitor opens needs, so the report renders with them already in local
7+
// caches:
8+
//
9+
// - vizdom wasm (~4.8MB): the shared bridge module is injected exactly the
10+
// way the report page injects it, so the binary is fetched once and stored
11+
// in the bridge's versioned Cache Storage entry — the report page's own
12+
// bridge import then resolves it locally with zero network.
13+
// - Chart.js + svg-pan-zoom: fetched no-cors into the HTTP cache; the
14+
// report page's classic <script src> requests then hit the cache (the CDN
15+
// serves both immutable), matching the later request mode exactly.
16+
// - The bundled report template: a same-origin fetch into the HTTP cache.
17+
//
18+
// Report DATA is deliberately never warmed: listings can hold thousands of
19+
// repositories and the one a visitor opens is unknowable in advance — only
20+
// the shared rendering pipeline is warmed. Every step is fire-and-forget
21+
// and fails silently (a cold cache is just the status quo), and nothing
22+
// ever delays the hosting page's own rendering (load + idle scheduling).
23+
// Popup-only widgets (sigma, graphology, graphlib-dot, the 3D force graph)
24+
// also stay cold by design: they load lazily on the report page and are
25+
// needed only on demand.
26+
27+
import { useEffect, useRef } from 'react';
28+
import { withBasePath } from '../lib/base-path';
29+
// The idle warm-up loads the vizdom bridge module on directory pages; this
30+
// module installs the widget-readiness bridge the bridge reports through, so
31+
// a mark fired while warming reaches the registry a later client-side-navigated
32+
// report reads from (the module map keeps the already-evaluated bridge module
33+
// from re-running on the report page).
34+
import '../lib/widget-loader';
35+
36+
const WIDGET_SCRIPTS = [
37+
'https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js',
38+
'https://cdn.jsdelivr.net/npm/svg-pan-zoom@3.6.1/dist/svg-pan-zoom.min.js'
39+
];
40+
41+
/** Fire-and-forget fetch: warming is best-effort, failures are fine. */
42+
function warmFetch(url, options) {
43+
fetch(url, options).catch(() => {});
44+
}
45+
46+
/**
47+
* Warms the report rendering libraries until a directory page visitor
48+
* opens a report.
49+
*
50+
* @param {object} props - Warm-up configuration.
51+
* @param {number} [props.fallbackDelayMs=1500] - Test seam: delay before the
52+
* warm when requestIdleCallback is unavailable.
53+
* @returns {null} Nothing is rendered.
54+
*/
55+
export default function ReportWarmup({ fallbackDelayMs = 1500 }) {
56+
const warmedRef = useRef(false);
57+
58+
useEffect(() => {
59+
// The warm runs once per mount (warmedRef); re-renders are no-ops.
60+
if (warmedRef.current) return undefined;
61+
let cancelled = false;
62+
let cancelIdle = () => {};
63+
let bridgeScript = null;
64+
65+
/** Runs each warm step exactly once, best-effort. */
66+
const warm = () => {
67+
if (cancelled || warmedRef.current) return;
68+
warmedRef.current = true;
69+
70+
// 1. vizdom wasm through the shared bridge module: same file, same
71+
// versioned Cache Storage entry as the report page uses.
72+
const bridgeSrc = withBasePath('/widgets/vizdom-bridge.js');
73+
const alreadyInjected = [...document.querySelectorAll('script[type="module"]')]
74+
.some(script => (script.getAttribute('src') || script.src || '')
75+
.endsWith('/widgets/vizdom-bridge.js'));
76+
if (!alreadyInjected) {
77+
const script = document.createElement('script');
78+
script.type = 'module';
79+
script.src = bridgeSrc;
80+
document.body.appendChild(script);
81+
bridgeScript = script;
82+
}
83+
84+
// 2. Classic widget scripts into the HTTP cache (no-cors matches the
85+
// report page's classic <script src> requests).
86+
WIDGET_SCRIPTS.forEach(src => warmFetch(src, { mode: 'no-cors' }));
87+
88+
// 3. The bundled report template.
89+
warmFetch(withBasePath('/assets/refactor-first-report.mustache'));
90+
};
91+
92+
/**
93+
* Schedules the warm at the first idle moment after the page loads. The
94+
* idle callback is registered without a forced timeout, so the browser
95+
* fires it strictly at an idle opportunity rather than on a timer while
96+
* the page may still be busy; engines without requestIdleCallback wait
97+
* out an idle window (the fallback delay) before the warm starts.
98+
*/
99+
const scheduleAtIdle = () => {
100+
if (cancelled) return;
101+
if (typeof window.requestIdleCallback === 'function') {
102+
const handle = window.requestIdleCallback(warm);
103+
cancelIdle = () => window.cancelIdleCallback?.(handle);
104+
} else {
105+
const timer = setTimeout(warm, fallbackDelayMs);
106+
cancelIdle = () => clearTimeout(timer);
107+
}
108+
};
109+
110+
if (document.readyState === 'complete') {
111+
scheduleAtIdle();
112+
} else {
113+
window.addEventListener('load', scheduleAtIdle, { once: true });
114+
}
115+
return () => {
116+
cancelled = true;
117+
window.removeEventListener('load', scheduleAtIdle);
118+
cancelIdle();
119+
// The bridge script is appended straight to <body> (outside React's
120+
// knowledge); drop it again on unmount so a navigating-away page leaves
121+
// no script behind.
122+
if (bridgeScript) bridgeScript.remove();
123+
};
124+
}, [fallbackDelayMs]);
125+
126+
return null;
127+
}

‎lib/widget-loader.js‎

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,56 @@
77
const readyWidgets = new Set();
88
const pendingCallbacks = new Map(); // name -> callback[]
99

10+
// Runtime globals a widget's bridge/library materializes once it is actually
11+
// usable, keyed by normalized widget name. The vizdom bridge assigns
12+
// window.Vizdom once the WASM finishes initializing and the CDN scripts assign
13+
// their globals on evaluation; the bridges then notify this registry through
14+
// window.__rfMarkWidgetReady. A mark can still be lost, though: an idle-time
15+
// warm-up on a directory page (components/report-warmup.jsx) loads the vizdom
16+
// bridge before any report page bundle exists, so the mark fires into a
17+
// registry this page never sees, and a client-side navigation to a report
18+
// reuses the already-materialized module (the browser module map does not
19+
// re-run it). Readiness is therefore also decidable from the materialized
20+
// global itself, so a widget whose runtime already exists is ready even
21+
// without a mark landing in this page.
22+
const WIDGET_RUNTIME_GLOBALS = {
23+
chart: 'Chart',
24+
vizdom: 'Vizdom',
25+
'svg-pan-zoom': 'svgPanZoom',
26+
sigma: 'Sigma',
27+
graphology: 'graphology',
28+
'graphlib-dot': 'graphlibDot',
29+
'3d-force-graph': 'ForceGraph3D',
30+
'three-spritetext': 'SpriteText'
31+
};
32+
33+
/** Returns the runtime global name a widget materializes, or null if unknown. */
34+
function widgetGlobalName(name) {
35+
const key = String(name || '').trim().toLowerCase();
36+
return Object.prototype.hasOwnProperty.call(WIDGET_RUNTIME_GLOBALS, key)
37+
? WIDGET_RUNTIME_GLOBALS[key]
38+
: null;
39+
}
40+
1041
function installGlobalBridge() {
1142
if (typeof window === 'undefined') return;
1243
if (typeof window.__rfMarkWidgetReady !== 'function') {
1344
window.__rfMarkWidgetReady = name => markWidgetReady(name);
1445
}
1546
}
1647

48+
// Install the browser-global bridge as soon as the registry module exists, so
49+
// marks fired before any report page has mounted (an idle-time warm-up on a
50+
// directory page) reach this registry — client-side navigations to report
51+
// pages share this module instance.
52+
installGlobalBridge();
53+
1754
// Register a callback fired once the named widget is ready. If the widget
1855
// is already ready the callback runs synchronously. Returns an unsubscribe
1956
// function that removes a still-pending callback.
2057
export function onWidgetReady(name, callback) {
2158
installGlobalBridge();
22-
if (readyWidgets.has(name)) {
59+
if (isWidgetReady(name)) {
2360
callback();
2461
return () => {};
2562
}
@@ -47,14 +84,17 @@ export function markWidgetReady(name) {
4784
}
4885

4986
export function isWidgetReady(name) {
50-
return readyWidgets.has(name);
87+
if (readyWidgets.has(name)) return true;
88+
const globalName = widgetGlobalName(name);
89+
if (globalName && typeof window !== 'undefined' && window[globalName]) return true;
90+
return false;
5191
}
5292

5393
// Wait until a widget is ready (or settles as failed). Returns a promise
5494
// that resolves with `true` when ready and `false` on timeout.
5595
export function waitForWidget(name, { timeoutMs = 5000 } = {}) {
5696
installGlobalBridge();
57-
if (readyWidgets.has(name)) return Promise.resolve(true);
97+
if (isWidgetReady(name)) return Promise.resolve(true);
5898
return new Promise(resolve => {
5999
const timer = setTimeout(() => resolve(false), timeoutMs);
60100
onWidgetReady(name, () => {

0 commit comments

Comments
 (0)