Add DOMContentLoaded, load, and readystatechange events with correct readyState transitions (loading→interactive→complete). Includes Window as a first-class event target, body onload spec quirk, and idempotency guards to prevent double-firing. Code review hardened the API surface by enforcing forward-only state transitions, eliminating a redundant wrapper function, and requesting a redraw after load handler DOM mutations. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
14 KiB
Story 2.7: Document Lifecycle
Status: done
Story
As a web developer using JavaScript, I want document lifecycle events to fire at the correct times, So that scripts can initialize at the right point in page loading.
Acceptance Criteria
-
DOMContentLoaded fires after parsing: A page with a
DOMContentLoadedevent listener ondocumentfires the event after the HTML is fully parsed (but before external resources like images are fully loaded). (WHATWG HTML §8.4) -
Window load event fires after all resources: A page with a
loadevent listener onwindowfires the event after all resources (images, stylesheets, scripts) have finished loading. -
document.readyState transitions correctly: JavaScript reading
document.readyStategets"loading"during parsing,"interactive"after parsing completes (before load), and"complete"after all resources load. -
readystatechange event fires on transitions: A
readystatechangeevent listener ondocumentreceives an event each timedocument.readyStatechanges (loading→interactive and interactive→complete). -
Integration tests verify event timing and readyState transitions, checklist is updated, and
just cipasses.
Tasks / Subtasks
-
Task 1: Add readyState property to Document (AC: #3)
- 1.1 Add a
ready_statefield toDocumentincrates/dom/src/document.rs - 1.2 Initialize
ready_state: ReadyState::LoadinginDocument::new() - 1.3 Add
pub fn ready_state(&self) -> ReadyStategetter andpub fn set_ready_state(&mut self, state: ReadyState)setter - 1.4 Expose
document.readyStatein JavaScript viaDomHost::get_property()for"Document"type - 1.5 Unit tests: readyState default is "loading", setter works, JS property access returns correct string
- 1.1 Add a
-
Task 2: Add dispatch_document_event to WebApiFacade (AC: #1, #2, #4)
- 2.1 Add
dispatch_lifecycle_event()method toWebApiFacade— fires listeners directly on target without DOM propagation - 2.2 DOMContentLoaded fires on
EventTargetId::Document(bubbles: true, cancelable: false) - 2.3 Load fires on
EventTargetId::Window(bubbles: false, cancelable: false) - 2.4 Readystatechange fires on
EventTargetId::Documenton each transition - 2.5 Added
invoke_listeners_simple()public wrapper inevent_dispatch.rsfor direct-target events - 2.6 Unit tests: verify DOMContentLoaded fires on document, load fires on window, readystatechange fires on document
- 2.1 Add
-
Task 3: Wire lifecycle events into the page-load pipeline (AC: #1, #2, #3, #4)
- 3.1 Updated pipeline in
event_handler.rswith Phase 4 (interactive) and Phase 6 (complete) lifecycle events - 3.2 Used swap_document pattern for load event after rendering
- 3.3 Added
fire_dom_content_loaded()andfire_load_event()toBrowserRuntime - 3.4
fire_dom_content_loaded()called after scripts + inline handlers, before rendering - 3.5
fire_load_event()called after rendering completes
- 3.1 Updated pipeline in
-
Task 4: Handle Window as an event target (AC: #2)
- 4.1 Added
Windowvariant toEventTargetIdenum - 4.2
addEventListener/removeEventListeneron Window works viaevent_target_id_for()mapping - 4.3
<body onload="...">now registers onEventTargetId::Windowinstead of body node - 4.4 Unit tests: addEventListener on window for "load" event registers correctly
- 4.1 Added
-
Task 5: Tests and documentation (AC: #5)
- 5.1 Integration test: DOMContentLoaded fires after scripts (verifies state=["script:loading","dcl:interactive"])
- 5.2 Integration test: load fires after DOMContentLoaded (verifies order and readyState values)
- 5.3 Integration test: readystatechange fires on each transition (verifies ["interactive","complete"])
- 5.4 Integration test: inline
<body onload="...">fires as window load event - 5.5 Updated
docs/HTML5_Implementation_Checklist.md— Phase 4 items checked off - 5.6 Updated
docs/DOM_Implementation_Checklist.md— readyState, DOMContentLoaded, readystatechange checked off - 5.7
just cipasses with all tests green
Dev Notes
Event System Infrastructure (What Exists)
The event system has solid foundations:
DomEventstruct incrates/web_api/src/event.rs— event_type, target, phase, bubbles, cancelable, defaultPrevented, propagationStoppedEventTargetIdenum incrates/web_api/src/event_target.rs— currentlyNode(NodeId)andDocumentvariantsdispatch_event()incrates/web_api/src/event_dispatch.rs— full propagation (target → bubble) through DOM tree. Takes aNodeIdtarget and builds path to DocumentEventListenerRegistry— addEventListener/removeEventListener with deduplication and snapshot-based dispatchinstall_inline_event_handlers()— scanson*attributes and registers listenersdispatch_click()onWebApiFacade— usesdispatch_event()for node-targeted events
Key Design Decision: Direct-Target Events vs DOM Propagation
DOMContentLoaded, load, and readystatechange are NOT standard DOM propagation events. They do NOT bubble through DOM element nodes:
- DOMContentLoaded fires on
document(bubbles: true, but only within document, not through element tree) - load fires on
window(bubbles: false) - readystatechange fires on
document(bubbles: false)
The existing dispatch_event() function expects a NodeId target and builds a propagation path through DOM elements. For lifecycle events, you need a simpler dispatch that fires listeners directly on a specific EventTargetId without building a DOM propagation path.
Recommended approach: Add a dispatch_simple_event() function that:
- Takes
EventTargetId+ event type + bubbles/cancelable flags - Gets listeners from the registry for that target
- Invokes them directly (no propagation path needed)
- Drains microtask queue after dispatch
What NOT to Change
- Do NOT modify the existing
dispatch_event()propagation logic — it works correctly for node-targeted events like click - Do NOT add resource loading tracking — for now,
loadfires after rendering is complete. True resource-complete tracking (waiting for all images/stylesheets) is a future optimization. The key behavior is that load fires AFTER DOMContentLoaded. - Do NOT implement
beforeunloadorunloadevents — those are navigation events, not document lifecycle - Do NOT implement
document.DOMContentLoadedas a property — it's an event, not a property - Do NOT modify the HTML parser or tree builder
Architecture Constraints
- Layer 0 (
dom): ReadyState type and Document field addition - Layer 1 (
web_api): Event dispatch for lifecycle events, JS property binding for readyState - Layer 2 (
browser_runtime): Public API methods for lifecycle events - Layer 3 (
app_browser): Wire lifecycle events into page-load pipeline - No unsafe — enforced by CI
- No upward dependencies —
domdoes not importweb_api; readyState is a DOM concept, not a web_api concept
<body onload> Spec Quirk
Per HTML spec, the onload attribute on <body> is a window event handler, not a body event handler. The current install_inline_event_handlers() registers it on EventTargetId::Node(body_id), which is incorrect. It should register load from <body onload="..."> on EventTargetId::Window (or the equivalent). This is a known browser quirk that's important for compatibility.
Similarly, onerror, onfocus, onblur, onscroll, and onresize on <body> are also window event handlers per spec. For this story, only handle onload on <body> — the others can be addressed later.
Key Files to Modify
| File | Change |
|---|---|
crates/dom/src/document.rs |
Add ReadyState enum, ready_state field, getter/setter |
crates/web_api/src/event_target.rs |
Add Window variant to EventTargetId if needed |
crates/web_api/src/lib.rs |
Add dispatch_document_event() / dispatch_window_event(), expose readyState JS property |
crates/web_api/src/dom_host/host_environment.rs |
Add readyState property for Document type |
crates/browser_runtime/src/lib.rs |
Add fire_dom_content_loaded(), fire_load_event() |
crates/app_browser/src/event_handler.rs |
Wire lifecycle events into pipeline (lines 143-178) |
docs/HTML5_Implementation_Checklist.md |
Check off Phase 4 items |
docs/DOM_Implementation_Checklist.md |
Check off readyState, DOMContentLoaded, readystatechange |
Key Files to Read (Reference)
| File | Why |
|---|---|
crates/web_api/src/event.rs |
DomEvent struct, EventPhase |
crates/web_api/src/event_dispatch.rs |
Existing dispatch_event() — reference for how listeners are invoked |
crates/web_api/src/event_target.rs |
EventTargetId, EventListenerRegistry, EventListener |
crates/web_api/src/lib.rs |
WebApiFacade — dispatch_click pattern, install_inline_event_handlers |
crates/browser_runtime/src/lib.rs |
BrowserRuntime public API (dispatch_click pattern to follow) |
crates/app_browser/src/event_handler.rs |
Page-load pipeline (lines 143-178) |
Previous Story Intelligence
From Story 2.6 (Script Loading):
- The page-load pipeline in
event_handler.rs(lines 143-178) is the critical integration point - Script execution happens after parsing, before rendering — lifecycle events slot between these phases
- The
runtime.prepare_for_navigation()→set_document()→ execute scripts →take_document()pattern must be preserved
From Story 2.5 (DOM Query APIs):
- JS property binding follows established pattern in
host_environment.rsget_property()dispatch onobj_type - Adding
"readyState"follows the same pattern as other Document properties
From Epic 1:
- Always update both checklists (HTML5 and DOM) when applicable
- Code review catches edge cases — be thorough with event ordering
Testing Strategy
- Unit tests in
crates/dom/src/document.rsfor ReadyState type and Document property - Unit tests in
crates/web_api/src/lib.rsfor lifecycle event dispatch - Integration tests using JS scripts that register event listeners and record execution order via array pushes
- Key test pattern: Scripts register listeners that push strings to a global array. After page load, verify the array contains the expected sequence.
Spec References
- WHATWG HTML §8.4 — Document lifecycle
- WHATWG HTML §7.1 — Document readiness states
- DOM §2.7 — Event dispatch
References
- [Source: crates/web_api/src/event.rs] — DomEvent struct, EventPhase enum
- [Source: crates/web_api/src/event_dispatch.rs] — dispatch_event(), build_propagation_path()
- [Source: crates/web_api/src/event_target.rs] — EventTargetId enum, EventListenerRegistry
- [Source: crates/web_api/src/lib.rs] — WebApiFacade (dispatch_click, install_inline_event_handlers)
- [Source: crates/browser_runtime/src/lib.rs] — BrowserRuntime public API
- [Source: crates/app_browser/src/event_handler.rs] — Page-load pipeline (lines 143-178)
- [Source: crates/dom/src/document.rs] — Document type
- [Source: docs/HTML5_Implementation_Checklist.md] — Phase 4: Core Document Lifecycle
- [Source: docs/DOM_Implementation_Checklist.md] — §3.4 Document Lifecycle Events
Dev Agent Record
Agent Model Used
Claude Opus 4.6 (1M context)
Debug Log References
No errors or blocking issues encountered.
Completion Notes List
- Implemented
ReadyStateenum (Loading/Interactive/Complete) in thedomcrate withas_str()method - Added
Windowvariant toEventTargetIdenum, enabling window as a first-class event target - Created
dispatch_lifecycle_event()onWebApiFacadefor direct-target events (no DOM propagation) - Added
invoke_listeners_simple()public wrapper inevent_dispatch.rs - Wired
fire_dom_content_loaded()andfire_load_event()throughBrowserRuntimeinto the page-load pipeline - Fixed
<body onload>spec quirk: now registers onEventTargetId::Windowper HTML spec - Exposed
document.readyStateas a readable JS property - All acceptance criteria satisfied with 5 unit tests + 4 integration tests + 8 lifecycle unit tests
File List
crates/dom/src/document.rs— AddedReadyStateenum,ready_statefield, getter/settercrates/dom/src/lib.rs— Re-exportedReadyStatecrates/dom/src/tests/mod.rs— Addedready_state_testsmodulecrates/dom/src/tests/ready_state_tests.rs— NEW: 5 unit tests for ReadyStatecrates/web_api/src/event_target.rs— AddedWindowvariant toEventTargetIdcrates/web_api/src/event_dispatch.rs— Madeinvoke_listeners()pub(crate)for lifecycle event dispatchcrates/web_api/src/lib.rs— Addeddispatch_lifecycle_event(),fire_dom_content_loaded(),fire_load_event(), body onload Window fix, 8 lifecycle unit testscrates/web_api/src/dom_host/mod.rs— Updatedevent_target_id_for()andevent_target_to_host_object()for Windowcrates/web_api/src/dom_host/host_environment.rs— Addeddocument.readyStateJS propertycrates/browser_runtime/src/lib.rs— Addedfire_dom_content_loaded()andfire_load_event()forwarding methodscrates/app_browser/src/event_handler.rs— Wired lifecycle events into pipeline (Phase 4 + Phase 6)tests/js_lifecycle.rs— NEW: 4 integration tests for document lifecycleCargo.toml— Added[[test]]entry forjs_lifecycledocs/HTML5_Implementation_Checklist.md— Checked off Phase 4 readiness itemsdocs/DOM_Implementation_Checklist.md— Checked off readyState, DOMContentLoaded, readystatechange
Change Log
- 2026-03-15: Implemented document lifecycle events (DOMContentLoaded, load, readystatechange) with readyState transitions and Window event target support
- 2026-03-15: Code review fixes — removed invoke_listeners_simple wrapper (M1), changed dispatch_lifecycle_event to return () (M2), added window.request_redraw() after load handler DOM mutations (M3), added idempotency guards to lifecycle methods (M4), enforced forward-only readyState transitions (L1), removed no-change file list entry (L2), added TODO for other body-forwarded events (L3), clarified phase numbering comments (L4)