Skip to content

Skip shared-timer re-renders whose output cannot change - #397

Closed
mattcosta7 wants to merge 6 commits into
fix-retain-disconnected-elementsfrom
perf-skip-unchanged-ticks
Closed

mattcosta7 wants to merge 6 commits into
fix-retain-disconnected-elementsfrom
perf-skip-unchanged-ticks

Conversation

@mattcosta7

@mattcosta7 mattcosta7 commented Nov 8, 2026 •

Copy link
Copy Markdown
Member

Parked. Five ship-check rounds found that, under a zero-observable-change bar, page code running mid-render (update listeners, instance overrides, prototype patches, custom elements in the shadow root) keeps exposing new cases. The guards for them made inserting elements about 35% slower and elements that never skip 30–60% slower per tick (measured). Before reconsidering this: state an explicit contract (page code must not modify the element class or its shadow DOM during a render) and remove the overhead on paths that never skip. Error isolation moved to #399, directly on #396.

Stacked on #396.

Problem

One element under a minute old makes the shared timer tick every second, and so does any running format="elapsed" timer. Every tick calls update() on every observed element, even when none of their text can change. A page with 500 timestamps a few hours old and one fresh timestamp re-renders all 500 every second for that minute. With an elapsed timer it does so for as long as the timer runs.

Inserted elements also render twice. connectedCallback renders immediately, then the update queued by attributeChangedCallback renders again in a microtask.

Change

After each render, an element records when its text can next change, plus the inputs it was rendered from. The shared timer and the queued attribute update skip the element until then, unless one of those inputs changed. Nothing observable changes:

  • The timer runs on the same schedule.
  • Elements render the same text and title, in the same shadow DOM.
  • relative-time-updated fires at the same moments.
  • Observer membership and the Intl formatter caches end up the same.
  • update() still always re-renders.

The skip only applies to the default relative output with second precision, for times between a minute and 28 days away. Within that range roundToSingleUnit can only change the text when:

  • under an hour, the elapsed seconds reach :55 (rounding up a minute) or :00
  • from an hour, the elapsed time within the hour reaches 54:55 (rounding up an hour) or 00:00
  • the local or UTC calendar date changes
  • the elapsed time reaches threshold

Elements with a time-zone, on themselves or an ancestor, are never skipped. A fixed offset like +00:01 can be any number of minutes, and historical offsets include seconds, so only an Intl lookup could place that zone's midnight. Clocks before 1970 aren't skipped either. github.com sets no time-zone, so this exclusion doesn't affect it.

Before skipping, the element checks that nothing else a render reads has changed:

  • its own attributes (a revision counter)
  • lang, time-zone and hour-cycle on ancestors
  • data-prefers-absolute-time on <html> and <body>
  • languagechange
  • the host time zone and UTC offset, read once per tick and once per microtask checkpoint
  • whether the clock went backwards
  • the shadow DOM state that #updateRenderRootContent checks before writing
  • whether Intl.DateTimeFormat and Intl.RelativeTimeFormat are still present

A render reads the inputs it records when it starts, before it writes to the DOM. So page code that runs while it writes, such as a removed custom element's disconnectedCallback, or an update listener that changes an attribute or an ancestor, still triggers a re-render. The render's own title write doesn't count as an attribute change.

A render's lookups in the shared Intl formatter caches are recorded. A skip repeats them in the same order, so the least-recently-used order, and with it every later eviction, matches a full render. If an entry was evicted, the element renders in full. A lookup the render left missing, such as an invalid lang locale that can't be constructed, repeats as missing, and forces a full render if the entry has appeared since.

These always render as before:

  • elements with a time-zone
  • clocks before 1970
  • any element while RelativeTimeElement.prototype differs from how it was defined: any added, removed or replaced member, such as a getAttribute that would shadow the built-in, a replaced date getter or a wrapped update(). The answer is reused only until page code may have run: the start of a tick, the start and end of each render and after its update event, each refresh outside a tick, and before and after any element with overrides. So changes made by update listeners, overrides or right after inserting an element are caught, and a tick of skipped elements checks once, for about 3 µs.
  • an element is checked for overrides before the timer reads it, so an override that removes itself on that read still renders
  • subclasses
  • instances with an own property that shadows an inherited member
  • duration, elapsed and micro formats
  • the absolute-time preference
  • other precisions
  • times under a minute or 28 days or more away
  • no-title elements, which fire an event on every render

What skipping assumes

