Contract Testing Custom Event Payloads
A custom event is the most fragile part of a Web Component’s public API because nothing enforces its shape. A component can rename a key in event.detail, change a value’s type, or stop bubbling, and every existing test still passes — until a consumer’s handler reads undefined at runtime in production. The payload is an untyped runtime contract, and the only durable defense is to write that contract down as a schema and assert against it in a real browser.
This deep-dive sits under Contract & Visual Testing, within the broader Distribution, Testing & Tooling section.
The minimal reproducible example
A date-picker emits a range-selected event. The original release ships this payload:
// v1.0 — the published contract consumers built against.
this.dispatchEvent(new CustomEvent('range-selected', {
bubbles: true,
composed: true,
detail: { start: '2026-06-01', end: '2026-06-20', nights: 19 },
}));
A consumer wires a handler to it:
picker.addEventListener('range-selected', (e) => {
bookingForm.checkIn.value = e.detail.start;
bookingForm.checkOut.value = e.detail.end;
bookingForm.total.value = e.detail.nights * nightlyRate;
});
A later refactor “tidies up” the payload — start/end become a nested range object, nights is dropped as derivable, and a developer flips composed to false because the event “doesn’t need to leave the component”:
// v1.1 — a silent breaking change. The unit suite is still green.
this.dispatchEvent(new CustomEvent('range-selected', {
bubbles: true,
composed: false, // now trapped inside the shadow boundary
detail: { range: { from: '2026-06-01', to: '2026-06-20' } },
}));
Nothing in the component’s test suite asserted the payload shape, so CI is green. In production the consumer’s handler silently writes undefined into three form fields, and because composed is now false, a listener attached on an ancestor outside the picker’s shadow root never fires at all — the event dies at the boundary. No exception, no log, just a broken booking form.
Root-cause analysis
Events are dispatched and propagated by the DOM Standard’s event dispatch algorithm, but that algorithm guarantees only delivery mechanics, never payload shape. The detail property of a CustomEvent is, by specification, any — an opaque value the platform forwards verbatim. There is no schema, no type, and no compile-time check, even in a TypeScript codebase, because CustomEvent<T>'s generic is erased at runtime and consumers in other languages or framework wrappers never see it.
The propagation flags compound the fragility. Per the dispatch algorithm, composed decides whether an event crosses shadow boundaries during the capture and bubble phases: a composed: false event stops at the shadow root that contains its target, so an ancestor listener in the light DOM is unreachable. bubbles: false confines the event to the target itself. These flags are part of the contract exactly as much as the detail keys are — the mechanics are detailed in Event Composition & Bubbling and Composing Custom Events Across Shadow Boundaries. Because all of this is runtime-only and untyped, the contract can drift with zero feedback unless a test asserts the full envelope: payload structure and propagation flags.
The production-safe fix
Encode the contract as a JSON Schema, validate event.detail against it with Ajv, and assert the propagation flags explicitly — all inside a real browser via Playwright, because the event must travel a real composed path to prove composed behaves.
// range-selected.schema.js — the contract, versioned alongside the component.
export const rangeSelectedSchema = {
$id: 'range-selected@1',
type: 'object',
required: ['start', 'end', 'nights'],
additionalProperties: false, // a NEW key is a contract change and must fail.
properties: {
start: { type: 'string', format: 'date' },
end: { type: 'string', format: 'date' },
nights: { type: 'integer', minimum: 0 },
},
};
// range-selected.contract.spec.js
import { test, expect } from '@playwright/test';
import Ajv from 'ajv';
import addFormats from 'ajv-formats';
import { rangeSelectedSchema } from './range-selected.schema.js';
const ajv = addFormats(new Ajv({ allErrors: true }));
const validate = ajv.compile(rangeSelectedSchema);
test('range-selected honors its published contract', async ({ page }) => {
await page.goto('/components/date-picker.html');
await page.evaluate(() => customElements.whenDefined('date-picker'));
// Capture the real event in the page realm, including its propagation flags,
// by listening on an ANCESTOR outside the component's shadow root.
const captured = await page.evaluate(() => new Promise((resolve) => {
document.body.addEventListener('range-selected', (e) => resolve({
detail: e.detail,
bubbles: e.bubbles,
composed: e.composed,
}), { once: true });
const picker = document.querySelector('date-picker');
picker.selectRange('2026-06-01', '2026-06-20'); // public API trigger
}));
// 1. Payload shape — the assertion is the schema itself.
const ok = validate(captured.detail);
expect(ok, JSON.stringify(validate.errors, null, 2)).toBe(true);
// 2. Propagation flags are part of the contract too.
expect(captured.bubbles).toBe(true);
expect(captured.composed).toBe(true);
});
Listening on document.body — an ancestor outside the picker’s shadow root — is deliberate: if a refactor flips composed to false, the event never reaches body, the promise never resolves, and the test fails on timeout. The assertion thus covers boundary-crossing behavior, not just payload keys. additionalProperties: false makes the schema strict: adding a key is treated as a contract change requiring a deliberate version bump, not a silent expansion.
Two refinements make the schema do more work. First, use format keywords (date, date-time, uri, email) via ajv-formats so a string that is structurally present but semantically wrong — "2026-13-45" in a date field — is rejected, not just a missing key. Second, when a payload nests objects or arrays, schema them fully rather than declaring type: 'object' and stopping; an under-specified nested schema is a hole through which a breaking change slips. Ajv compiles the schema once into an optimized validator function, so even an exhaustive schema costs microseconds per assertion and never becomes the test’s bottleneck.
Run Ajv in strict mode during development so the schema itself is validated — a typo like requried or an unknown keyword throws at compile time instead of silently never constraining anything. Compile the validator once at module load, outside the test body, so every test in the file reuses the same compiled function; recompiling per test is wasteful and obscures schema-compile errors among test failures.
Verification
Run the test against the broken v1.1 implementation and watch it fail with a precise, actionable message:
range-selected honors its published contract (chromium)
AssertionError: expected false to be true
[
{ "instancePath": "", "keyword": "required",
"params": { "missingProperty": "start" }, "message": "must have required property 'start'" },
{ "instancePath": "", "keyword": "required",
"params": { "missingProperty": "end" }, "message": "must have required property 'end'" },
{ "instancePath": "", "keyword": "required",
"params": { "missingProperty": "nights" }, "message": "must have required property 'nights'" },
{ "instancePath": "", "keyword": "additionalProperties",
"params": { "additionalProperty": "range" }, "message": "must NOT have additional properties" }
]
The composed: false regression is caught separately — the page.evaluate promise never resolves and Playwright reports a timeout on the listener, pinpointing that the event stopped crossing the shadow boundary. Restore the v1.0 payload and flags, and the same test passes:
range-selected honors its published contract (chromium) ✓ 41ms
range-selected honors its published contract (webkit) ✓ 48ms
range-selected honors its published contract (firefox) ✓ 45ms
The failing-then-passing pair is the proof: the schema rejects the broken payload with named missing properties and a named offending key, then accepts the correct one across all three engines.
Backward-compatibility guidance
A schema that fails on any change is too brittle for a library that must evolve. Version the contract instead of freezing it.
| Change to the event | Compatibility | How the schema should treat it |
|---|---|---|
Add an optional detail key |
Backward-compatible | Keep additionalProperties: false, add the key as non-required; bump minor (@1 → still @1). |
Add a required detail key |
Breaking | Consumers may not supply/expect it; new schema $id (@2), keep @1 test until the deprecation window closes. |
| Rename or retype a key | Breaking | New $id; emit both old and new keys during a transition release so existing handlers keep working. |
| Remove a key | Breaking | New $id; deprecate first, warn in dev builds, remove only on a major. |
Flip bubbles or composed |
Breaking | Assert explicitly; treat any change as major — it alters who can hear the event. |
The practical pattern for a non-breaking evolution is additive emission: during the transition window, populate both the old and new shapes so the v1 schema and the v2 schema both pass, give consumers a release to migrate, then drop the old keys on the next major and retire the old schema. Keep each schema file versioned by $id and committed next to the component, so the contract is reviewed in the same pull request as the code that emits it — the contract and its implementation can never drift apart if a reviewer sees both diffs together.
The schema is also a documentation and tooling artifact, not only a test fixture. Because it is plain JSON Schema, the same file can generate consumer-facing TypeScript types (via json-schema-to-typescript), feed a published API reference, and validate the payload at the consumer’s boundary in defensive integrations. A consumer that does not trust an upstream component can run the same schema against received events in its own integration tests, turning the producer’s contract into a shared, executable agreement rather than prose in a changelog. This is the durable value of writing the contract down: one file simultaneously gates the producer’s CI, types the consumer’s handler, and documents the event for everyone in between.
Finally, treat the propagation flags as first-class schema-adjacent assertions rather than afterthoughts. A change from bubbles: true to bubbles: false is invisible to a JSON Schema validating only detail, yet it silently breaks every consumer that delegated the listener to a shared ancestor. Assert bubbles, composed, and cancelable explicitly in every event contract test, and document their intended values alongside the detail schema, so the full event envelope — payload plus reach — is reviewed and protected as one unit.
When to use, when to avoid
- Use schema-based contract tests for every event a published component emits — these are the events consumers wire handlers to, and they are exactly what unit tests silently let drift.
- Use real-browser dispatch (Playwright) so
composed/bubblesare exercised through an actual composed event path, not a mock. - Use
additionalProperties: falseplus explicit flag assertions so additions and propagation changes are deliberate, reviewed events. - Avoid schema-testing purely internal events that never cross the component boundary and have no external consumers — assert those with lighter unit checks.
- Avoid over-constraining value content (exact dates, generated IDs) in the schema; assert types and structure, and check volatile values separately or mask them.
Related
- Contract & Visual Testing — the parent topic covering the full real-browser test gate.
- Distribution, Testing & Tooling — the section governing packaging, testing, and publishing.
- Visual Regression Testing of Shadow DOM — the pixel-level half of the same gate.
- Event Composition & Bubbling — the
composed/bubblessemantics this contract asserts. - Composing Custom Events Across Shadow Boundaries — emitting events that survive shadow-root crossings.