WICG / WICG/declarative-partial-updates
Expose `<template for>` lifecycle events and timing
Nobody has claimed this yet.
- Dominant language
- Bikeshed
- Stars
- 135
- Forks
- 13
- Avg merge
- 12m
- Merged PRs (30d)
- 3
Description
Summary
The current <template for> proposal has parser-visible lifecycle points: recognition of a patch attempt, target resolution, fallback clearing, streamed-content application, and completion or failure. Today there is no patch-specific way to observe those points or correlate them.
Could declarative partial updates expose a small live lifecycle API, plus a PerformanceEntry for diagnostics?
This issue is only about observation. Paint holding, morphing, framework hydration, retry, drafts, and navigation remain separate.
Why this matters
For a streaming page with several independently updated regions, application code needs to know:
- Was the target found?
- When did fallback disappear?
- When did replacement content begin?
- How did the patch settle?
- Which observations belong to the same patch?
None of the existing APIs answers those questions directly. MutationObserver sees the resulting DOM changes, not parser intent or terminal state. Document lifecycle events are too coarse. User Timing marks measure surrounding script, not parser work. And successful patch templates remain detached, so the template itself is not a useful observation target.
In our streaming UI prototype, we ended up building our own document-level patch feed, then wiring hydration, replay, and debugging to it. A bootstrap scan can adopt content that is already present, but it cannot recover the original arrival, phase, or terminal timing. The parser already knows those facts.
Reduced scenario
<section>
<?start name="results">Loading…<?end>
</section>
<!-- streamed later -->
<template for="results">
<p>First result</p>
<?marker name="results">
</template>
<template for="results">
<p>Second result</p>
</template>
A client-side enhancer can see nodes change, but not which patch caused the change, whether target lookup succeeded first, or how the patch settled. Those are patch lifecycle facts, not general DOM mutation facts.
Minimal lifecycle
For the current <template for> proposal, v1 can stay small:
discovered: the parser recognizes a<template for>patch candidate in a context where patching is enabled, before target lookuptarget-found: target lookup succeedsfallback-removed: fallback content is cleared, when applicablecontent-started: the first parser-attributable patch-body mutation reaches the live target, when applicablesettled: the patch reaches a parser-observed terminal state
We would likely leave stale/superseded/coalesced states, sequencing, and transaction boundaries out of v1. The current proposal does not define them.
Possible shape
The parser already knows one set of lifecycle facts for each patch attempt. The smallest shape could have two outputs:
- live lifecycle events on the relevant tree root; and
- one terminal
PerformanceEntryfor metrics and debugging.
The PerformanceEntry is telemetry only. PerformanceObserver delivery is delayed, buffers are finite, and entries may be dropped; it cannot drive hydration or recovery. See Performance Timeline.
Neither output gives a late subscriber an authoritative lifecycle/status view. Buffered PerformanceEntrys can provide bounded, lossy diagnostics, but must not fill that role. v1 should say that plainly and leave bounded status/replay to a follow-up. If replay is later added, it should retain immutable metadata/status only, report dropped or overflowed records, and avoid retaining DOM nodes or raw inserted roots.
Lifecycle checkpoints
The current explainer and open HTML PR already have candidate checkpoints: patch preparation, target lookup, fallback clearing, incremental DOM mutation, and close. The table maps them to event phases and timing fields. See the current patching explainer and WHATWG PR #11818.
| Phase | Meaning | Timing field |
|---|---|---|
discovered |
The parser recognizes a <template for> patch candidate in a context where patching is enabled, before target lookup. |
startTime |
target-found |
Marker/range lookup succeeds and the insertion target is recorded. | targetFoundTime |
fallback-removed |
The fallback-clear checkpoint runs for a matched range, including an empty range. Absent for a marker-only patch. | fallbackRemovedTime |
content-started |
The first parser-attributable patch-body DOM mutation reaches the live insertion target, including character data appended to an existing Text node. Absent for an empty patch. |
contentStartedTime |
| — | The last parser-attributable patch-body DOM mutation reaches the live target. This is a wall-clock streaming envelope, not a semantic event. | lastContentMutationTime |
settled |
One terminal state is recorded when the parser observes explicit close, preparation/target failure, or EOF before an explicit template close. | duration and outcome |
The event should carry parser facts, not a retained list of inserted nodes. It complements rather than replaces ordinary DOM observation or a separate live tree-discovery primitive for consumers that need actual added or removed roots.
Delivery
- Record during parsing; deliver later. Record immutable lifecycle facts and monotonic checkpoint instants during parsing, then dispatch in FIFO order. When a queued event is created, use the recorded checkpoint as its time of occurrence, not callback-delivery time. Whether delivery uses a microtask, a task, or batching is open.
- Record at most one terminal state. Every patch that reaches a parser-observed terminal path records exactly one
settledphase and queues one corresponding event. An abrupt non-EOF termination has no authoritative terminal event in v1 unless HTML defines a parser-visible abort signal. Delivery can still be lost if the relevant global is discarded before a queued notification runs. - Dispatch on the relevant tree root. Successful templates are detached and markers may disappear, so the stable target in a document tree is the captured
DocumentorShadowRoot. For fragment parsing, the open question is the event target: the returnedDocumentFragment, the temporary parser document, or no lifecycle event in v1. - Preserve shadow encapsulation. Closed-shadow events must not escape the root. Timing entries are global, so closed-root entries need an explicit privacy decision.
- Keep it read-only. Cancellation, sequencing, coalescing, and reconciliation are separate concerns.
Strawman A: lifecycle event
PR #26 proposed a patch event. With marker/range targets, neither the detached template nor a target element is a stable event target, so this draft uses the tree root.
Names are placeholders; "patch" is the placeholder event type to make the relationship to PR #26 explicit:
enum DeclarativePatchPhase {
"discovered",
"target-found",
"fallback-removed",
"content-started",
"settled"
};
enum DeclarativePatchOutcome {
"complete",
"target-not-found",
"eof-before-close"
};
[Exposed=Window]
interface DeclarativePatchLifecycleEvent : Event {
readonly attribute unsigned long long patchId;
readonly attribute DOMString forValue;
readonly attribute DeclarativePatchPhase phase;
readonly attribute DeclarativePatchOutcome? outcome;
};
Semantics:
- Allocate one nonzero, UA-generated opaque
patchIdat discovery time. It correlates the event stream with the timing entry; it is not an application ID or ordering key. - Dispatch a trusted
"patch", non-cancelable, non-bubbling, non-composed event at the capturedDocumentorShadowRoot. - Use the stored checkpoint instant for
timeStamp, not callback-delivery time. - Expose literal
forValueonly on this tree-scoped event. - For each parser-observed terminal path, record exactly one
settledphase and queue one corresponding event.outcomeis non-null only for that event. - A patch attempt begins when the parser recognizes a
<template for>candidate in a context where patching is enabled, before target lookup. An emptyforvalue or failed marker lookup settles immediately astarget-not-found, and a later close or EOF does not replace that outcome. eof-before-closemeans the parser reached EOF without seeing an explicit</template>. Do not split clean EOF from transport abort/error until the parser can distinguish them normatively. See #104.
We can keep late observation out of these two strawmen. #21 is a useful starting point, though its element-scoped currentPatch shape may need adaptation for marker/range targets and late-status lifetime.
Strawman B: terminal PerformanceEntry
Use one terminal entry for each parser-settled patch, not one entry per phase. That keeps cardinality low and avoids a second correlation problem. Navigation Timing and Long Animation Frames already put several timestamps on one terminal entry.
[Exposed=Window]
interface PerformanceDeclarativePatchTiming : PerformanceEntry {
readonly attribute unsigned long long patchId;
readonly attribute DOMHighResTimeStamp? targetFoundTime;
readonly attribute DOMHighResTimeStamp? fallbackRemovedTime;
readonly attribute DOMHighResTimeStamp? contentStartedTime;
readonly attribute DOMHighResTimeStamp? lastContentMutationTime;
readonly attribute DeclarativePatchOutcome outcome;
[Default] object toJSON();
};
patchId is separate from the inherited PerformanceEntry.id because correlation begins at discovery, before a terminal entry is queued.
| Field | Meaning | null when |
|---|---|---|
entryType |
"declarative-patch" |
never |
name |
constant "template-for" |
never |
startTime |
the parser recognizes the patch candidate before target lookup | never |
patchId |
the same opaque key used by lifecycle events | never |
targetFoundTime |
marker/range resolution succeeds | lookup does not succeed |
fallbackRemovedTime |
range fallback clearing runs, including for an empty range | marker-only patch |
contentStartedTime |
the first parser-attributable patch-body DOM mutation reaches the live target | empty, failed, or never-started patch |
lastContentMutationTime |
the last parser-attributable patch-body DOM mutation reaches the live target | empty, failed, or never-started patch |
duration |
terminal checkpoint minus startTime |
never for a queued terminal entry |
outcome |
terminal classification | never for a queued terminal entry |
duration is wall time from discovery to settlement, not DOM-application cost. lastContentMutationTime - contentStartedTime is also a wall-clock envelope, not CPU attribution. Defer applyDuration until it can be defined normatively.
Use nullable milestones rather than 0 sentinels. patchId is scoped to the relevant global and current Document, opaque, not stable across navigation or browsing sessions, and not an ordering key. Its allocation must not let exposed IDs reveal omitted roots or cross-root activity.
All exposed milestones should be relative timestamps for the relevant global produced through HR-Time coarsening, with UA freedom to use stronger rounding or jitter. Delayed or batched delivery reduces attack speed; it does not hide patch existence, count, or outcome.
The entry should be observer-only and bounded:
entryType:"declarative-patch"availableFromTimeline:false- finite
maxBufferSize, perhaps150 should add entry: true for every eligible parser-settled patch, subject to the closed-root exposure decision- delivery through
PerformanceObserver.observe({ type: "declarative-patch", buffered: true })
No entry is queued for a patch that never reaches a parser-observed terminal path.
There should be no duration threshold: short, empty, and failed patches are useful diagnostics. The exact buffer size is less important than keeping buffered first-N rather than an unlimited lifecycle log. See the Timing Entry Names Registry.
Paint/presentation
Presentation timing is useful but harder to define: several patches can land before one render, a patch may never be visible, and presentation timing can add exposure. We should likely leave that out of any v1.
If this is revisited, PaintTimingMixin is relevant prior art, but a future proposal would need separate attribution and privacy analysis before adding any patch-specific paint or presentation signal. It must not imply paint holding. See Element Timing and Paint Timing.
Security, privacy, and cardinality
Same-origin script in an ordinary document or open root can already observe resulting DOM changes. This proposal would additionally expose parser intent, terminal outcome, and tighter timing. It should not expose cross-origin DOM, URLs, content, nodes, or persistent identifiers. Global timing for closed roots and buffered retention remain open privacy questions.
- Emit at most one terminal timing entry per parser-settled patch, never one per inserted node, parser chunk, or lifecycle phase.
- Keep
namefixed. Do not putforValue, selectors, URLs, content, byte counts, node counts, author tokens, operation IDs, or sequence IDs in the timing entry. - Keep
forValueon the tree-scoped event only. Do not expose the detached template or raw target nodes in the timing entry. - Any late replay API should be per-
Documentor tree root, bounded, and immutable, with explicit dropped-record or overflow reporting; it should not retain detached templates, raw roots, or other DOM nodes, and should not persist across browsing sessions. BFCache restore/discard behavior and whether replay retainsforValueneed explicit answers. - Omitting names does not fully settle closed-root privacy: a global entry can still reveal patch count, timing, milestones, and outcome. A
PerformanceEntrycannot be root-scoped; closed-root entries may need to be omitted or intentionally exposed with that leak documented, andpatchIdallocation must not reveal omitted roots through gaps or cross-root ordering.
Out of scope
- author operation/sequence IDs, retry, resume, idempotency, navigation, or turn ownership
- stale, superseded, coalesced, or sealed states
- abort/transport error reasons before the parser can distinguish them
- raw target nodes or retained detached DOM
applyDuration, parse/layout/paint CPU attribution
Related discussions
- #21 discussed patch status reflection and control.
- PR #26 described a
patchevent with MutationObserver/slotchange-like timing. - #104 covers incomplete streamed patches and the lack of a patch-specific terminal signal.
- #37 is about paint holding; this issue is only about observation.
- The patching explainer and WHATWG HTML PR #11818 contain the relevant parser checkpoints. The WHATWG discussion also calls out error notification as a missing piece.
Questions
- Is a root-scoped
"patch"lifecycle event the right direction, and what should its target be for fragment parsing? - What parser-safe delivery timing should events use: MutationObserver/
slotchange-like microtask timing, a task, or batching? - Should v1 say late status/replay is unsupported, leaving bounded metadata replay to a follow-up?
- Are the five phases and three parser-owned outcomes sufficient, including no terminal event for an abrupt non-EOF termination?
- Is one bounded terminal
PerformanceEntrythe right diagnostics shape, should closed-root entries be omitted or intentionally exposed, and how shouldpatchIdallocation avoid leaking omitted roots?
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with patching-explainer.md and WHATWG HTML PR #11818, then compare the lifecycle checkpoints and delivery questions with PR #26 and issues #21 and #104. Done means agreeing on the v1 event and timing API, terminal semantics, root/privacy behavior, and updating the relevant proposal or specification text; no implementation file or test is named.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- html
- Domain
- api, web-dev
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100