Skipping assumes two things:

  • The page doesn't replace members of DOM, Date or Intl built-ins. Changes to RelativeTimeElement.prototype, subclasses and per-instance overrides are detected.
  • The host time zone doesn't change in the middle of a task. Browsers apply time zone changes between tasks; a debugger paused mid-tick is the exception.

Results

Measured with Playwright in a page with 500 elements a few hours old and one element 2 seconds old, over 5 seconds of 1-second ticks:

Chromium before Chromium after WebKit before WebKit after
update() calls 2,505 5 2,505 5
Time inside update() 39.7 ms 1.4 ms 41 ms 1 ms
Renders when inserting the 500 elements 1,000 500 1,000 500

A Chromium CPU profile of the whole tick, including the skip checks, drops from about 17.5 ms to about 9 ms per 8 seconds. A separate warm benchmark with 1,000 stable elements measured 4.6 ms → 1.3 ms per tick. Skipped elements still run getUnitFactor, so the timer schedule is unchanged. They still read their ancestors and render root, so changes there are picked up on the same tick as before.

Reading the host time zone costs one Intl.DateTimeFormat construction per tick: about 23 µs in Chromium and 47 µs in WebKit. On pages with about 10 or fewer ticking elements, that roughly cancels the savings.

Tests

In test/relative-time.js, the suite skipping re-renders that cannot change output covers four kinds of behavior.

Differential tests. These tick the shared timer every simulated second (every 250 ms in one test). After each tick they compare every element with a freshly rendered copy that has the same attributes and ancestors. The windows cover:

  • minute, hour, 21-hour, day, 28-day and threshold boundaries
  • past and future dates
  • every tense
  • custom thresholds, including PT45M30S and PT1H30M
  • local, time-zone and UTC midnights, including a local month end
  • DST changes in New York and Lord Howe, and Chatham's +13:45 offset
  • 40 random dates
  • mid-run changes to ancestor lang, time-zone and hour-cycle, the absolute-time preference, languagechange, the shadow DOM, a custom title, and the clock going backwards

Performance tests. 100 elements three hours old don't re-render while a fresh element ticks every second. An inserted element renders once. Both fail without this change.

API tests. update() always re-renders, and an attribute change always re-renders.

Built bundle. test/bundle.js loads the built dist/bundle.js, renders an element and ticks the shared timer. The first version of this change threw Cannot access "RelativeTimeElement" before initialization there, because the bundle evaluates the computed [refresh] method key before the class binding exists. The source tests didn't catch it.

Regression tests from four rounds of adversarial review and a ship-check. These cover:

  • listeners changing attributes
  • no-title events
  • a changed render root
  • host time zone changes, including with the clock frozen
  • formatter cache order under eviction
  • subclasses and instance overrides
  • getter read counts
  • Intl.RelativeTimeFormat removed at runtime
  • a missing queueMicrotask
  • no cache entries added while working out when text can change
  • crossing midnight in +00:01 and Africa/Monrovia, and UTC midnight with a clock before 1970
  • a prototype getter replaced before the first render, by an update listener during a tick, and right after inserting an element
  • update() wrapped right after inserting an element
  • a getAttribute added to the prototype, and connectedCallback, attributeChangedCallback or the onRelativeTimeUpdated getter replaced
  • an instance override that changes the prototype after calling the inherited update()
  • page code changing datetime while a render repairs the shadow root
  • an override that removes itself when the timer reads it
  • an instance override that removes itself while being read
  • an invalid lang (en_US) still rendering once and skipping

The tests count renders through Element.prototype.hasAttribute('no-title'). Patching update() would change the class and turn skipping off.

Each of these fails without its fix.

test/intl-cache.ts covers recording and repeating lookups.

