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 ( <> -
+
+

Loading report…

+
{CLASSIC_WIDGETS.map(widget => (