WICG / WICG/declarative-partial-updates

Expose `<template for>` lifecycle events and timing

Open
#110 6 comments 1 reaction 0 assignees View on GitHub

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 lookup
  • target-found: target lookup succeeds
  • fallback-removed: fallback content is cleared, when applicable
  • content-started: the first parser-attributable patch-body mutation reaches the live target, when applicable
  • settled: 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:

  1. live lifecycle events on the relevant tree root; and
  2. one terminal PerformanceEntry for 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 settled phase 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 Document or ShadowRoot. For fragment parsing, the open question is the event target: the returned DocumentFragment, 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 patchId at 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 captured Document or ShadowRoot.
  • Use the stored checkpoint instant for timeStamp, not callback-delivery time.
  • Expose literal forValue only on this tree-scoped event.
  • For each parser-observed terminal path, record exactly one settled phase and queue one corresponding event. outcome is 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 empty for value or failed marker lookup settles immediately as target-not-found, and a later close or EOF does not replace that outcome.
  • eof-before-close means 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, perhaps 150
  • 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 name fixed. Do not put forValue, selectors, URLs, content, byte counts, node counts, author tokens, operation IDs, or sequence IDs in the timing entry.
  • Keep forValue on 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-Document or 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 retains forValue need explicit answers.
  • Omitting names does not fully settle closed-root privacy: a global entry can still reveal patch count, timing, milestones, and outcome. A PerformanceEntry cannot be root-scoped; closed-root entries may need to be omitted or intentionally exposed with that leak documented, and patchId allocation 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

Questions

  1. Is a root-scoped "patch" lifecycle event the right direction, and what should its target be for fragment parsing?
  2. What parser-safe delivery timing should events use: MutationObserver/slotchange-like microtask timing, a task, or batching?
  3. Should v1 say late status/replay is unsupported, leaving bounded metadata replay to a follow-up?
  4. Are the five phases and three parser-owned outcomes sufficient, including no terminal event for an abrupt non-EOF termination?
  5. Is one bounded terminal PerformanceEntry the right diagnostics shape, should closed-root entries be omitted or intentionally exposed, and how should patchId allocation avoid leaking omitted roots?

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.