From 1bed15a1889ad66719c64cba0e040db4ff4061fd Mon Sep 17 00:00:00 2001 From: Jim Bethancourt Date: Wed, 28 Oct 2026 05:59:05 -0500 Subject: [PATCH 1/7] Improving page load performance and applying coderabbit fixes --- AGENTS.md | 14 +- README.md | 3 +- app/[username]/page.jsx | 6 + components/landing.jsx | 6 + tests/e2e/performance.spec.js | 295 ++++++++++++++++++++++-- tests/integration/landing-page.test.jsx | 20 +- 6 files changed, 318 insertions(+), 26 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 26859bd..5d1c3a6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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, @@ -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 ``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 386cf42..f80a011 100644 --- a/README.md +++ b/README.md @@ -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, diff --git a/app/[username]/page.jsx b/app/[username]/page.jsx index 3d6615e..c5cc004 100644 --- a/app/[username]/page.jsx +++ b/app/[username]/page.jsx @@ -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; @@ -30,6 +31,11 @@ export default async function UserPage({ params }) { return ( Loading…

}> + {/* 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. */} +
); } diff --git a/components/landing.jsx b/components/landing.jsx index 59a16f8..c74f273 100644 --- a/components/landing.jsx +++ b/components/landing.jsx @@ -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; @@ -30,6 +31,11 @@ export default function Landing({ 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. Renders nothing; never delays this page. */} + ); } diff --git a/tests/e2e/performance.spec.js b/tests/e2e/performance.spec.js index 8a1b368..4533519 100644 --- a/tests/e2e/performance.spec.js +++ b/tests/e2e/performance.spec.js @@ -5,9 +5,20 @@ import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; // Performance / bundle-size guardrails (Technical Appendix §10): -// - landing page transfers <190KB of first-party code (js/css/html), -// measured gzip-compressed — production static hosts serve compressed -// bodies and the local test server does not. (The plan target was 150KB; +// - landing page ships <190KB of its own first-party code (js/css/html), +// measured gzip-compressed (production static hosts serve compressed +// bodies and the local test server does not). Landing assets are +// identified by document provenance, never by arrival time: everything +// the landing's HTML references (async chunks + CSS) gates load, so it +// is exactly the code the landing itself ships. Arrival time cannot +// draw that line — Next's automatic prefetch downloads the +// linked routes' chunks (first-party .js as well), and once hydration +// has run those responses can complete before the window load event; +// the delayed-logo test below reproduces that race and verifies the +// exclusion. The idle-time rendering-library warm-up +// (components/report-warmup.jsx) is excluded the same way: it fetches +// modules no landing document references. +// (The plan target was 150KB; // baselines are recorded as accepted deviations in // plans/nextjs-conversion.md §10: ~173KB measured at the Next.js // cutover, re-measured ~185KB after later framework bumps — ~83% of @@ -21,28 +32,132 @@ import { dirname, join } from 'node:path'; const FIRST_PARTY = url => url.startsWith('http://localhost') && /\.(js|css|html?)(\?|$)/.test(url); -test('landing page ships less than 190KB of first-party code (gzipped)', async ({ page }) => { - const sizePromises = []; - page.on('response', response => { - if (FIRST_PARTY(response.url())) { - sizePromises.push((async () => { - try { - const body = await response.body(); - return { url: response.url(), bytes: gzipSync(body).length }; - } catch { - return null; // interrupted navigation - } - })()); +/** + * Measures the landing's own gzipped payload. Landing assets are + * identified by document provenance, never by arrival time: the initial + * HTML references exactly the code the landing ships (async chunks + + * CSS), and all of it gates load. Arrival time cannot draw that line — + * Next's automatic prefetch downloads the linked routes' chunks + * (first-party .js as well), and once hydration has run those responses + * can complete before the window load event (the delayed-logo test + * below reproduces this and verifies the exclusion). The idle-time + * rendering-library warm-up (components/report-warmup.jsx) stays out + * the same way: it fetches modules no landing document references. + * + * Returns per-row data for every first-party response: + * - landing: { url, bytes } rows for the landing's own assets (the + * ones the initial HTML references), + * - total: their gzipped byte sum, + * - prefetchBeforeLoad: other routes' bytes that completed before the + * load event — exactly what an arrival-time listener miscounts into + * the budget. + * + * The optional onForeignFirstParty(url) callback fires once another + * route's first-party bytes have downloaded, letting a caller stall the + * load event until the miscount window is guaranteed open. + */ +async function measureLandingPayload(page, { onForeignFirstParty = null } = {}) { + const documentResponse = new Promise(resolve => { + page.on('response', response => { + if (response.request().resourceType() === 'document') resolve(response); + }); + }); + const landingPaths = documentResponse.then(async response => { + const html = await response.text(); + const paths = new Set(); + for (const match of html.matchAll(/]*)>/g)) { + const src = /\ssrc="([^"]+)"/.exec(match[1]); + if (src) paths.add(new URL(src[1], response.url()).pathname); } + for (const match of html.matchAll(/]*)>/g)) { + const rel = /\srel="([^"]+)"/.exec(match[1]); + const href = /\shref="([^"]+)"/.exec(match[1]); + if (href && rel && /(stylesheet|preload|modulepreload)/.test(rel[1])) { + paths.add(new URL(href[1], response.url()).pathname); + } + } + return paths; }); - await page.goto('/'); - await page.waitForLoadState('networkidle'); - const sizes = (await Promise.all(sizePromises)).filter(Boolean); - const total = sizes.reduce((sum, r) => sum + r.bytes, 0); + let loadFired = false; + // Firefox also fires a `load` event for the initial about:blank, which + // would mark every response after-load before the landing document even + // starts; arm the flag only once its own response is on the wire (its + // load event cannot precede that). + documentResponse.then(() => { + page.on('load', () => { loadFired = true; }); + }); + const rows = []; + page.on('response', response => { + const url = response.url(); + const beforeLoad = !loadFired; // what an arrival-time listener sees + if (!FIRST_PARTY(url)) return; + rows.push((async () => { + try { + const [paths, body] = await Promise.all([landingPaths, response.body()]); + const landing = paths.has(new URL(url).pathname); + // Notify (the delayed-load test below) once another route's + // first-party bytes have actually downloaded. + if (!landing && onForeignFirstParty) onForeignFirstParty(url); + return { url, bytes: gzipSync(body).length, landing, beforeLoad }; + } catch { + return null; // interrupted navigation + } + })()); + }); + await page.goto('/', { waitUntil: 'load' }); + + const measured = (await Promise.all(rows)).filter(Boolean); + const landing = measured.filter(row => row.landing); + // The measurement must actually see the landing's assets — an empty + // measurement would make the budget pass vacuously. + expect(landing.length).toBeGreaterThan(0); + return { + landing, + total: landing.reduce((sum, row) => sum + row.bytes, 0), + prefetchBeforeLoad: measured.filter(row => !row.landing && row.beforeLoad), + }; +} + +test('landing page ships less than 190KB of first-party code (gzipped)', async ({ page }) => { + const { landing, total } = await measureLandingPayload(page); console.log(`first-party gzipped total: ${total} bytes\n` // eslint-disable-line no-console - + sizes.map(s => ` ${s.bytes.toString().padStart(8)} ${s.url}`).join('\n')); + + landing.map(s => ` ${s.bytes.toString().padStart(8)} ${s.url}`).join('\n')); + expect(total).toBeLessThan(190 * 1024); +}); + +test('190KB guard stays exact when load is delayed past linked-route prefetches', async ({ page }) => { + // The logo gates window load but not rendering or hydration, so + // stalling it manufactures the arrival-time race: the hydrated router + // prefetches the linked routes' chunks (first-party .js, measured + // ~38KB on this build) while window load waits. Engines schedule that + // prefetch at very different idle latencies (webkit starts seconds + // later than chromium), so a fixed stall could let load win the race + // — the logo is held until another route's chunk has actually + // downloaded, making the miscount window deterministic everywhere. + let noteForeignChunk; + const foreignChunkDownloaded = new Promise(resolve => { noteForeignChunk = resolve; }); + await page.route('**/assets/logo.png', async route => { + // Fallback so a prefetch-less future still navigates; the assertion + // below then fails with a clear zero instead of a timeout. + await Promise.race([foreignChunkDownloaded, new Promise(r => setTimeout(r, 15000))]); + await route.continue(); + }); + + const { total, prefetchBeforeLoad } = await measureLandingPayload(page, { + onForeignFirstParty: noteForeignChunk, + }); + + // The stall really did land linked-route prefetch bytes before the load + // event — exactly what an arrival-time listener miscounts. + expect(prefetchBeforeLoad.length).toBeGreaterThan(0); + // The landing's own payload still measures clean: all of its assets + // gated the (delayed) load and were counted, and none of the other + // routes' bytes leaked into the budget. expect(total).toBeLessThan(190 * 1024); + // Firefox re-requests the logo after hydration; drop the stall so a + // late request cannot leave the route handler pending past the test. + await page.unrouteAll({ behavior: 'ignoreErrors' }); }); test('report page loads CDN widgets lazily (after window load)', async ({ page }) => { @@ -86,6 +201,40 @@ function mockReportJson(page) { }); } +/** + * Waits until the warmed wasm binary is observable in Cache Storage — the + * true precondition for any later page resolving it without network. + * window.Vizdom alone only proves the wasm initialized: the bridge fills + * the cache in the background (public/widgets/vizdom-bridge.js), so the + * stored entry itself must be observed before the test navigates onward. + * + * Polls through page.evaluate — waitForFunction in this Playwright version + * treats an async predicate's Promise object as immediately truthy, so it + * cannot await cache checks. Scans every cache by URL: the entry's cache + * name is the bridge's versioned constant and must not leak into tests. + */ +async function waitForWarmedWasm(page, timeout = 30000) { + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + const warmed = await page.evaluate(async () => { + if (typeof window.Vizdom !== 'object') return false; + try { + const wasmUrl = new URL('/widgets/vizdom_ts_bg.wasm', location.origin).href; + for (const key of await caches.keys()) { + const cache = await caches.open(key); + if (await cache.match(wasmUrl)) return true; + } + } catch { + // Cache Storage unavailable: the warm can never complete here. + } + return false; + }); + if (warmed) return; + await page.waitForTimeout(100); + } + throw new Error(`vizdom wasm never appeared in Cache Storage within ${timeout}ms`); +} + test('report content renders without waiting for the vizdom wasm', async ({ page }) => { await mockReportJson(page); // Hold the wasm and Chart.js back forever: the report must still render. @@ -111,6 +260,10 @@ test('vizdom wasm is fetched once and served from Cache Storage on repeat loads' await page.goto('/refactorfirst/refactorfirst'); await page.locator('#classGraph svg').waitFor({ timeout: 30000 }); expect(wasmRequests).toBe(1); + // The graphs only prove the wasm initialized; the cache fill runs in the + // background, so wait for the stored entry before asserting cache-served + // behavior across the reload. + await waitForWarmedWasm(page); // Canary entry: probe whether this browser persists Cache Storage across // loads in this environment. (Playwright's WebKit build keeps it @@ -237,3 +390,105 @@ test('3D popup renders the force graph through the shared widget modules', async // The force graph mounts its WebGL canvas inside the popup container. await page.locator('#graph-container-classGraph canvas').waitFor({ timeout: 30000 }); }); + +// --------------------------------------------------------------------------- +// Report rendering-library warm-up: directory pages (landing, user listing) +// warm the shared rendering libraries the first report needs during idle +// time — the vizdom wasm into Cache Storage through the shared bridge, the +// classic widget scripts and the bundled template into the HTTP cache — so +// the report a visitor opens renders from local caches. Report data is +// never warmed (listings can hold thousands of repositories), and the warm +// is gated on window load + idle so it never delays the hosting page's own +// first paint. +// --------------------------------------------------------------------------- + +/** Maps a warm-up resource URL onto its counter key (null if not one). */ +function warmResourceKey(url) { + if (url.endsWith('/widgets/vizdom_ts_bg.wasm')) return 'wasm'; + if (/chart\.umd\.min\.js$/.test(url)) return 'chart'; + if (/svg-pan-zoom\.min\.js$/.test(url)) return 'panZoom'; + if (url.endsWith('/assets/refactor-first-report.mustache')) return 'template'; + if (url.endsWith('.refactorfirst/refactor-first.json')) return 'reportData'; + return null; +} + +test('landing page warms the report rendering libraries during idle time', async ({ page }) => { + let loadFired = false; + page.on('load', () => { loadFired = true; }); + const warm = { wasm: 0, chart: 0, panZoom: 0, template: 0, reportData: 0 }; + const timing = []; + const downloads = []; + page.on('request', request => { + const key = warmResourceKey(request.url()); + if (!key) return; + // Record a resource only once its download completed successfully: + // a request that merely fired can still abort mid-flight or fail + // with a non-2xx status, and neither leaves anything behind for the + // report page to reuse (a cold cache is just the status quo there — + // the report refetches). Requests are captured when they start (all + // warm steps fire in the same idle callback) and each is resolved + // through its response's completion, so the counters reflect warmed + // bytes, not attempts. + downloads.push((async () => { + try { + const response = await request.response(); + if (!response || !response.ok()) return; // failed: not warmed + await response.finished(); // body fully downloaded + warm[key]++; + timing.push({ key, afterLoad: loadFired }); + } catch { + // Aborted mid-download: not warmed. + } + })()); + }); + + await page.goto('/'); + // The warmed bridge finished initializing the wasm AND storing it in + // Cache Storage (the fill runs in the background, so the stored entry — + // not window.Vizdom — is what a later page will read). + await waitForWarmedWasm(page); + // Every warm request started before the wasm finished caching (they all + // fire in the same idle callback), so the download list is complete — + // settle the in-flight completions before asserting on them. + await Promise.all(downloads); + + expect(warm.wasm).toBe(1); + expect(warm.chart).toBe(1); + expect(warm.panZoom).toBe(1); + expect(warm.template).toBe(1); + // Rendering libraries only: no repository report data is ever warmed. + expect(warm.reportData).toBe(0); + // The warm never competes with the landing's own rendering. + expect(timing.every(entry => entry.afterLoad)).toBe(true); +}); + +test('a report opened from the warmed landing never downloads the wasm', async ({ page }) => { + await mockReportJson(page); + await page.goto('/'); + await waitForWarmedWasm(page); + // Canary probe: does this environment persist Cache Storage across loads? + // (Playwright's WebKit build keeps it in-memory only on the http test + // server; HTTPS production deployments persist it in every engine.) + await page.evaluate(async () => { + const cache = await caches.open('vizdom-cache-canary'); + await cache.put('/canary', new Response('ok')); + }); + + let wasmRequests = 0; + page.on('request', request => { + if (request.url().endsWith('/widgets/vizdom_ts_bg.wasm')) wasmRequests++; + }); + await page.goto('/refactorfirst/refactorfirst'); + const cachePersists = await page.evaluate(async () => { + try { + const cache = await caches.open('vizdom-cache-canary'); + return (await cache.match('/canary')) !== undefined; + } catch { + return false; + } + }); + await page.locator('#classGraph svg').waitFor({ timeout: 30000 }); + // The wasm resolved from the warmed Cache Storage entry: zero network + // wherever the cache persists (the designed fallback refetches otherwise). + expect(wasmRequests).toBe(cachePersists ? 0 : 1); +}); diff --git a/tests/integration/landing-page.test.jsx b/tests/integration/landing-page.test.jsx index 07fc696..eae88bc 100644 --- a/tests/integration/landing-page.test.jsx +++ b/tests/integration/landing-page.test.jsx @@ -1,7 +1,7 @@ // Phase 3: landing page (RSC output) — hero, hero search, Add My Repo CTA, // featured repositories. Ported from tests/integration/search-flow.test.js // landing cases; fixtures replace the repositories.txt fetch. -import { describe, test, expect, beforeEach } from 'bun:test'; +import { describe, test, expect, beforeEach, afterEach, spyOn } from 'bun:test'; import { mock } from 'bun:test'; import { sharedNextNavigationMock } from './next-navigation-stub'; @@ -12,7 +12,7 @@ mock.module('next/navigation', () => sharedNextNavigationMock({ })); import Landing from '../../components/landing'; -import { render, screen, fireEvent, installRtlDom } from './rtl'; +import { render, screen, fireEvent, installRtlDom, cleanup } from './rtl'; import { jsx as _jsx } from 'react/jsx-runtime'; installRtlDom(); @@ -28,7 +28,21 @@ const REPOS = [ ]; describe('Landing page', () => { - beforeEach(() => { pushed.length = 0; }); + // The landing now mounts the ReportWarmup client component (idle-time + // report rendering-library warming). jsdom has no requestIdleCallback, so + // its warm would run on a setTimeout fallback — spy on fetch so these + // tests never reach the network and cleanup cancels the pending warm. + let mockFetch; + + beforeEach(() => { + pushed.length = 0; + mockFetch = spyOn(global, 'fetch') + .mockImplementation(() => Promise.resolve({ ok: true })); + }); + afterEach(() => { + mockFetch.mockRestore(); + cleanup(); + }); test('renders hero, search input and Add My Repo call-to-action', () => { render(_jsx(Landing, { repositories: REPOS })); From 4a094524fa02c646f31dd67d43fc03ec4d325164 Mon Sep 17 00:00:00 2001 From: Jim Bethancourt Date: Wed, 28 Oct 2026 06:59:59 -0500 Subject: [PATCH 2/7] Fixed DOT graph load issue --- lib/widget-loader.js | 46 ++++++++++++++++++++++++-- tests/integration/report-view.test.jsx | 44 ++++++++++++++++++++---- tests/unit/widget-loader.test.js | 31 ++++++++++++++++- 3 files changed, 111 insertions(+), 10 deletions(-) diff --git a/lib/widget-loader.js b/lib/widget-loader.js index 1163bf6..973c257 100644 --- a/lib/widget-loader.js +++ b/lib/widget-loader.js @@ -7,6 +7,37 @@ 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') { @@ -14,12 +45,18 @@ function installGlobalBridge() { } } +// 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 () => {}; } @@ -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, () => { diff --git a/tests/integration/report-view.test.jsx b/tests/integration/report-view.test.jsx index 61c758c..be49b34 100644 --- a/tests/integration/report-view.test.jsx +++ b/tests/integration/report-view.test.jsx @@ -406,12 +406,12 @@ describe('progressive widget enhancement', () => { test('builds charts once the chart widget reports ready after the render', async () => { const created = []; - window.Chart = function (ctx, config) { created.push(config); }; respondJsonFor(sampleJson); const utils = await renderReport(); // The report rendered with the widget still pending: no charts yet. expect(created.length).toBe(0); + window.Chart = function (ctx, config) { created.push(config); }; markWidgetReady('chart'); await waitFor(() => { expect(created.length).toBeGreaterThan(0); @@ -421,6 +421,10 @@ describe('progressive widget enhancement', () => { test('renders inline graphs once vizdom reports ready after the render', async () => { const renderedSvgs = []; + respondJsonFor(sampleJson); + const utils = await renderReport(); + expect(renderedSvgs.length).toBe(0); + window.Vizdom = { DotParser: class { parse() { @@ -436,10 +440,6 @@ describe('progressive widget enhancement', () => { } } }; - respondJsonFor(sampleJson); - const utils = await renderReport(); - expect(renderedSvgs.length).toBe(0); - markWidgetReady('vizdom'); await waitFor(() => { expect(renderedSvgs.length).toBeGreaterThan(0); @@ -447,13 +447,45 @@ describe('progressive widget enhancement', () => { expect(utils.container.querySelector('#classGraph svg')).not.toBeNull(); }); + test('renders inline graphs when the vizdom module already materialized before the report mounted', async () => { + // Regression: a visitor who first lands on a directory page and opens a + // report afterwards (client-side navigation) warms the vizdom wasm during + // idle time; the module evaluates then (window.Vizdom set) but its mark + // lands in a widget registry that only exists on report pages, so the + // registry here has never seen it. The graph enhancement must key off + // the already-materialized global instead of waiting for a mark that + // cannot fire again — otherwise the class/package graphs stay empty. + const renderedSvgs = []; + window.Vizdom = { + DotParser: class { + parse() { + return { + to_directed: () => ({ + layout: () => ({ + to_svg: () => ({ + to_string: () => { renderedSvgs.push(true); return ''; } + }) + }) + }) + }; + } + } + }; + respondJsonFor(sampleJson); + const { container } = await renderReport(); + await waitFor(() => { + expect(renderedSvgs.length).toBeGreaterThan(0); + }); + expect(container.querySelector('#classGraph svg')).not.toBeNull(); + }); + test('a payload change retires its still-pending widget callbacks', async () => { const created = []; - window.Chart = function (ctx, config) { created.push(config); }; respondJsonFor(sampleJson); const utils = await renderReport(); expect(created.length).toBe(0); + window.Chart = function (ctx, config) { created.push(config); }; // Branch switch: the payload-1 chart callback must not fire into the // replacement report; the fresh payload registers its own callback, so // marking the widget ready builds exactly one chart per live canvas. diff --git a/tests/unit/widget-loader.test.js b/tests/unit/widget-loader.test.js index 879ee26..7c08c76 100644 --- a/tests/unit/widget-loader.test.js +++ b/tests/unit/widget-loader.test.js @@ -9,7 +9,14 @@ import { } from '../../lib/widget-loader.js'; describe('widget-loader', () => { - beforeEach(() => resetWidgetRegistry()); + beforeEach(() => { + resetWidgetRegistry(); + // Readiness may also resolve from a materialized widget global (the idle + // warm-up case); keep every probed global clean so each test starts from + // the same unready baseline regardless of file execution order. + delete window.Vizdom; + delete window.Chart; + }); it('fires callbacks registered before the mark', () => { const calls = []; @@ -57,6 +64,28 @@ describe('widget-loader', () => { expect(isWidgetReady('vizdom')).toBe(false); }); + it('treats an already-materialized bridge global as ready', () => { + // The idle warm-up on a directory page can materialize a bridge module + // (window.Vizdom after the WASM initializes) before the widget registry + // has ever been instantiated on that page — so the mark that follows is + // lost, and a later report page must still recognize the widget as + // ready from the materialized global alone. + expect(isWidgetReady('vizdom')).toBe(false); + window.Vizdom = { DotParser: class {} }; + expect(isWidgetReady('vizdom')).toBe(true); + delete window.Vizdom; + expect(isWidgetReady('vizdom')).toBe(false); + }); + + it('treats an already-materialized CDN global as ready', () => { + expect(isWidgetReady('chart')).toBe(false); + window.Chart = function Chart() {}; + expect(isWidgetReady('chart')).toBe(true); + expect(isWidgetReady('Chart')).toBe(true); + delete window.Chart; + expect(isWidgetReady('chart')).toBe(false); + }); + it('marks a widget only once (duplicate marks are ignored)', () => { const calls = []; onWidgetReady('sigma', () => calls.push(1)); From 8d217d9af74f5612c6a746bb5f25b634afd6854a Mon Sep 17 00:00:00 2001 From: Jim Bethancourt Date: Wed, 28 Oct 2026 07:50:07 -0500 Subject: [PATCH 3/7] Fixed DOT graph load issue and applied coderabbit fixes --- components/report-warmup.jsx | 115 ++++++++++++++++++ tests/e2e/performance.spec.js | 26 +++- tests/integration/report-warmup.test.jsx | 144 +++++++++++++++++++++++ 3 files changed, 283 insertions(+), 2 deletions(-) create mode 100644 components/report-warmup.jsx create mode 100644 tests/integration/report-warmup.test.jsx diff --git a/components/report-warmup.jsx b/components/report-warmup.jsx new file mode 100644 index 0000000..383e2f2 --- /dev/null +++ b/components/report-warmup.jsx @@ -0,0 +1,115 @@ +'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