To check that the tests catch mistakes, I broke the bound or the input checks in 16 ways before the time-zone change. Every one failed at least one test:

  • carry at 56 s
  • hour carry at 55:00
  • no zone quarter hour (now removed, since time-zone elements aren't skipped)
  • no local midnight
  • threshold bound 10 minutes late
  • ancestor lang ignored
  • future-date bound 2 s late
  • host time zone ignored
  • span style ignored
  • attribute changes ignored
  • lookups not repeated
  • subclass and override guards removed
  • Intl presence ignored
  • no-title ignored
  • the guard on recording inputs removed
  • host time zone not re-read each tick

The suite takes about 20 seconds instead of 5.

Command Result
npm test (build, lint, web-test-runner) 715 passed, 0 failed
npx prettier --check src test passes
new suite against the source before this change the 2 performance tests fail

mattcosta7 and others added 2 commits November 8, 2026 11:01
One element under a minute old makes the shared timer tick every second,
and every tick re-rendered every observed element. Each element now
records when its relative text can next change and the inputs it was
rendered from; ticks skip it until then unless an input changed. The
tick schedule, rendered text, titles and events are unchanged.

The same check skips the second render a newly inserted element got
from its queued attribute update. update() still always re-renders.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1. dist/bundle.js threw "Cannot access "RelativeTimeElement" before
   initialization" on the first render. tsc (es2017) hoists a class's
   private methods into the computed `[refresh]` key, which runs while
   the class is being defined, and esbuild turns the class-name reference
   in `#recordRender` into an eager TDZ error. The prototype check now
   lives in a module-level `isPlainElement` that `#recordRender` and
   `[refresh]` share. dist/index.js was unaffected. test/bundle.js loads
   the built bundle, renders an element and ticks the shared timer.

2. `#stableFor` assumed a named zone's date can only change on a UTC
   quarter hour. Fixed offsets such as `+00:01` and historical offsets
   such as Africa/Monrovia's -00:44:30 break that, leaving "yesterday"
   showing after a fresh render says "2 days ago". Elements with a
   resolved time zone are no longer skipped.

3. With a clock before 1970, `now % msPerDay` is negative and the UTC
   midnight bound was too late. Such clocks are no longer skipped.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
mattcosta7 and others added 4 commits November 8, 2026 13:32
…lang

Replacing a RelativeTimeElement.prototype member, such as the date getter,
before any element rendered still let elements skip, so a changed input
went unrendered. Elements now skip only while the prototype has exactly
the members it was defined with, checked once per tick and once per
microtask checkpoint. The tests count renders through hasAttribute so
they no longer change the class themselves.

An invalid lang records a locale lookup that is never cached, so
repeating it always failed and the element never skipped. Lookups the
render left missing now repeat as missing, and force a full render if
the entry has since been cached.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The class check was cached per task, so page code that replaced a
prototype member mid-task, from an update listener during a tick or right
after inserting an element, went unnoticed until the next task. It now
checks the 20 members a render reads on every call (about 1 µs), with no
cache. Whether an element is plain is checked when a render starts as
well as when it records its inputs, so an override that removes itself
while being read can't make the render look skippable. A render that
can't be skipped now always clears #stableUntil, so a clock moving back
can't reuse an older render's inputs.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
… timer reads

Adding a member to RelativeTimeElement.prototype, such as a getAttribute
that shadows the built-in, was not detected, as only the 20 members a
render reads were compared. The prototype's full set of keys is now
compared too. The shared timer read an element's date before checking
for overrides, so an override that removed itself on that read went
unnoticed; the check now comes first.

The class check is reused only until page code may have run: the start
of a tick, the start of each render and after its update event, each
refresh outside a tick, and after any element with overrides. Within a
tick of skipped elements it runs once.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…t writes

The class check compared only the members a render reads, so replacing
another one, such as connectedCallback, went unnoticed. It now compares
every member's descriptor; the check is reused within a tick, so this
costs about 3 µs once per tick.

An instance override could change the prototype after calling the
inherited update(), and page code run while a render writes the DOM,
such as a removed custom element's disconnectedCallback, could change an
input that was then recorded as rendered. Inputs are now read when a
render starts, the render's own title write no longer counts as an
attribute change, and page code is assumed to have run at the end of
every render and after any element with overrides.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@mattcosta7

Copy link
Copy Markdown
Member Author

Closing. The core idea held up: independent differential checks matched main's output across DST, midnights, thresholds and input changes. But five ship-check rounds kept finding new cases where page code runs mid-render, under a zero-observable-change bar:

  • update listeners
  • instance overrides
  • prototype patches
  • custom elements in the shadow root

The guards for those cases made inserting elements about 35% slower, and made elements that never skip 30–60% slower per tick (measured).

If this comes back, start from:

  • Evidence it's needed. Measure the per-second tick cost on a real long-lived page, such as one with a running elapsed timer, before adding complexity.
  • An explicit contract. For example, page code mustn't modify the element class or its shadow DOM during a render, with no added cost on paths that never skip.

The leak fix is #396 and tick error isolation is #399.

@mattcosta7 mattcosta7 closed this Nov 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant