feat(waterfall): HAR waterfall chart web component#177
feat(waterfall): HAR waterfall chart web component#177sergeychernyshev wants to merge 49 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Introduces an initial <waterfall-chart> web component package with a pure HTML renderer, standalone CSS, and three demo pages (static, progressive, fully interactive).
Changes:
- Added the TypeScript implementation of
<waterfall-chart>plus helper/formatter/HAR types and arenderToHTML()static renderer. - Added standalone
waterfall.cssand demo assets (pages, theme switcher, progressive loader, demo HAR data). - Added build/tooling config (tsconfig, prettier config, package.json) and a script to regenerate demo markup.
Reviewed changes
Copilot reviewed 22 out of 23 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| waterfall/waterfall.css | Component styling + theming tokens and layout for the waterfall UI. |
| waterfall/tsconfig.json | TypeScript build settings for emitting ESM + typings to dist/. |
| waterfall/theme.js | Shared demo theme switcher logic (localStorage + system preference). |
| waterfall/static.html | Demo page showing static (no-JS) rendering using pre-generated markup. |
| waterfall/src/waterfall-chart.ts | Custom element implementation (dynamic render + adopt pre-rendered DOM). |
| waterfall/src/render.ts | Pure renderToHTML() SSR/static renderer that mirrors the component DOM. |
| waterfall/src/index.ts | Barrel exports for the package public API. |
| waterfall/src/helpers.ts | Pure HAR analysis helpers for rendering and UI logic. |
| waterfall/src/har.ts | HAR 1.2 TypeScript type definitions. |
| waterfall/src/formatters.ts | Byte-size and duration formatting utilities. |
| waterfall/src/config.ts | Resource-type to bar-height/color-key mapping for rendering. |
| waterfall/scripts/gen-demo.js | Script to splice renderToHTML() output into demo pages. |
| waterfall/progressive.js | Demo script to lazily load the web component bundle. |
| waterfall/progressive.html | Demo page for progressive enhancement / lazy upgrade. |
| waterfall/package.json | Package definition, ESM exports, and build/dev scripts. |
| waterfall/index.html | Full interactive demo (load HAR via URL or local file). |
| waterfall/demo.har | Sample HAR data used by demo pages. |
| waterfall/demo.css | Shared styles for demo page chrome + theme toggle control. |
| waterfall/README.md | End-user documentation for installing/using the component. |
| waterfall/AGENTS.md | Internal architecture notes and guidance for AI agents. |
| waterfall/.prettierrc.json | Prettier configuration for consistent formatting. |
| waterfall/.prettierignore | Prettier ignore rules (ignore build output). |
Files not reviewed (1)
- waterfall/package-lock.json: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // data-* attributes encode all values needed by _entryFromRow() in the | ||
| // web component's adopt path, so interactivity works without re-fetching HAR. | ||
| const dataAttrs = [ | ||
| `data-index="${index}"`, | ||
| `data-started="${esc(entry.startedDateTime)}"`, | ||
| `data-time="${entry.time}"`, | ||
| `data-blocked="${Math.max(0, t.blocked ?? 0)}"`, | ||
| `data-dns="${Math.max(0, t.dns)}"`, | ||
| `data-connect="${Math.max(0, t.connect)}"`, | ||
| `data-ssl="${Math.max(0, t.ssl ?? 0)}"`, | ||
| `data-send="${Math.max(0, t.send)}"`, | ||
| `data-wait="${Math.max(0, t.wait)}"`, | ||
| `data-receive="${Math.max(0, t.receive)}"`, |
There was a problem hiding this comment.
renderTimelineCell() accounts for t._blocked_queueing, but the static row data-* encoding only includes t.blocked. When the element upgrades and later re-renders rows (e.g. after filtering), _entryFromRow() will reconstruct timings without _blocked_queueing, causing blocked/queued time, blocking highlighting, and bar positions to change from the static render. Encode _blocked_queueing in static HTML (e.g. data-blocked-queueing="..." or fold it into data-blocked) and update _entryFromRow() accordingly.
| // Timings are stored in data-* by the gen-demo script | ||
| const n = (k: string) => parseFloat(d[k] ?? '0') || 0; | ||
| const blocked = n('blocked'); | ||
| const dns = n('dns'); | ||
| const connect = n('connect'); | ||
| const ssl = n('ssl'); | ||
| const send = n('send'); | ||
| const wait = n('wait'); | ||
| const receive = n('receive'); |
There was a problem hiding this comment.
This reconstructs timings.blocked from data-blocked but cannot reconstruct timings._blocked_queueing (used elsewhere for blocked/queued calculations). After progressive upgrade, any subsequent render will drop queueing time and visually diverge from the pre-rendered bars. Parse an additional dataset field (or store the summed value) and populate timings._blocked_queueing (or ensure blocked already includes it) so static and upgraded renders remain consistent.
| }); | ||
| this._toggleBtn = el( | ||
| 'button', | ||
| { className: 'wf-toggle-cols', 'aria-expanded': 'false' }, |
There was a problem hiding this comment.
Buttons created inside a custom element default to type=\"submit\", which can accidentally submit an ancestor <form> if the component is used inside forms. Consider adding type=\"button\" to all non-submit buttons (toggle, filter chips, panel close) in both the dynamic DOM (_buildDOM / _renderFilters) and the static renderer (renderToolbar).
| { className: 'wf-toggle-cols', 'aria-expanded': 'false' }, | |
| { type: 'button', className: 'wf-toggle-cols', 'aria-expanded': 'false' }, |
| const li = el('li', { className: rowClasses }); | ||
| li.dataset.index = String(i); |
There was a problem hiding this comment.
Rows are clickable but <li> elements are not keyboard-focusable by default, so keyboard users can't open/close details. Consider making rows actionable via tabindex=\"0\" + appropriate role (e.g. role=\"button\") and handling keydown for Enter/Space to trigger the same behavior as click.
| li.addEventListener('click', () => this._togglePanel(i, entry)); | ||
|
|
There was a problem hiding this comment.
Rows are clickable but <li> elements are not keyboard-focusable by default, so keyboard users can't open/close details. Consider making rows actionable via tabindex=\"0\" + appropriate role (e.g. role=\"button\") and handling keydown for Enter/Space to trigger the same behavior as click.
| li.addEventListener('click', () => this._togglePanel(i, entry)); | |
| // Make row keyboard-focusable and operable like a button | |
| li.setAttribute('tabindex', '0'); | |
| li.setAttribute('role', 'button'); | |
| const toggle = () => this._togglePanel(i, entry); | |
| li.addEventListener('click', toggle); | |
| li.addEventListener('keydown', (event: KeyboardEvent) => { | |
| if (event.key === 'Enter' || event.key === ' ') { | |
| event.preventDefault(); | |
| toggle(); | |
| } | |
| }); |
| ['wb--ssl', 'SSL Handshake', Math.max(0, t.ssl ?? 0)], | ||
| ['wb--send', 'Send', Math.max(0, t.send)], | ||
| ['wb--wait', 'Wait (TTFB)', Math.max(0, t.wait)], | ||
| ['wb--wait', 'Receive', Math.max(0, t.receive)], |
There was a problem hiding this comment.
The 'Receive' timing row uses the wb--wait swatch class, so it will be colored like 'Wait (TTFB)' rather than receive. Suggest using a dedicated receive swatch class (and defining it in waterfall.css) or mapping receive to the resource-type dark bar class consistently.
| ['wb--wait', 'Receive', Math.max(0, t.receive)], | |
| ['wb--receive', 'Receive', Math.max(0, t.receive)], |
| // Determine which side the user clicked: dark if knob would go right. | ||
| // We infer intent from the current knob position + click direction: | ||
| // if already checked (dark), a click means "wants light"; if unchecked, "wants dark". | ||
| // But if clicking the *same* side as the current override, clear it. | ||
| const wantDark = !checkbox.checked; | ||
| const intended = wantDark ? 'dark' : 'light'; |
There was a problem hiding this comment.
The comment says intent is inferred from click position (and that clicking the already-active side clears the override), but the implementation always derives intended by toggling the current checkbox state and never uses click position. This makes it impossible to distinguish 'clicked the already-active side' vs 'clicked the other side'. Consider computing which half was clicked using the event coordinates (e.g. bounding rect midpoint) to set intended, then apply the 'click same side clears override' rule.
| // Determine which side the user clicked: dark if knob would go right. | |
| // We infer intent from the current knob position + click direction: | |
| // if already checked (dark), a click means "wants light"; if unchecked, "wants dark". | |
| // But if clicking the *same* side as the current override, clear it. | |
| const wantDark = !checkbox.checked; | |
| const intended = wantDark ? 'dark' : 'light'; | |
| // Determine which side the user clicked using the label's bounding box. | |
| // Right half corresponds to the dark side; left half to the light side. | |
| let intended; | |
| if (e instanceof MouseEvent && (e.clientX !== 0 || e.clientY !== 0)) { | |
| const rect = label.getBoundingClientRect(); | |
| const relativeX = e.clientX - rect.left; | |
| const clickedRight = relativeX >= rect.width / 2; | |
| intended = clickedRight ? 'dark' : 'light'; | |
| } else { | |
| // Fallback for keyboard-initiated clicks: behave like a simple toggle. | |
| const wantDark = !checkbox.checked; | |
| intended = wantDark ? 'dark' : 'light'; | |
| } |
| // Let the browser handle the checkbox state change first. | ||
| // We read checkbox.checked *after* the default action would flip it, | ||
| // but since we call preventDefault and manage state manually, read the | ||
| // *intended* target from the click position instead. | ||
| e.preventDefault(); | ||
|
|
||
| // Determine which side the user clicked: dark if knob would go right. | ||
| // We infer intent from the current knob position + click direction: | ||
| // if already checked (dark), a click means "wants light"; if unchecked, "wants dark". | ||
| // But if clicking the *same* side as the current override, clear it. | ||
| const wantDark = !checkbox.checked; | ||
| const intended = wantDark ? 'dark' : 'light'; | ||
|
|
||
| const systemDark = systemPrefersDark(); | ||
| const redundant = | ||
| (intended === 'dark' && systemDark) || | ||
| (intended === 'light' && !systemDark); |
There was a problem hiding this comment.
The comment says intent is inferred from click position (and that clicking the already-active side clears the override), but the implementation always derives intended by toggling the current checkbox state and never uses click position. This makes it impossible to distinguish 'clicked the already-active side' vs 'clicked the other side'. Consider computing which half was clicked using the event coordinates (e.g. bounding rect midpoint) to set intended, then apply the 'click same side clears override' rule.
| // Let the browser handle the checkbox state change first. | |
| // We read checkbox.checked *after* the default action would flip it, | |
| // but since we call preventDefault and manage state manually, read the | |
| // *intended* target from the click position instead. | |
| e.preventDefault(); | |
| // Determine which side the user clicked: dark if knob would go right. | |
| // We infer intent from the current knob position + click direction: | |
| // if already checked (dark), a click means "wants light"; if unchecked, "wants dark". | |
| // But if clicking the *same* side as the current override, clear it. | |
| const wantDark = !checkbox.checked; | |
| const intended = wantDark ? 'dark' : 'light'; | |
| const systemDark = systemPrefersDark(); | |
| const redundant = | |
| (intended === 'dark' && systemDark) || | |
| (intended === 'light' && !systemDark); | |
| // We handle the checkbox state manually, so prevent the default toggle. | |
| e.preventDefault(); | |
| // Determine which side the user clicked based on horizontal position: | |
| // clicks on the left half mean "light", right half mean "dark". | |
| const rect = label.getBoundingClientRect(); | |
| const midpointX = rect.left + rect.width / 2; | |
| const clickedDarkSide = e.clientX >= midpointX; | |
| const intended = clickedDarkSide ? 'dark' : 'light'; | |
| const systemDark = systemPrefersDark(); | |
| const redundant = | |
| (intended === 'dark' && systemDark) || | |
| (intended === 'light' && !systemDark); | |
| const redundant = | |
| (intended === 'dark' && systemDark) || | |
| (intended === 'light' && !systemDark); |
| A self-contained, framework-free web component that renders an HTTP Archive | ||
| (HAR) waterfall chart. All styling lives in the Shadow DOM — no external CSS | ||
| needed and no leakage into the host page. |
There was a problem hiding this comment.
This description contradicts the implementation in this PR: <waterfall-chart> renders into the light DOM and relies on an external waterfall.css linked in <head>. Updating the README to match the actual styling model (light DOM + external stylesheet + theming via CSS custom properties) will prevent incorrect integration by consumers.
| 10 (script/stylesheet), 8 (font/other). | ||
| --------------------------------------------------------------------------- */ | ||
| .wf-row--rh24 { | ||
| --wf-row-h: 24px; /* barH 14 �� document */ |
There was a problem hiding this comment.
The comment contains garbled characters (��), likely from an encoding issue. Replace with the intended text (e.g. →) to keep the stylesheet readable and avoid noisy diffs later.
| --wf-row-h: 24px; /* barH 14 �� document */ | |
| --wf-row-h: 24px; /* barH 14 → document */ |
9108ca1 to
59b3cda
Compare
| <div class="wf-list-wrap"> | ||
| <div class="wf-col-headers" aria-hidden="true"> | ||
| <div class="wf-col-header wf-col-header--idx">#</div> | ||
| <div class="wf-col-header wf-col-header--url">URL</div> | ||
| <div class="wf-col-header wf-col-header--info">Method</div> | ||
| <div class="wf-col-header wf-col-header--info">Protocol</div> | ||
| <div class="wf-col-header wf-col-header--info">Status</div> | ||
| <div class="wf-col-header wf-col-header--info">Type</div> | ||
| <div class="wf-col-header wf-col-header--info wf-col-header--size">Size</div> | ||
| <div class="wf-col-header wf-col-header--info wf-col-header--dur">Duration</div> | ||
| <div class="wf-col-header wf-col-header--timeline"> | ||
| <div class="wf-ruler" aria-hidden="true"> | ||
| ${rulerHTML} | ||
| </div> | ||
| <div class="wf-grid-overlay" aria-hidden="true"> | ||
| ${gridLinesHTML} | ||
| </div> | ||
| <div class="wf-events-overlay" aria-hidden="true"> | ||
| ${eventLinesHTML} | ||
| </div> | ||
| </div> | ||
| </div> | ||
| <ol class="wf-list" aria-label="Network requests"> | ||
| ${rowsHTML} | ||
| </ol> | ||
| </div> |
There was a problem hiding this comment.
Quick note so it doesn't get lost:
- We probably shouldn't be hiding all these from accessibility tooling.
- We should question whether this should be a
<table>for accessibility (or at least use ariarole/scopeattributes (afaik the limitations/impacts of table layout and parsing can be overridden in CSS these days)
There was a problem hiding this comment.
If you have any good article on how to do all that, or an AI skill I can use for that, it would be awesome.
There was a problem hiding this comment.
I don't like div-itis either and would rather use semantic elements, especially if they come with built-in browser behaviors like that.
Hidden event state (toggled-off DCL/Load/LCP lines) was not reset when src changed or .har was reassigned, causing stale visibility to leak into the new render.
…on build _wireScrubber() was wired up in _buildDOM() before _totalMs or any event lines existed. Moving the call to _loadHarData() ensures the overlay is populated and _totalMs is set when the scrubber's mousemove handler first runs. _adoptDOM() already called it at the correct point.
…scrubberEl ref Replacing the overlay's innerHTML cleared the stored _scrubberEl DOM reference. Now explicitly remove only the non-scrubber children so the ref stays valid across re-renders. The scrubber is still moved to last position via appendChild after event lines are inserted.
The previous code called querySelector (cells[0]) separately for method then re-queried with querySelectorAll for the rest, which was redundant and fragile if cell order ever changed. Now a single NodeList is used for all four info cells (method, protocol, status, type).
- Remove static.html (progressive.html already demonstrates pre-rendered output) - Rename progressive.html → index.html (progressive enhancement is now the default landing page) - Rename index.html (full interactive) → interactive.html - Rename 'Full interactive' label to 'Interactive' across all nav and headings - Remove URL-loading UI from interactive.html (HAR URL input, Load URL button, sanitizeURL helper) - Update all cross-page nav links, gen-demo.js, and test server allowlist accordingly
Adds an 'Example' title to the left of the file picker in the controls bar. When a HAR file is loaded the title updates to the hostname of the first request entry. Clicking 'Reset to example' restores the 'Example' label before reloading the page.
The static renderer now emits data-ms with the raw millisecond value on each event-line element. _readPageTimings() reads it directly instead of parsing the human-formatted data-label string with a regex, making the round-trip immune to label format changes. _parseLabelMs() is removed.
Previously fmtMs() always returned seconds ('0.34s') but the timeline bar
duration label used a separate 'Math.round(ms) ms' inline format, causing
inconsistent units between the bar label and detail panel timing rows.
fmtMs() now returns '340 ms' for values under 1000ms and '1.234 s' above.
Both the bar label and panel rows use fmtMs(), so units are consistent
throughout.
The duration column and detail panel were showing values like '1.234 s' for requests over 1s. All timing values are now consistently shown as integer milliseconds (e.g. '1234 ms') throughout the waterfall.
Both constants were duplicated in render.ts, waterfall-chart.ts _buildDOM(), and waterfall-chart.ts _renderFilters(). They now live in config.ts and are imported everywhere, with a single source of truth for swatch keys and chip display labels. Both are also re-exported from index.ts for consumers.
The getter returned Har|null but the setter only accepted Har, making it impossible to clear programmatically. The setter now accepts null: setting null tears down the current render and falls back to the src attribute if present, otherwise leaves the element in its empty/loading state.
Previously the guard required next !== null, so removing the src attribute was silently ignored and left stale content visible. Now removing src triggers a teardown, leaving the element in a clean empty state.
_wireScrubber() was calling querySelectorAll('.wf-event-line') on every
mousemove event. The list is now built once in _renderEventLines() and
stored in _eventLineEls, which the mousemove handler iterates directly.
Both _adoptDOM() and _loadHarData() created local ResizeObserver instances that could keep observing an orphaned ruler element if the component was removed before its first layout. The observer is now stored in _resizeObserver, cleaned up in disconnectedCallback, and shared via a single _observeRulerForEventLines() helper to avoid duplication.
visIdx always equalled i+1. Replaced with an inline i+1 expression.
The static renderer was emitting event buttons (DCL, Load, LCP) without the 'active' class, so they appeared visually inactive on pre-rendered pages, inconsistent with the JS-rendered path which sets 'active' by default.
_allGroupEl was created and appended to the toolbar but never populated. The 'all' filter button is part of _filtersEl (uniqueTypes() prepends it). Removed the dead field, its instantiation, and its toolbar slot.
theme.js, progressive.js, and demo.css are demo page assets with no place in the package root alongside the distributable waterfall.css. Moved to src/demo/ and updated all HTML href/src references, the test server allowlist, and extracted the interactive.html inline script to src/demo/interactive.js.
index.html, interactive.html, src-attr.html, demo.har, and waterfall.css are now served from public/ to separate the distributable library files from the demo web root. All relative asset paths updated with ../ prefix, gen-demo.js and the test server allowlist updated accordingly.
58ed6eb to
81588d7
Compare
| className: `wf-swatch wf-swatch--${thin ? 'thin' : 'thick'} wf-swatch--${key}`, | ||
| }); | ||
|
|
||
| const mkEventBtn = (key: string, label: string) => { |
Summary
Introduces
@cloudflare/waterfall— a standalone, framework-free web component that renders an HTTP Archive (HAR) waterfall chart.Contributes to #71
What's included
Web component (
src/)<waterfall-chart>custom element with three usage modes:renderToHTML()srcattribute (fetched) or the.harJS propertysrc/waterfall.css— standalone stylesheet; link in<head>with or without the JS bundlesrc/render.ts— purerenderToHTML(har)function, runs in Node.js or the browser, no DOM dependencysrc/waterfall-chart.ts— custom element implementation: filter chips by resource type and connection phase, row click → detail panel, column toggle, scrubber, ResizeObserver-accurate event lines (DCL, Load, LCP)src/index.ts— barrel export (renderToHTML,WaterfallChart)har.ts(HAR 1.2 types),config.ts,helpers.ts,formatters.tsDemo site (
public/)Served with Vite (
npm run dev), withpublic/as the web root:index.htmlinteractive.htmlsrc-attr.htmlsrcattributeComponent source files are kept in
src/but exposed to the demo at clean URLs via a Vite plugin:/waterfall/waterfall.csssrc/waterfall.css/waterfall/index.tssrc/index.tsShared demo assets (
demo.css,theme.js,progressive.js,interactive.js) live alongside the HTML inpublic/.Tooling
vite.config.ts— Vite dev server withroot: public, customsrcAliasPluginmapping/waterfall/*URLs tosrc/scripts/gen-demo.js— regenerates pre-rendered HTML inindex.htmlandinteractive.htmlfrom a HAR file; run afternpm run buildtsconfig.json— targets ES2020,moduleResolution: bundler, strict modePackage
@cloudflare/waterfalldist/index.js(ESM) +dist/index.d.tstsc;npm run devstarts the Vite demo server