dispatchEvent() returning true is an easy success signal to overread. In a reusable component, that boolean can be true while the application-level delegated listener you care about receives nothing. The missing step is not “make events bubble” in the abstract. It is to prove the exact route from the component’s dispatch origin to the named consumer, including capture, shadow-host handling, outside bubbling, retargeting, and the event path visible to each listener.
That distinction is the core of this Shadow DOM event propagation playbook. For CustomEvent, bubbles and composed are separate routing controls: the DOM Standard gives both options a default of false, while MDN describes composed as the flag that allows a path to cross a shadow boundary. A CustomEvent with bubbles: true but composed: false can therefore bubble inside its shadow tree and still never reach the host or document. Conversely, a non-bubbling composed event has outside observations that are easy to miss if you inspect only a document bubble listener.
The laboratory below uses one host, one shadow root, one internal button, native DOM APIs, and synchronous dispatch. The preparation record reports Chromium 144.0.7559.96, Playwright 1.57.0, and CPython 3.13.5. The fixture printed below returned PASS_10_CASES on 21 October 2026 with headless Chromium 153.0.8010.0, Playwright 1.57.0, and CPython 3.12.14. An external launcher selected the available Chromium executable; the event logic, flags, assertions, and case inputs were those shown here. The results describe behavior in that Chromium build; other browser engines remain untested here.
Define the public event the application actually needs
Start with the consumer contract, not with the flags. In this fixture, the named application consumer is the non-capture listener on document: document-bubble. It needs one public component notification carrying a stable identifier. The acceptance question is therefore narrow: for an event originating on the internal button, does that exact listener run with the agreed payload, target view, and phase?
That requirement must stay separate from dispatch completion. The DOM Standard defines dispatchEvent() in terms of dispatch and cancellation state, not in terms of whether a particular application listener acknowledged the event. A successful call is necessary to execute the route, but it is not evidence that document-bubble ran.
Write the proposed public contract before the test. A useful review statement is: “When the component publishes item-selected from its internal control, the application’s document-level bubbling listener receives exactly one notification with a stable item identifier; outside listeners see the component host as the target.” That statement names the consumer, payload, permitted external delivery, and expected retargeting.
The payload belongs in the event’s documented public data, not in assumptions about the component’s private markup. CustomEvent extends the normal event initialization options with detail, which is the application-defined data available to handlers. Broader component integration boundaries may influence ownership, but this playbook keeps the decision at one native event boundary. Architectural choices about independently maintained components still need an explicit agreement about which notifications their consumers may observe.
Finally, state what is not public. Internal implementation signals do not automatically earn bubbles: true, composed: true merely because one public event needs those flags. The component owner should approve exposure event by event. A diagnostic case can pass while proving that a candidate is unsuitable for the reviewed public contract.
Turn that statement into an acceptance record before anyone edits code. Record the event type, dispatch origin, application listener target, capture setting, required payload keys, and which outside observers are permitted to see the event. This prevents a common review failure: one engineer proves that some outside callback ran while another engineer assumes the document bubble consumer ran. The contract should name document-bubble explicitly so an outside capture observation can never be substituted for the required recipient.
Build one host and a controlled shadow tree
Reduce the problem until route selection is the only meaningful variable. Each dispatch case creates a plain div host, attaches one shadow root, appends one button, and registers capture and non-capture listeners at four positions: document, host, shadow root, and button. No framework adapter, custom-element lifecycle, slot, nested root, or user click is involved.
That shape gives eight possible listener identities without changing component topology between cases. The test records five facts for every invocation: listener label, eventPhase, observer-visible target, composedPath() labels, and the payload identifier. The expected route is built independently from those observations and compared against the exact ordered listener list.
A real browser matters because native event dispatch is the behavior under test. The DOM Standard defines the dispatch algorithm, including path construction, capture traversal, bubbling traversal, target adjustment, and path clearing. Playwright is only the automation layer that asks Chromium to run that algorithm. Replacing the browser with a DOM emulator would change the implementation being evidenced.
The execution should also capture the browser and tool versions in the same report as the traces. That makes the result auditable instead of silently treating a browser behavior observation as timeless. The source below hashes itself before launch and writes a fresh evidence directory. Its local HTML is inert; the run itself does not install dependencies or contact production services.
The expected route is deliberately encoded separately from the listener log. That design matters more than the green status. If the test generated its expectation from the same observations it was validating, a routing regression could rewrite both sides of the comparison and still look consistent. Here, case inputs determine the expected listener identities before the log is compared, and each row is then checked for target, phase, payload, and whether the internal button appears in its path. Complete path arrays are logged for separate comparison; the embedded assertion does not check every path entry. A reviewer can therefore inspect which invariant failed rather than accepting a single aggregate boolean.
Keep listener registration fixed across cases
Do not “help” a failing case by changing callbacks while you change flags. The fixture registers the same listener pattern for every dispatch and uses AbortController only to isolate one case from the next. Listener lifetime is therefore controlled, not investigated.
That matters because a missing callback can otherwise be misdiagnosed as a binding problem. Here, later positive cases prove that the outer listeners exist. The controlled variables are origin node, shadow mode, bubbles, and composed; handler identity, markup copying, and cleanup timing remain fixed.
Save the following as shadow_lab.py in a fresh working directory, using UTF-8, LF line endings, and one final newline. Python, the Playwright Python package, and a runnable Chromium executable must already be installed. With those exact source bytes, the SHA-256 is f662af86bf9a89e3c0cc9ebdadf0b781afc153d9203e48272ac3f6743f69381a.
from pathlib import Path
from datetime import datetime, timezone
import hashlib, json, platform, sys, tempfile, importlib.metadata
from playwright.sync_api import sync_playwright
JS = r'''() => {
const require = (x, m) => { if (!x) throw new Error(m); };
const cases = [];
function run(id, mode, bubbles, composed, onHost=false) {
const host = document.createElement('div'); host.id = 'host';
document.body.append(host);
const root = host.attachShadow({mode});
const button = document.createElement('button'); button.id = 'button';
root.append(button);
const abort = new AbortController();
const log = [];
const name = n => n === window ? 'window' : n === document ? 'document' :
n === root ? 'root' : n.id || n.nodeName.toLowerCase();
const listen = (node, label, capture) => node.addEventListener('probe', e => {
log.push({listener:label, phase:e.eventPhase, target:name(e.target),
path:e.composedPath().map(name), detail:e.detail.id});
}, {capture, signal:abort.signal});
for (const [node, label] of [
[document,'document'],[host,'host'],[root,'root'],[button,'button']
]) {
listen(node, label+'-capture', true); listen(node, label+'-bubble', false);
}
try {
const event = new CustomEvent('probe', {bubbles, composed, detail:{id}});
const dispatched = (onHost ? host : button).dispatchEvent(event);
let expected;
if (onHost) expected = [
'document-capture','host-capture','host-bubble',
...(bubbles?['document-bubble']:[])
];
else {
expected = [...(composed?['document-capture','host-capture']:[]),
'root-capture','button-capture','button-bubble',
...(bubbles?['root-bubble']:[]),
...(composed?['host-bubble']:[]),
...(bubbles && composed?['document-bubble']:[])];
}
require(
JSON.stringify(log.map(x=>x.listener))===JSON.stringify(expected),
id+' order');
require(dispatched===true, id+' dispatch return');
for (const row of log) {
const outer = row.listener.startsWith('document') ||
row.listener.startsWith('host');
require(row.target===(onHost || outer ? 'host' : 'button'), id+' retarget');
const phase = row.listener.startsWith('button') ||
row.listener.startsWith('host') ? 2 :
row.listener.endsWith('capture') ? 1 : 3;
require(row.phase===phase, id+' phase');
require(row.detail===id, id+' payload identity');
const shouldSeeButton = !onHost && !(mode==='closed' && outer);
require(row.path.includes('button')===shouldSeeButton, id+' path visibility');
}
cases.push({id,mode,bubbles,composed,onHost,
dispatch_return:dispatched,expected,log,status:'PASS'});
} finally { abort.abort(); host.remove(); }
}
for (const mode of ['open','closed'])
for (const b of [false,true]) for (const c of [false,true])
run${mode}-b${+b}-c${+c},mode,b,c);
run('host-default','closed',false,false,true);
run('host-bubbling','closed',true,false,true);
require(cases.length===10,'coverage');
return cases;
}'''
def main():
folder = Path(tempfile.mkdtemp(
prefix='shadow-evidence-', dir=Path(__file__).parent))
report = {'status':'FAIL','started_utc':datetime.now(timezone.utc).isoformat(),
'python':sys.version,'platform':platform.platform(),
'playwright':importlib.metadata.version('playwright'),
'source_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest()}
try:
with sync_playwright() as p:
browser = p.chromium.launch(
executable_path='/usr/bin/chromium', headless=True,
args=['--no-sandbox'])
try:
report['browser'] = browser.version
page = browser.new_page();
page.set_content('<!doctype html><html><body></body></html>')
report['cases'] = page.evaluate(JS)
finally: browser.close()
report['status']='PASS_10_CASES'
except Exception as exc:
report['error']={'type':type(exc).__name__,'message':str(exc)}
raise
finally:
report['finished_utc']=datetime.now(timezone.utc).isoformat()
path=folder/'results.json'; path.write_text(json.dumps(report,indent=2)+'\n')
print(path)
if name=='__main__': main()Run it from that directory:
python shadow_lab.pyTreat a nonzero exit as a failed run, not as a partial pass. Retain the generated evidence directory before editing the fixture. The runner expects /usr/bin/chromium; select another executable only when it is the real Chromium binary you intend to test, and record the resulting browser version. On an assertion failure, the report records the environment, source hash, and error, but this runner does not retain partially completed case traces. Investigating those traces requires additional diagnostic capture and a separately recorded rerun.
Establish the internal-only dispatch baseline
Begin with open-b0-c0: internal button origin, open root, bubbles: false, composed: false. The observed and expected listener sequence is root-capture, button-capture, button-bubble. Neither host nor document appears. The target is button for all three rows, and the visible path is button → root.
That route follows directly from the platform contract. EventInit defaults bubbles and composed to false, and the shadow root’s parent algorithm returns null for an internal-origin event when the composed flag is unset. No outer registration is missing; the route simply ends at the root.
Now change only bubbling: open-b1-c0. The sequence becomes root-capture, button-capture, button-bubble, root-bubble. The fourth row proves ordinary bubbling inside the shadow tree, yet host and document are still absent. This is the canonical “CustomEvent not crossing shadow root” failure: bubbles: true did exactly what it was asked to do, but composed: false prevented boundary crossing.
The distinction protects you from the wrong repair. The related cloned-node event binding article compares cloned cards and explicit or delegated binding. Its fixture investigates listener registration in light DOM. In this laboratory, listeners remain deliberately fixed while the event route is tested at the shadow boundary. A repair to registration would address a different failure from the one isolated by these controls.
Both baseline cases are supposed to pass their assertions. That does not make either one an acceptable public event for the document bubble consumer. A passing diagnostic test can prove a candidate is unsafe or insufficient for the reviewed integration contract. Preserve that evidence instead of deleting the negative control once you discover the repair.
When debugging a real component, compare the failing production symptom to these controls before touching registration code. If internal root bubbling is present but all host/document rows are absent, the trace resembles b1-c0: investigate composed and the declared origin first. If even the internal button/root sequence is missing, this laboratory no longer describes the failure, and a binding or dispatch-origin problem becomes more plausible. The point of the baseline is diagnostic discrimination, not merely demonstration.
Change composed without assuming ordinary bubbling
The most useful control is open-b0-c1: composed: true, bubbles: false, still dispatched on the internal button. The exact sequence is document-capture, host-capture, root-capture, button-capture, button-bubble, host-bubble. There is no root-bubble and no document-bubble.
That result is why outside capture and outside bubbling belong in separate ledger columns. Composition puts the outside document and host on the route, but the ordinary non-target bubbling pass still respects bubbles: false. The DOM dispatch algorithm runs the capture traversal regardless of the bubbling flag; in its forward traversal it skips ordinary non-target ancestors when bubbles is false.
MDN’s Event.composed page describes composed events as able to cross the shadow boundary and adds the important caveat that capturing-only composed events are handled at the host as if they were in AT_TARGET. Read that implementation-facing summary together with the normative DOM Standard dispatch algorithm. Treating its shorthand “propagation only occurs if bubbles is true” as “no external listener can observe a non-bubbling composed event” would contradict the host caveat and the capture traversal defined by the standard.
For the application in this playbook, open-b0-c1 is still a REPAIR case. The document capture listener proves that the event crossed the boundary, but the named document-bubble consumer did not run. Do not move production logic to capture simply because the diagnostic logger can see the event there; that changes the consumer contract and its ordering semantics.
Read host AT_TARGET entries correctly
The two host rows in a composed internal-origin event both record phase 2, AT_TARGET, even though one registration was created with capture: true and the other with capture: false. The suffixes host-capture and host-bubble describe registration options, not necessarily the numeric phase recorded during invocation.
The DOM Standard’s dispatch steps explain the shape. A path item with a non-null shadow-adjusted target is assigned AT_TARGET; during the later invocation traversal, that target-like item is not skipped merely because bubbles is false. This is why host handling cannot be inferred from document bubbling. A composed non-bubbling event reaches both host registrations at phase 2 while the document bubble registration remains absent.
That is an operationally important negative control. If your only assertion says “document bubble did not run,” you cannot tell a boundary-crossing failure from a legitimate composed-but-non-bubbling route. The per-dispatch ledger resolves that ambiguity by recording document capture, host handling, and document bubbling independently.
It also prevents a misleading repair from passing review. Suppose someone changes the application listener to {capture: true} and reports that the event is now visible at document. That may be a valid new contract, but it is not evidence that the original delegated bubbling contract was repaired. Capture executes on a different part of the route and, for this composed-only case, before the internal target listeners. If application semantics depend on ordering, that distinction is material and should be reviewed rather than hidden behind a green assertion.
Test the public bubbling contract
For an internal-button origin and a non-capture delegated listener on document, the four flag combinations form a complete route matrix in this fixture. The recorded Chromium traces were identical in listener identity and phase for corresponding open- and closed-root cases; closed mode changes external path visibility, not the recipient set in these cases.
Flags | Internal route outcome | Document capture | Host handling | Document bubble | Public contract decision |
bubbles: false, composed: false | Target/root only | No | No | No | REPAIR for public consumer |
bubbles: true, composed: false | Adds root bubbling | No | No | No | REPAIR for public consumer |
bubbles: false, composed: true | Crosses boundary without ordinary outside bubbling | Yes | Yes, phase 2 | No | REPAIR for this consumer |
bubbles: true, composed: true | Full reviewed outside route | Yes | Yes, phase 2 | Yes, phase 3 | Candidate for ACCEPT |
The exact positive sequence for open-b1-c1 and closed-b1-c1 is document-capture(1), host-capture(2), root-capture(1), button-capture(2), button-bubble(2), root-bubble(3), host-bubble(2), document-bubble(3). That order follows the standard’s reverse capture traversal followed by its forward target/bubbling traversal.
This is the minimum routing configuration for the stated consumer because both independent requirements must be satisfied: the event must cross the shadow boundary, and it must participate in ordinary bubbling once outside. composed: true, bubbles: true is therefore not a generic recipe for every custom event. It is the candidate configuration for this public event, origin, and listener mode.
The table also shows why “CustomEvent bubbles composed” should be reviewed as two claims, not one phrase. bubbles determines whether non-target path entries participate in the bubbling traversal; composed determines whether an internal-origin event path can continue from its shadow root to the host. The integration test should preserve cases that prove each responsibility independently.
Inspect the target seen by each listener
Delivery is only half of the contract. The same dispatch exposes different event.target values depending on the listener’s position. In every internal-origin composed case from the controlled run, root and button listeners see target: 'button', while host and document listeners see target: 'host'.
That is retargeting, not data loss. The DOM Standard’s retargeting algorithm repeatedly replaces a node inside a shadow tree with that tree’s host when evaluated against an observer outside the relevant shadow tree. Event dispatch uses that retargeting machinery when constructing and invoking its path. The external listener is therefore expected to see the component boundary, not the private inner button.
A consumer that says if (event.target.matches('#button')) has coupled itself to an identity the public route does not expose through target. If the listener did run but then rejected the host because it expected the button, classify that as a consumer-assumption failure, not a delivery failure. REPAIR the consumer or the public payload contract instead of toggling event flags again.
The ledger should keep listener, phase, and target as separate fields. document-capture identifies the callback registration. Phase 1 records where the callback sits in dispatch. Target host records what that observer sees as the adjusted target. Collapsing those facts into one “event reached document” boolean discards the evidence you need to debug the next integration change.
Capture composedPath during dispatch
composedPath() needs the same observer-aware treatment. MDN documents it as the event path visible to the caller and notes that nodes in a closed shadow tree are omitted from the path exposed outside that closed root. The DOM Standard defines the path and then explicitly empties it before dispatch completes.
That is why the fixture maps e.composedPath() to primitive labels inside each listener. Retaining the event object and inspecting composedPath() only after dispatchEvent() returns is too late for this evidence purpose: the normative dispatch steps have already set the event’s path to the empty list.
For an open root and a composed internal event, the observed path at registered listeners is button → root → host → body → html → document → window. With composed: false, it is only button → root in either shadow mode. For a composed event in a closed root, internal listeners still observe the complete path through window, while outside host/document listeners see host → body → html → document → window.
Do not confuse path membership with listener invocation. body, html, and window appear in these path snapshots even though the fixture registers no probe listeners there. Likewise, a document node can be on the path while document-bubble is not invoked for a non-bubbling event. The ledger therefore stores path and delivery separately.
For code review, store the path as labels or other immutable primitives rather than as a collection of live node references. The goal is to preserve what the listener could see at dispatch time, not to create a second API through which tests depend on private DOM objects after the case has been torn down. This fixture removes the host after each case, which makes the timing boundary explicit: the trace is the evidence artifact, not the final document state.
Repeat the matrix with a closed shadow root
Now change only shadow mode. closed-b0-c0, closed-b1-c0, closed-b0-c1, and closed-b1-c1 produce the same listener identity and phase sequences as their open-root counterparts in the recorded Chromium run. In particular, closed-b1-c1 still reaches document capture, both host listeners, and document bubbling.
That is consistent with the DOM Standard: for this internal-origin fixture, the composed flag determines whether the event path continues from the shadow root to its host. Closed mode is tracked separately for path visibility. MDN’s composedPath() documentation demonstrates that an outside observer’s path for a closed root omits the inner shadow nodes.
The key comparison is therefore delivery equality versus path-visibility difference. Outside closed-b1-c1, event.target is the host and composedPath() begins at the host. Inside the root and button listeners, the target is still the button and the path still contains the button and root. The event was not suppressed; the external view was filtered.
Do not turn that observation into a security-isolation claim. Closed mode is not being tested here as a protection mechanism, and this fixture does not attempt hostile-code analysis. Its scope is only event delivery and the path visible to registered listeners. A public composed event can cross a closed shadow boundary, so “closed” must not be used as shorthand for “external listeners cannot receive it.”
The acceptance rule should therefore avoid assertions such as “closed root means the event stays private.” Instead, express privacy in terms of the event contract itself: a local-only signal has composed: false and negative outer-recipient assertions; a reviewed public signal may be composed even when the component uses a closed root. Shadow mode and event exposure solve different problems in this test. Keeping them separate also makes a future refactor from open to closed mode easier to evaluate: recipient expectations can remain stable while path-visibility expectations change.
Publish a stable payload instead of an internal node
A reusable component needs an identifier contract that survives retargeting and internal refactors. In the fixture, detail: {id} is deliberately tiny. Every invocation records e.detail.id, and the runner then checks that each recorded value equals the case identifier after dispatch. The same scalar value therefore joins the route rows without requiring an outside consumer to discover the internal button.
The browser API supports exactly that separation: CustomEvent accepts the normal event options plus a detail value, and that value is exposed to event handlers as CustomEvent.detail. For a real component, use a domain-level payload such as { componentId: 'picker-A', itemId: 'item-17' }, then specify which field the application should use and what each identifier means.
Treat those identifiers as an explicit schema. A public contract might require a non-empty itemId, identify the component instance with componentId, and prohibit consumers from depending on private node IDs. Test the exact expected values at the named consumer, not merely detail != null. A route can be perfect while the payload names the wrong item.
That discipline also applies to explicit browser payload contracts: declare the expected data separately from visual or incidental browser state. The FormData example examines control eligibility, submitter identity, and payload timing. Here, the separate questions are which listener receives the component event and whether its public identifier is correct. A payload check should identify the missing or incorrect field rather than report only that some object arrived.
Separate event origin from public payload identity
Keep three identities distinct in review notes. Origin is the node on which code called dispatchEvent(); for the main matrix, that is the internal button. Observed target is the retargeted node exposed to a listener; outside the shadow tree, that is the host. Public payload identity is the application identifier carried in detail; it should describe the component-level action in terms the consumer is allowed to know.
That separation prevents a common false repair. If document-bubble receives the event with target === host, nothing is wrong merely because the original button is hidden behind retargeting. The consumer should use the documented payload for itemId rather than infer business identity from private markup.
Payload ownership must also be decided. This fixture proves equality of a scalar copied into each log row; it does not decide whether a production detail object is mutable, who may mutate it, or whether the component retains references after dispatch. If those questions affect correctness and nobody owns the answer, mark the release decision HOLD even when route assertions pass.
For a practical schema review, write two examples: one payload the consumer must accept and one it must reject. Then test those values at the actual application listener, not only at an internal component callback. That catches a subtle class of integration defects in which routing is correct but the public identifier is populated from the wrong internal record. The event route and the payload schema should be independently green before the public contract can move to ACCEPT.
Compare dispatching from the host itself
The final two cases change the origin rather than the flags. They create the same shadow tree but call dispatchEvent() on the host. This comparison is essential because it demonstrates that composed is about crossing a shadow boundary, not about granting an event general permission to reach document.
host-default uses bubbles: false, composed: false. It still invokes document-capture(1), host-capture(2), and host-bubble(2). The path is host → body → html → document → window. The event starts in the document tree, so no shadow boundary needs to be crossed on its upward route.
host-bubbling changes only bubbles to true. The sequence adds document-bubble(3). composed remains false, yet the application-level bubble listener receives the event because the host is already outside the shadow tree. This matches the shadow-root parent rule: the composed flag constrains continuation from the shadow root for an internal-origin event; it is not a prerequisite for ordinary ancestry when the origin is the host.
Do not convert this control into a universal redesign prescription. Publishing from the host can be a coherent component contract, but it changes the declared origin and therefore deserves its own review. A component that intentionally dispatches a public composed event from an inner control is not automatically wrong.
Also keep host-origin publication separate from dispatching the same event twice. Every case here constructs one CustomEvent and dispatches it once. A forwarding design that receives an internal event and then emits a second public event introduces duplication and mapping decisions that this fixture does not test.
Reconcile the observed route with the consumer contract
A useful evidence row is not just “listener fired.” Join the case inputs and observations: case ID, shadow mode, origin, bubbles, composed, dispatch return, listener identity, phase, target, path, and payload ID. Then compare the ordered recipient set to an independently declared oracle.
For closed-b1-c1, for example, the required document-bubble row is phase 3, target host, detail closed-b1-c1, with the outside-visible path host → body → html → document → window. The root rows in the same dispatch instead see target button and the internal path. Those different views are simultaneously correct.
Check missing and unexpected recipients separately. A missing document-bubble entry breaks the stated public consumer requirement. An unexpected document entry in an internal-only case breaks the negative exposure requirement. A mere row count can miss both defects if one wrong listener substitutes for another.
Preserve the expected route before changing code. When a mismatch occurs, first save the failure with source hash and environment metadata. Then decide whether the implementation is wrong, the consumer assumption is wrong, or the contract itself was underspecified. Editing the oracle until it matches the current output destroys the distinction between evidence and expectation.
A useful review format is one row per dispatch followed by one row per listener invocation. The dispatch row contains source hash, environment, origin, flags, and boolean return. Invocation rows contain listener, phase, target, path, and payload. That normalization makes two independent failures visible: “dispatch completed but required listener absent” and “listener present but target/payload wrong.” An aggregate PASS field can summarize those checks, but it should never replace the underlying identities and values.
Keep dispatch success separate from consumer acknowledgement
dispatchEvent() is synchronous: its applicable handlers run before the method returns. Its boolean return is false when the event’s canceled flag is set; otherwise it is true. The DOM Standard defines that cancellation-based return contract. Listener exceptions are reported separately and do not propagate to the dispatchEvent() caller, so a true return also does not certify that application handlers completed without errors.
The laboratory does not set cancelable, so all ten cases return true, including internal-only cases where no host or document listener runs. That is the precise misleading success signal this playbook is designed to neutralize. true means the dispatch was not canceled under this contract; it is not an acknowledgement from document-bubble, and it says nothing about later business work.
Keep four claims separate in integration review: dispatch completed; the named consumer ran; the consumer interpreted the payload correctly; and the application completed the intended action. This fixture verifies synchronous dispatch completion, the named diagnostic listener’s delivery, and equality of the synthetic payload identifier. Application interpretation and the intended action require separate integration assertions. Any asynchronous application effect also requires its own observation.
Repair the boundary without exposing every internal event
Repair from the reviewed failure, not from a generic event recipe. If the approved public event originates on the inner button and the application requires document bubbling, change the event to bubbles: true, composed: true. Preserve the prior b1-c0 trace so the repair has a documented before state: internal bubbling worked, boundary crossing did not.
If the event already reaches the consumer but the callback expects event.target to be the button, repair the consumer assumption. The outside target should be the host under this route; the public detail payload should carry the identifier the application actually needs. The standard’s retargeting rules define this behavior, so changing the flags would not repair the mistaken target assumption.
If an event is intentionally private, do the opposite: keep a negative test proving that host and document do not receive it. Do not apply composed: true to every internal notification merely because one event must be public. Public exposure and local implementation signaling are different interface decisions.
The fixture’s AbortController isolates the cases by aborting every registration after dispatch. For the separate problem of mounting, disposal, and remounting, listener identity and cleanup covers callback identity, capture matching, and teardown. Keep those lifecycle checks separate from this shadow-boundary routing matrix so a failure can be assigned to the contract that actually changed.
Preserve failure evidence before repair. Keep the original results.json, the source hash, and environment fields. If you edit the laboratory, record a new hash and rerun; do not attach an old passing report to revised code. A reproducible failure is more valuable than a green aggregate that no longer corresponds to the candidate under review.
Keep the repair as small as the failed invariant. A missing boundary crossing calls for a composition decision; a missing outside bubble callback calls for a bubbling decision; a wrong external target assumption calls for a consumer fix; a wrong detail value calls for a payload fix. Combining all four into one refactor makes the before/after evidence harder to interpret and increases the chance that an unrelated change masks the original defect.
Choose accept, repair, rerun or hold
The test status and the release decision are intentionally different. PASS_10_CASES means all ten diagnostic cases matched their expected traces. It does not mean ten candidates are approved for production. Most of the matrix exists to prove routes that the public consumer must not mistake for success.
Decision | Use it when | Required action |
ACCEPT | The reviewed event origin, flags, recipients, phases, target view, payload, and required integration behavior match, with required browser evidence present. | Approve that named public-event contract and retain its evidence. |
REPAIR | Flags select the wrong route, the consumer assumes an internal target, or payload content violates the agreed schema. | Preserve the failure, fix the responsible side, and rerun the affected contract. |
RERUN | A required browser/version is untested, source changed, version metadata is missing, or the command failed. | Execute the controlled protocol in the required real browser and record new evidence. |
HOLD | Payload ownership, permitted exposure, consumer semantics, or another integration requirement is ambiguous. | Resolve ownership or requirements before approval. |
For the specific internal-button/document-bubble contract, b1-c1 is the routing candidate for ACCEPT. b0-c0, b1-c0, and b0-c1 remain essential regression controls. host-bubbling demonstrates a different origin contract and should not be substituted for an implementation that actually dispatches on the button.
Assign responsibility on both sides of the component
The component owner approves the event name, dispatch origin, exposure, flags, and payload semantics. The application owner approves listener placement, capture mode, payload validation, and the action taken when the event arrives. Put both owners on the interface review.
This division makes ambiguous cases easier to classify. If the component team cannot say whether outside capture is acceptable, HOLD. If the application requires Firefox or Safari evidence and only Chromium evidence is available, RERUN in those required environments. If the document listener expects the inner button despite the approved retargeting contract, REPAIR the consumer.
Do not downgrade missing integration requirements into a browser test detail. A perfect route cannot answer an undefined question such as who owns mutations to a complex detail object or whether two application listeners are both permitted to act. Those are contract decisions first, test assertions second.
Add regression cases before releasing the component
Keep the eight internal-button cases and two host controls together. Their value comes from contrast. The bubbles-only cases prove that internal bubbling does not imply shadow-boundary crossing. The composed-only cases prove that outside capture and host handling can occur without document bubbling. The host controls prove that an event already originating in the document tree can reach document without composed: true.
Version additional evidence separately. If Firefox or WebKit becomes part of the support requirement, run the same oracle in those engines and record exact versions rather than translating the Chromium result into a cross-browser claim. A local Chromium pass establishes implementation behavior in that build; it does not establish universal browser compatibility.
Likewise, create new contracts before adding conditions omitted by this fixture. stopPropagation(), slots, nested shadow roots, framework adapters, and listener cancellation can change the route or interpretation. Folding them into this baseline without separate expectations would make the evidence harder to audit.
The test also does not model trusted user activation. It constructs a synthetic CustomEvent and invokes dispatchEvent() synchronously; MDN distinguishes script-dispatched events from browser-fired native events and documents that dispatchEvent() invokes applicable listeners synchronously. Do not use this trace to claim native click activation behavior, user-activation state, or security properties.
For the actual application integration, add a second-level test that installs the real delegated listener and checks the agreed public event name and exact payload. Keep the native routing matrix as the platform oracle, then test application action semantics separately. That layering makes a regression easier to assign: platform route, component publication, or consumer logic.
Finally, preserve negative evidence. A future refactor that accidentally changes composed to false should make b1-c1 fail and resemble the preserved b1-c0 route. That is far more informative than a single positive test that reports only “listener count was one.”
Make browser evidence additive rather than substitutive. When a second engine is tested, keep its report alongside Chromium’s instead of replacing the earlier record. If the traces diverge, mark the affected requirement RERUN or HOLD while the difference is investigated; do not average the outputs into a generic cross-browser statement. The same rule applies when a browser version changes materially: preserve the old evidence, run the same oracle, and record the new environment as a distinct observation.
Develop component-contract skills with the FrontEnd Developer Program
The transferable engineering skill is not memorizing two booleans. It is learning to turn a browser integration requirement into a small, auditable contract: declare the origin and consumer, hold listener registration fixed, record exact recipients and phases, distinguish retargeted target from payload identity, and preserve negative controls that explain why a candidate fails.
Those habits build on frontend development foundations such as JavaScript, browser debugging, and testing. The broader foundation helps an engineer inspect real browser state and translate an integration requirement into an observable check. This playbook applies those skills to a deliberately narrow question: whether the declared component event reaches its named consumer with the expected public data.
For broader frontend foundations, review Refonte Learning’s FrontEnd Developer Program, whose published curriculum lists HTML/CSS, JavaScript ES6+, React, Redux, APIs and AJAX, Git, accessibility, performance optimization, and a capstone project. These topics provide relevant grounding for component and integration work. The published curriculum does not specify the exact Shadow DOM laboratory, CustomEvent matrix, or Playwright workflow used here.
