diff --git a/AGENTS.md b/AGENTS.md
index 250a76d..26859bd 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -134,7 +134,9 @@ public/ # static files copied verbatim into out/:
repositories.txt # synced from the repo root (sync-repositories.mjs)
assets/ # mustache template, logo
templates/ # workflow-sample-*.html fragments
- widgets/ # module bridges: vizdom WASM, three-spritetext,
+ widgets/ # module bridges: vizdom WASM, force-graph
+ # (3D graph + sprite labels sharing one
+ # three build),
# sentry (runtime CDN imports cannot be bundled)
templates/ # user CI samples (user-refactorfirst-*.yml) for the docs
ci/process-submissions.sh # shared submission validator for GH/Gl/BB CI
@@ -256,14 +258,30 @@ coverage there when introducing new markup patterns.
lib/renderer.js applies **filter → sort → paginate** per table and injects
`tableUi` blocks the mustache template renders; `enhanceTables` in
lib/table-enhancer.js binds the controls and reports state changes back to
- components/report-view.jsx, which re-renders (widgets only gate the first
- render; the search input's focus/caret is restored after each re-render).
- The expensive `enhanceReport` pipeline (Chart.js charts, WASM DOT layout)
- runs only when the payload changes — table-state re-renders stash the live
- chart canvases / graph containers before the innerHTML swap and graft them
- back into the fresh DOM (`statefulElementIds`/`stashStatefulDom`/
- `graftStatefulDom` in lib/report-view.js), re-binding only the cheap popup
- handlers.
+ components/report-view.jsx, which re-renders (widgets never gate the
+ render — charts/graphs enhance progressively via `onWidgetReady`
+ callbacks when their widget reports ready; the search input's focus/caret
+ is restored after each re-render). The expensive `enhanceReport` pipeline
+ (Chart.js charts, WASM DOT layout) runs only when the payload changes —
+ it receives `{charts, graphs}` readiness flags so still-loading widgets
+ are skipped and picked up by the widget-readiness effect instead — and
+ table-state re-renders stash the live chart canvases / graph containers
+ before the innerHTML swap and graft them back into the fresh DOM
+ (`statefulElementIds`/`stashStatefulDom`/`graftStatefulDom` in
+ lib/report-view.js), re-binding only the cheap popup handlers.
+- Initial render performance (LCP/CLS): the report markup renders as soon
+ as the parallel template + report-JSON fetches resolve, never waiting on
+ the CDN widgets or the ~4.8MB vizdom wasm. The report page shells
+ (`[username]/[repository]/page.jsx` + `[branch]/page.jsx`) preconnect to
+ raw.githubusercontent.com (the report JSON host; CORS fetch) and
+ cdnjs.cloudflare.com (classic widget scripts) and render the loading
+ placeholder inside `#report-container` — the same id the hydrated
+ ReportView renders — with a full-viewport `min-height: 100svh` (vh
+ fallback) in app/globals.css, so the footer starts below the fold and
+ 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.
- Search ``s are injected by table-enhancer — `` is FORBID in
the renderer's sanitization allow-list, so it must never appear in the
mustache template.
diff --git a/README.md b/README.md
index b7839e7..386cf42 100644
--- a/README.md
+++ b/README.md
@@ -56,7 +56,7 @@ lib/ # Shared logic consumed by client components and
public/ # Published verbatim: repositories.txt, assets/
# (mustache template, logo), templates/
# (workflow samples), widgets/ (WASM + ESM
- # bridges for vizdom, three-spritetext, Sentry)
+ # bridges for vizdom, force-graph, Sentry)
repositories.txt # Listed repositories, one "user/repo" per line
ci/process-submissions.sh # Shared submission validator used by GitHub
# Actions, GitLab CI and Bitbucket Pipelines
@@ -436,7 +436,7 @@ in
| URL routes | `lib/routes.js` (+ `tests/unit/routes.test.js`) |
| Raw fetching / branch fallback (platform-aware) | `lib/fetcher.js` |
| Mustache rendering | `lib/renderer.js`, `public/assets/refactor-first-report.mustache` (port of the RefactorFirst viewer template) |
-| Interactive report widgets | `lib/report-view.js` + CDN widgets loaded via `next/script` in `components/report-view.jsx` (Chart.js, sigma/graphology, graphlib-dot, svg-pan-zoom, 3d-force-graph) and the ES-module bridges in `public/widgets/` (vizdom WASM, three-spritetext) |
+| Interactive report widgets | `lib/report-view.js` + CDN widgets loaded via `next/script` in `components/report-view.jsx` (Chart.js, sigma/graphology, graphlib-dot, svg-pan-zoom) and the ES-module bridges in `public/widgets/` (vizdom WASM; force-graph: 3D graph + sprite labels sharing one pinned Three.js build) |
| Search / type-ahead | `lib/search.js` + `components/{search-combobox,hero-search,menu-search}.jsx` |
| Submission flow | `lib/repo-submission.js`, `components/repo-submission-form.jsx` |
| Submission validation (CI) | `ci/process-submissions.sh`, `.github/workflows/add-repository.yml`, `.gitlab-ci.yml`, `bitbucket-pipelines.yml` |
diff --git a/app/[username]/[repository]/[branch]/page.jsx b/app/[username]/[repository]/[branch]/page.jsx
index 5253a70..c18a759 100644
--- a/app/[username]/[repository]/[branch]/page.jsx
+++ b/app/[username]/[repository]/[branch]/page.jsx
@@ -27,8 +27,26 @@ export default async function BranchPage({ params }) {
notFound();
}
return (
- Loading report…
}>
-
-
+ <>
+ {/* Warm the connections the client-side report fetch and the lazy CDN
+ widgets will use: raw.githubusercontent.com serves the report JSON
+ (a CORS fetch, so the preconnect is anonymous too) and cdnjs the
+ 2D-popup scripts (classic scripts, no CORS). Self-managed
+ GitLab/Bitbucket deployments fetch from their own platform host
+ instead, where these hints are simply unused. React hoists both
+ links into . */}
+
+
+ {/* The fallback mirrors the hydrated container (same id + reserved
+ height, app/globals.css) so the footer starts below the fold before
+ hydration too and the report's arrival causes no layout shift. */}
+
+
Loading report…
+
+ )}>
+
+
+ >
);
}
diff --git a/app/[username]/[repository]/page.jsx b/app/[username]/[repository]/page.jsx
index 447ba73..41ae4c0 100644
--- a/app/[username]/[repository]/page.jsx
+++ b/app/[username]/[repository]/page.jsx
@@ -25,8 +25,26 @@ export default async function RepositoryPage({ params }) {
notFound();
}
return (
- Loading report…}>
-
-
+ <>
+ {/* Warm the connections the client-side report fetch and the lazy CDN
+ widgets will use: raw.githubusercontent.com serves the report JSON
+ (a CORS fetch, so the preconnect is anonymous too) and cdnjs the
+ 2D-popup scripts (classic scripts, no CORS). Self-managed
+ GitLab/Bitbucket deployments fetch from their own platform host
+ instead, where these hints are simply unused. React hoists both
+ links into . */}
+
+
+ {/* The fallback mirrors the hydrated container (same id + reserved
+ height, app/globals.css) so the footer starts below the fold before
+ hydration too and the report's arrival causes no layout shift. */}
+
+
Loading report…
+
+ )}>
+
+
+ >
);
}
diff --git a/app/globals.css b/app/globals.css
index 8118cbc..90dd8b9 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -301,6 +301,21 @@ main section {
padding: 3rem 0;
}
+/* Report pages fetch and render their report client-side; both the
+ prerendered loading fallback and the hydrated container carry
+ #report-container, so reserving a full viewport here keeps the footer
+ below the fold from the very first paint — whatever the header's
+ height. The report's late arrival then grows the page instead of
+ shoving the visible footer down the screen (layout shift / CLS).
+ Rendered reports are always far taller than this, so the reservation
+ adds no space once content lands. svh is the guaranteed-visible
+ viewport height on mobile (dynamic browser chrome); vh is the
+ fallback for engines without viewport units. */
+#report-container {
+ min-height: 100vh;
+ min-height: 100svh;
+}
+
.site-footer {
text-align: center;
padding: 1.5rem 1rem;
diff --git a/components/report-view.jsx b/components/report-view.jsx
index 07c1b5b..ddc0ed8 100644
--- a/components/report-view.jsx
+++ b/components/report-view.jsx
@@ -1,12 +1,20 @@
'use client';
// Report page client component. Fetches the bundled Mustache template and
-// the repository's refactor-first.json once, then re-renders through
-// prepareReportData whenever per-table UI state (page/sort/search) changes.
-// lib/table-enhancer.js binds controls in the rendered DOM and reports table
-// actions back here; copy feedback surfaces through the toast region.
-// Widget scripts are loaded via next/script and report readiness through
-// lib/widget-loader.js.
+// the repository's refactor-first.json once (in parallel), then re-renders
+// through prepareReportData whenever per-table UI state (page/sort/search)
+// changes. lib/table-enhancer.js binds controls in the rendered DOM and
+// reports table actions back here; copy feedback surfaces through the toast
+// region. Widget scripts load via next/script and report readiness through
+// lib/widget-loader.js — the report markup renders as soon as the data
+// arrives and never waits on them; charts and WASM graphs enhance the page
+// when (and only when) their widget becomes ready.
+//
+// The initial loading placeholder lives inside #report-container in this
+// component's JSX and in the report page shells' Suspense fallback, and
+// app/globals.css reserves most of the viewport for the container, so the
+// report's late arrival grows the page below the fold instead of shoving
+// the footer (and CLS) down the screen.
import { useCallback, useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'next/navigation';
@@ -18,6 +26,8 @@ import { announceResolvedBranch } from '../lib/breadcrumbs.js';
import { renderTemplate, prepareReportData } from '../lib/renderer';
import {
enhanceReport,
+ initDisharmonyCharts,
+ initWasmGraphs,
bindPopupHandlers,
stashStatefulDom,
graftStatefulDom,
@@ -25,7 +35,7 @@ import {
} from '../lib/report-view';
import { enhanceTables } from '../lib/table-enhancer';
import { renderErrorPage, logError } from '../lib/error-handler';
-import { markWidgetReady, waitForWidget } from '../lib/widget-loader';
+import { isWidgetReady, markWidgetReady, onWidgetReady } from '../lib/widget-loader';
import ToastRegion, { useToastNotifications } from './toast-notification';
import { TABLE_CONFIG } from '../lib/table-operations';
@@ -34,17 +44,14 @@ const CLASSIC_WIDGETS = [
{ name: 'svg-pan-zoom', src: 'https://cdn.jsdelivr.net/npm/svg-pan-zoom@3.6.1/dist/svg-pan-zoom.min.js' },
{ name: 'sigma', src: 'https://cdnjs.cloudflare.com/ajax/libs/sigma.js/2.4.0/sigma.min.js' },
{ name: 'graphology', src: 'https://cdnjs.cloudflare.com/ajax/libs/graphology/0.25.4/graphology.umd.min.js' },
- { name: 'graphlib-dot', src: 'https://cdn.jsdelivr.net/npm/graphlib-dot@0.6.4/dist/graphlib-dot.min.js' },
- { name: '3d-force-graph', src: 'https://cdn.jsdelivr.net/npm/3d-force-graph' }
+ { name: 'graphlib-dot', src: 'https://cdn.jsdelivr.net/npm/graphlib-dot@0.6.4/dist/graphlib-dot.min.js' }
];
// Module bridges (CSP 'script-src self' — vendored files) report readiness
-// themselves via window.__rfMarkWidgetReady.
-const BRIDGE_WIDGETS = ['/widgets/vizdom-bridge.js', '/widgets/three-spritetext-bridge.js'];
-
-// Widgets that enhanceReport consumes synchronously; all others degrade
-// gracefully at popup-open time.
-const ENHANCE_WIDGETS = ['chart', 'vizdom'];
+// themselves via window.__rfMarkWidgetReady. The force-graph bridge loads
+// the 3D graph and its sprite labels as ES modules pinned to one shared
+// three build (the UMD used to ship a second, bundled three).
+const BRIDGE_WIDGETS = ['/widgets/vizdom-bridge.js', '/widgets/force-graph-bridge.js'];
/**
* Fetches and renders a repository report with interactive table controls.
@@ -58,7 +65,6 @@ export default function ReportView({
branch: branchProp,
environment: environmentProp,
platformBaseUrl: platformBaseUrlProp,
- widgetSettleMs = 5000,
toastDurationMs = TABLE_CONFIG.copy.toastDuration,
// Test seams (bun's mock.module leaks across test files, so lifecycle
// tests inject Deferred/spying implementations instead of module mocks).
@@ -70,7 +76,6 @@ export default function ReportView({
const [attempt, setAttempt] = useState(0);
const [payload, setPayload] = useState(null);
const [tableStates, setTableStates] = useState({});
- const widgetsSettledRef = useRef(false);
const pendingFocusRef = useRef(null);
// The last payload that went through the full (expensive) enhanceReport
// pipeline; table-state-only re-renders skip it and graft the live chart
@@ -97,15 +102,16 @@ export default function ReportView({
}, [showToast]);
// Fetch the template + report JSON once per report (no refetch on table
- // interactions).
+ // interactions). Both requests start together — the cross-origin report
+ // JSON is the slow one and must not queue behind the same-origin template.
useEffect(() => {
const controller = new AbortController();
const container = containerRef.current;
// Every fetch cycle replaces the report markup — loading placeholder,
// then the fresh render — so release the previous cycle's Chart.js
- // instances before any of that happens; enhanceReport builds new charts
- // for the new canvases once the payload arrives.
+ // instances before any of that happens; the chart widgets rebuild them
+ // for the new canvases once the payload renders.
destroyBubbleCharts();
/** Fetches the trusted template and the selected repository report. */
@@ -115,31 +121,35 @@ export default function ReportView({
try {
// The bundled template is authoritative — repository-provided
// templates are untrusted and intentionally never fetched.
- const templateResponse = await fetch(
+ const templatePromise = fetch(
withBasePath('/assets/refactor-first-report.mustache'),
{ signal: controller.signal }
- );
- if (!templateResponse.ok) {
- const error = new Error('Report template could not be loaded');
- error.status = 500;
- throw error;
- }
- const template = await templateResponse.text();
+ ).then(response => {
+ if (!response.ok) {
+ const error = new Error('Report template could not be loaded');
+ error.status = 500;
+ throw error;
+ }
+ return response.text();
+ });
const environment = environmentProp ?? detectHostingEnvironment(window.location.hostname);
const platformBaseUrl = platformBaseUrlProp ?? getPlatformBaseUrl(readMetaTag);
- const { data, branch: resolvedBranch } = await fetchReport(username, repository, branch, {
- environment,
- baseUrl: platformBaseUrl,
- signal: controller.signal
- });
+ const [template, report] = await Promise.all([
+ templatePromise,
+ fetchReport(username, repository, branch, {
+ environment,
+ baseUrl: platformBaseUrl,
+ signal: controller.signal
+ })
+ ]);
if (controller.signal.aborted) return;
// Tell the breadcrumb trail which branch actually loaded (a main
// 404 falls back to master) so its label matches the report shown.
- announceResolvedBranch(window, { username, repository, branch: resolvedBranch });
+ announceResolvedBranch(window, { username, repository, branch: report.branch });
setTableStates({});
- setPayload({ template, data, resolvedBranch });
+ setPayload({ template, data: report.data, resolvedBranch: report.branch });
} catch (error) {
if (controller.signal.aborted || error.name === 'AbortError') return;
logError(error, { route: 'report', username, repository, branch });
@@ -158,7 +168,9 @@ export default function ReportView({
}, [username, repository, branch, environmentProp, platformBaseUrlProp, attempt]);
// Render effect: re-renders the report whenever the payload arrives or the
- // per-table UI state changes. Widgets only gate the first render. The
+ // per-table UI state changes. The markup never waits on widgets: ones that
+ // are ready enhance synchronously inside enhanceReport, and ones that are
+ // still loading enhance through the widget-readiness effect below. The
// enhanceReport pipeline (Chart.js charts, WASM DOT layout) runs once per
// payload; table-state re-renders carry the live stateful nodes over to
// the fresh DOM and only re-bind the cheap popup/table handlers.
@@ -166,26 +178,10 @@ export default function ReportView({
if (!payload) return undefined;
let cancelled = false;
const container = containerRef.current;
- let widgetSettleTimer;
/** Renders the current table state and restores stateful report widgets. */
async function run() {
try {
- if (!widgetsSettledRef.current) {
- // Give the widgets that enhanceReport uses synchronously a moment
- // to arrive; everything else degrades gracefully when missing.
- await Promise.race([
- Promise.all(ENHANCE_WIDGETS.map(name => waitForWidget(name, { timeoutMs: widgetSettleMs }))),
- new Promise(resolve => {
- widgetSettleTimer = setTimeout(resolve, widgetSettleMs);
- })
- ]);
- // The race is settled — the fallback no longer needs to fire, so
- // release its handle (and the closure it retains) early.
- clearTimeout(widgetSettleTimer);
- widgetSettleTimer = undefined;
- widgetsSettledRef.current = true;
- }
if (cancelled) return;
const payloadChanged = lastEnhancedPayloadRef.current !== payload;
@@ -201,7 +197,13 @@ export default function ReportView({
container.dataset.resolvedBranch = payload.resolvedBranch;
if (payloadChanged) {
- await enhanceReportImpl(container, payload.data);
+ // Widgets that are already ready enhance now; the still-loading
+ // ones are deferred (and skipped here) so a slow CDN script or the
+ // multi-megabyte vizdom wasm can never hold back the render.
+ await enhanceReportImpl(container, payload.data, {
+ charts: isWidgetReady('chart'),
+ graphs: isWidgetReady('vizdom')
+ });
if (cancelled) return;
lastEnhancedPayloadRef.current = payload;
} else {
@@ -242,16 +244,58 @@ export default function ReportView({
run();
return () => {
cancelled = true;
- // Cancel a still-pending settle fallback so its timer cannot fire after
- // the effect is torn down. (No-op once the race has settled.)
- clearTimeout(widgetSettleTimer);
};
- }, [payload, tableStates, widgetSettleMs, username, repository, branch,
+ }, [payload, tableStates, username, repository, branch,
handleTableAction, handleCopy, enhanceReportImpl, enhanceTablesImpl]);
+ // Widget-readiness effect: keys on the payload only (not the table UI
+ // state), so a registration survives table re-renders and is retired
+ // exactly when the payload changes or the component unmounts. Widgets
+ // already covered by the synchronous enhanceReport call above are not
+ // registered again.
+ useEffect(() => {
+ if (!payload) return undefined;
+ let cancelled = false;
+ const unsubscribers = [];
+
+ /**
+ * Runs the enhancement once the named widget reports ready.
+ *
+ * @param {string} name - Widget name in the readiness registry.
+ * @param {Function} enhance - Enhancement bound to the current payload.
+ * @returns {void}
+ */
+ const whenReady = (name, enhance) => {
+ if (isWidgetReady(name)) return;
+ unsubscribers.push(onWidgetReady(name, () => {
+ if (cancelled) return;
+ try {
+ enhance();
+ } catch (error) {
+ console.warn(`report ${name} enhancement failed:`, error); // eslint-disable-line no-console
+ }
+ }));
+ };
+
+ whenReady('chart', () => initDisharmonyCharts(
+ payload.data.disharmonies,
+ payload.data.project && payload.data.project.repoUrl
+ ));
+ whenReady('vizdom', () => {
+ initWasmGraphs(payload.data);
+ });
+
+ return () => {
+ cancelled = true;
+ unsubscribers.forEach(unsubscribe => unsubscribe());
+ };
+ }, [payload, username, repository, branch]);
+
return (
<>
-
+