Skip to content

feat(waterfall): HAR waterfall chart web component#177

Draft
sergeychernyshev wants to merge 49 commits into
mainfrom
waterfall
Draft

feat(waterfall): HAR waterfall chart web component#177
sergeychernyshev wants to merge 49 commits into
mainfrom
waterfall

Conversation

@sergeychernyshev
Copy link
Copy Markdown
Member

@sergeychernyshev sergeychernyshev commented Mar 5, 2026

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:
    • Static — pure HTML + CSS, zero JavaScript, pre-rendered via renderToHTML()
    • Progressive enhancement — pre-rendered children upgraded in place when the JS bundle loads, wiring up interactivity without re-rendering
    • Fully dynamic — element builds its own DOM from a HAR supplied via the src attribute (fetched) or the .har JS property
  • src/waterfall.css — standalone stylesheet; link in <head> with or without the JS bundle
  • src/render.ts — pure renderToHTML(har) function, runs in Node.js or the browser, no DOM dependency
  • src/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)
  • Supporting modules: har.ts (HAR 1.2 types), config.ts, helpers.ts, formatters.ts

Demo site (public/)

Served with Vite (npm run dev), with public/ as the web root:

Page Description
index.html Progressive enhancement — pre-rendered chart, click to activate JS
interactive.html Fully dynamic — load any local HAR file
src-attr.html Load HAR via the src attribute

Component source files are kept in src/ but exposed to the demo at clean URLs via a Vite plugin:

URL Source
/waterfall/waterfall.css src/waterfall.css
/waterfall/index.ts src/index.ts

Shared demo assets (demo.css, theme.js, progressive.js, interactive.js) live alongside the HTML in public/.

Tooling

  • vite.config.ts — Vite dev server with root: public, custom srcAliasPlugin mapping /waterfall/* URLs to src/
  • scripts/gen-demo.js — regenerates pre-rendered HTML in index.html and interactive.html from a HAR file; run after npm run build
  • tsconfig.json — targets ES2020, moduleResolution: bundler, strict mode
  • Playwright + Vitest test suite covering bar rendering, metric filters, overlay positioning, theme toggle, column toggle

Package

  • Name: @cloudflare/waterfall
  • Exports: dist/index.js (ESM) + dist/index.d.ts
  • Built with tsc; npm run dev starts the Vite demo server

@sergeychernyshev sergeychernyshev requested review from a team and Copilot March 5, 2026 20:47
@sergeychernyshev sergeychernyshev marked this pull request as draft March 5, 2026 20:47
Comment thread waterfall/index.html Fixed
Comment thread waterfall/index.html Fixed
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 a renderToHTML() static renderer.
  • Added standalone waterfall.css and 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.

Comment on lines +260 to +272
// 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)}"`,
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +266 to +274
// 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');
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread waterfall/src/waterfall-chart.ts Outdated
});
this._toggleBtn = el(
'button',
{ className: 'wf-toggle-cols', 'aria-expanded': 'false' },
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
{ className: 'wf-toggle-cols', 'aria-expanded': 'false' },
{ type: 'button', className: 'wf-toggle-cols', 'aria-expanded': 'false' },

Copilot uses AI. Check for mistakes.
Comment on lines +892 to +893
const li = el('li', { className: rowClasses });
li.dataset.index = String(i);
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +949 to +950
li.addEventListener('click', () => this._togglePanel(i, entry));

Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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();
}
});

Copilot uses AI. Check for mistakes.
['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)],
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
['wb--wait', 'Receive', Math.max(0, t.receive)],
['wb--receive', 'Receive', Math.max(0, t.receive)],

Copilot uses AI. Check for mistakes.
Comment on lines +81 to +86
// 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';
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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';
}

Copilot uses AI. Check for mistakes.
Comment thread waterfall/theme.js Outdated
Comment on lines +75 to +91
// 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);
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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);

Copilot uses AI. Check for mistakes.
Comment thread waterfall/README.md Outdated
Comment on lines +3 to +5
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.
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread waterfall/waterfall.css Outdated
10 (script/stylesheet), 8 (font/other).
--------------------------------------------------------------------------- */
.wf-row--rh24 {
--wf-row-h: 24px; /* barH 14 �� document */
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
--wf-row-h: 24px; /* barH 14 �� document */
--wf-row-h: 24px; /* barH 14 document */

Copilot uses AI. Check for mistakes.
Comment thread waterfall/__tests__/theme.test.ts Fixed
Comment thread packages/waterfall/__tests__/helpers.ts Fixed
Comment thread waterfall/src/waterfall-chart.ts Fixed
Comment thread waterfall/src/waterfall-chart.ts Fixed
Comment thread waterfall/src/waterfall-chart.ts Fixed
Comment thread waterfall/src/waterfall-chart.ts Fixed
Comment thread waterfall/src/waterfall-chart.ts Fixed
Comment thread waterfall/src/waterfall-chart.ts Fixed
Comment thread packages/waterfall/src/waterfall-chart.ts Fixed
Comment thread waterfall/index.html Fixed
Comment thread waterfall/index.html Fixed
Comment thread waterfall/index.html Fixed
Comment thread waterfall/index.html Fixed
Comment thread packages/waterfall/src/waterfall-chart.ts Fixed
@sergeychernyshev sergeychernyshev added the ticket This label indicates that internal ticket was created to track it. label Mar 24, 2026
@sergeychernyshev sergeychernyshev changed the title Initial waterfall web component feat(waterfall): HAR waterfall chart web component Mar 24, 2026
Comment on lines +353 to +378
<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>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 aria role/scope attributes (afaik the limitations/impacts of table layout and parsing can be overridden in CSS these days)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
className: `wf-swatch wf-swatch--${thin ? 'thin' : 'thick'} wf-swatch--${key}`,
});

const mkEventBtn = (key: string, label: string) => {
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ticket This label indicates that internal ticket was created to track it.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants