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
14 changes: 12 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,9 @@ components/ # client components: report-view, repo-list,
# hero-search, menu-search, menu-toggle, breadcrumbs
# (route trail under the menu), workflow-sample,
# platform-config, sentry-provider,
# toast-notification (copy feedback live region)
# toast-notification (copy feedback live region),
# report-warmup (idle-time rendering-library
# warming on directory pages)
lib/ # shared logic (client + RSC): routes, fetcher,
# renderer, search, utils, host, rate-limiter,
# cache-manager, error-handler, repo-submission,
Expand Down Expand Up @@ -281,7 +283,15 @@ coverage there when introducing new markup patterns.
the report's arrival causes no layout shift. The vizdom bridge
(`public/widgets/vizdom-bridge.js`) serves the wasm from Cache Storage
(versioned cache name) so repeat visits never re-download it regardless
of the host's HTTP cache headers.
of the host's HTTP cache headers. Directory pages (landing + user
listing) mount `components/report-warmup.jsx`, which — after window
load, at main-thread idle — warms the shared report rendering libraries
only (the vizdom bridge module/wasm, Chart.js + svg-pan-zoom no-cors
into the HTTP cache, the bundled template), never report data (listings
can hold thousands of repositories), so the first report a visitor opens
renders from local caches. The landing bundle budget e2e measures
through the window load event for this reason: post-load prefetches and
warm-up fetches never delay the landing's own first paint.
- Search `<input>`s are injected by table-enhancer — `<input>` is FORBID in
the renderer's sanitization allow-list, so it must never appear in the
mustache template.
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ app/ # Next.js App Router (static export): layout
components/ # Client components (search combobox, hero/menu
# search, menu toggle, workflow sample, user
# listing, report view, submission form,
# platform-config context, sentry provider)
# platform-config context, sentry provider,
# report warm-up for directory pages)
lib/ # Shared logic consumed by client components and
# RSC alike (routes, fetcher, renderer, search,
# repo-submission, error-handler, rate-limiter,
Expand Down
6 changes: 6 additions & 0 deletions app/[username]/page.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { userStaticParams } from '../../lib/static-params';
import { reposForUser } from '../../lib/utils';
import { isValidGitHubName } from '../../lib/routes';
import RepoList from '../../components/repo-list';
import ReportWarmup from '../../components/report-warmup';

export const dynamicParams = false;

Expand All @@ -30,6 +31,11 @@ export default async function UserPage({ params }) {
return (
<Suspense fallback={<p className="loading" role="status">Loading&hellip;</p>}>
<RepoList username={username} initialRepositories={repositories} />
{/* Directory page: warm the shared report rendering libraries during
idle time (vizdom wasm, Chart.js, svg-pan-zoom, the template —
never report data) so the first report a visitor opens renders
from local caches. */}
<ReportWarmup />
</Suspense>
);
}
6 changes: 6 additions & 0 deletions components/landing.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Link from 'next/link';
import HeroSearch from './hero-search';
import ReportWarmup from './report-warmup';
import { buildRepositoryListUrl } from '../lib/routes.js';

export const FEATURED_COUNT = 6;
Expand Down Expand Up @@ -30,6 +31,11 @@ export default function Landing({ repositories }) {
</ul>
</div>
</section>
{/* Directory page: warm the shared report rendering libraries during
idle time (vizdom wasm, Chart.js, svg-pan-zoom, the template —
never report data) so the first report a visitor opens renders
from local caches. Renders nothing; never delays this page. */}
<ReportWarmup />
</>
);
}
127 changes: 127 additions & 0 deletions components/report-warmup.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
'use client';

// Report rendering-library warm-up for directory pages (landing, user
// listing). Renders nothing; once the page has fully loaded AND the main
// thread is idle, it warms the shared rendering libraries the first report
// a visitor opens needs, so the report renders with them already in local
// caches:
//
// - vizdom wasm (~4.8MB): the shared bridge module is injected exactly the
// way the report page injects it, so the binary is fetched once and stored
// in the bridge's versioned Cache Storage entry — the report page's own
// bridge import then resolves it locally with zero network.
// - Chart.js + svg-pan-zoom: fetched no-cors into the HTTP cache; the
// report page's classic <script src> requests then hit the cache (the CDN
// serves both immutable), matching the later request mode exactly.
// - The bundled report template: a same-origin fetch into the HTTP cache.
//
// Report DATA is deliberately never warmed: listings can hold thousands of
// repositories and the one a visitor opens is unknowable in advance — only
// the shared rendering pipeline is warmed. Every step is fire-and-forget
// and fails silently (a cold cache is just the status quo), and nothing
// ever delays the hosting page's own rendering (load + idle scheduling).
// Popup-only widgets (sigma, graphology, graphlib-dot, the 3D force graph)
// also stay cold by design: they load lazily on the report page and are
// needed only on demand.

import { useEffect, useRef } from 'react';
import { withBasePath } from '../lib/base-path';
// The idle warm-up loads the vizdom bridge module on directory pages; this
// module installs the widget-readiness bridge the bridge reports through, so
// a mark fired while warming reaches the registry a later client-side-navigated
// report reads from (the module map keeps the already-evaluated bridge module
// from re-running on the report page).
import '../lib/widget-loader';

const WIDGET_SCRIPTS = [
'https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js',
'https://cdn.jsdelivr.net/npm/svg-pan-zoom@3.6.1/dist/svg-pan-zoom.min.js'
];

/** Fire-and-forget fetch: warming is best-effort, failures are fine. */
function warmFetch(url, options) {
fetch(url, options).catch(() => {});
}

/**
* Warms the report rendering libraries until a directory page visitor
* opens a report.
*
* @param {object} props - Warm-up configuration.
* @param {number} [props.fallbackDelayMs=1500] - Test seam: delay before the
* warm when requestIdleCallback is unavailable.
* @returns {null} Nothing is rendered.
*/
export default function ReportWarmup({ fallbackDelayMs = 1500 }) {
const warmedRef = useRef(false);

useEffect(() => {
// The warm runs once per mount (warmedRef); re-renders are no-ops.
if (warmedRef.current) return undefined;
let cancelled = false;
let cancelIdle = () => {};
let bridgeScript = null;

/** Runs each warm step exactly once, best-effort. */
const warm = () => {
if (cancelled || warmedRef.current) return;
warmedRef.current = true;

// 1. vizdom wasm through the shared bridge module: same file, same
// versioned Cache Storage entry as the report page uses.
const bridgeSrc = withBasePath('/widgets/vizdom-bridge.js');
const alreadyInjected = [...document.querySelectorAll('script[type="module"]')]
.some(script => (script.getAttribute('src') || script.src || '')
.endsWith('/widgets/vizdom-bridge.js'));
if (!alreadyInjected) {
const script = document.createElement('script');
script.type = 'module';
script.src = bridgeSrc;
document.body.appendChild(script);
bridgeScript = script;
}

// 2. Classic widget scripts into the HTTP cache (no-cors matches the
// report page's classic <script src> requests).
WIDGET_SCRIPTS.forEach(src => warmFetch(src, { mode: 'no-cors' }));

// 3. The bundled report template.
warmFetch(withBasePath('/assets/refactor-first-report.mustache'));
};

/**
* Schedules the warm at the first idle moment after the page loads. The
* idle callback is registered without a forced timeout, so the browser
* fires it strictly at an idle opportunity rather than on a timer while
* the page may still be busy; engines without requestIdleCallback wait
* out an idle window (the fallback delay) before the warm starts.
*/
const scheduleAtIdle = () => {
if (cancelled) return;
if (typeof window.requestIdleCallback === 'function') {
const handle = window.requestIdleCallback(warm);
cancelIdle = () => window.cancelIdleCallback?.(handle);
} else {
const timer = setTimeout(warm, fallbackDelayMs);
cancelIdle = () => clearTimeout(timer);
}
};

if (document.readyState === 'complete') {
scheduleAtIdle();
} else {
window.addEventListener('load', scheduleAtIdle, { once: true });
}
return () => {
cancelled = true;
window.removeEventListener('load', scheduleAtIdle);
cancelIdle();
// The bridge script is appended straight to <body> (outside React's
// knowledge); drop it again on unmount so a navigating-away page leaves
// no script behind.
if (bridgeScript) bridgeScript.remove();
};
}, [fallbackDelayMs]);

return null;
}
46 changes: 43 additions & 3 deletions lib/widget-loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,56 @@
const readyWidgets = new Set();
const pendingCallbacks = new Map(); // name -> callback[]

// Runtime globals a widget's bridge/library materializes once it is actually
// usable, keyed by normalized widget name. The vizdom bridge assigns
// window.Vizdom once the WASM finishes initializing and the CDN scripts assign
// their globals on evaluation; the bridges then notify this registry through
// window.__rfMarkWidgetReady. A mark can still be lost, though: an idle-time
// warm-up on a directory page (components/report-warmup.jsx) loads the vizdom
// bridge before any report page bundle exists, so the mark fires into a
// registry this page never sees, and a client-side navigation to a report
// reuses the already-materialized module (the browser module map does not
// re-run it). Readiness is therefore also decidable from the materialized
// global itself, so a widget whose runtime already exists is ready even
// without a mark landing in this page.
const WIDGET_RUNTIME_GLOBALS = {
chart: 'Chart',
vizdom: 'Vizdom',
'svg-pan-zoom': 'svgPanZoom',
sigma: 'Sigma',
graphology: 'graphology',
'graphlib-dot': 'graphlibDot',
'3d-force-graph': 'ForceGraph3D',
'three-spritetext': 'SpriteText'
};

/** Returns the runtime global name a widget materializes, or null if unknown. */
function widgetGlobalName(name) {
const key = String(name || '').trim().toLowerCase();
return Object.prototype.hasOwnProperty.call(WIDGET_RUNTIME_GLOBALS, key)
? WIDGET_RUNTIME_GLOBALS[key]
: null;
}

function installGlobalBridge() {
if (typeof window === 'undefined') return;
if (typeof window.__rfMarkWidgetReady !== 'function') {
window.__rfMarkWidgetReady = name => markWidgetReady(name);
}
}

// Install the browser-global bridge as soon as the registry module exists, so
// marks fired before any report page has mounted (an idle-time warm-up on a
// directory page) reach this registry — client-side navigations to report
// pages share this module instance.
installGlobalBridge();

// Register a callback fired once the named widget is ready. If the widget
// is already ready the callback runs synchronously. Returns an unsubscribe
// function that removes a still-pending callback.
export function onWidgetReady(name, callback) {
installGlobalBridge();
if (readyWidgets.has(name)) {
if (isWidgetReady(name)) {
callback();
return () => {};
}
Expand Down Expand Up @@ -47,14 +84,17 @@ export function markWidgetReady(name) {
}

export function isWidgetReady(name) {
return readyWidgets.has(name);
if (readyWidgets.has(name)) return true;
const globalName = widgetGlobalName(name);
if (globalName && typeof window !== 'undefined' && window[globalName]) return true;
return false;
}

// Wait until a widget is ready (or settles as failed). Returns a promise
// that resolves with `true` when ready and `false` on timeout.
export function waitForWidget(name, { timeoutMs = 5000 } = {}) {
installGlobalBridge();
if (readyWidgets.has(name)) return Promise.resolve(true);
if (isWidgetReady(name)) return Promise.resolve(true);
return new Promise(resolve => {
const timer = setTimeout(() => resolve(false), timeoutMs);
onWidgetReady(name, () => {
Expand Down
Loading
Loading