From f5386f267d03ab1e013d585aeb4fe6ca92f0d19c Mon Sep 17 00:00:00 2001 From: Matthew Costabile <8616962+mattcosta7@users.noreply.github.com> Date: Sun, 8 Nov 2026 09:52:58 -0500 Subject: [PATCH 1/6] Skip shared-timer re-renders whose output cannot change 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> --- src/intl-cache.ts | 34 +++ src/relative-time-element.ts | 231 +++++++++++++- test/intl-cache.ts | 45 ++- test/relative-time.js | 562 +++++++++++++++++++++++++++++++++++ 4 files changed, 867 insertions(+), 5 deletions(-) diff --git a/src/intl-cache.ts b/src/intl-cache.ts index 9ed1cd98..28502eff 100644 --- a/src/intl-cache.ts +++ b/src/intl-cache.ts @@ -23,6 +23,10 @@ class LRUCache { constructor(private readonly max = 100) {} get(key: string): V | undefined { + if (lookups) { + if (lookups.length < maxLookups) lookups.push([this as LRUCache, key]) + else lookupsOverflowed = true + } const value = this.#map.get(key) if (value !== undefined) { this.#map.delete(key) @@ -46,6 +50,36 @@ class LRUCache { const registeredCaches: Array> = [] +export type Lookups = Array<[LRUCache, string]> + +// Lookups made while rendering an element. Repeating them when the element skips a render +// leaves every cache in the same least-recently-used order as a full render would. +let lookups: Lookups | null = null +let lookupsOverflowed = false +// A render makes a handful of lookups. The cap bounds a recording left running by a render that +// threw; an overflowing recording is discarded. +const maxLookups = 64 + +export function startLookups(): void { + lookups = [] + lookupsOverflowed = false +} + +export function stopLookups(): Lookups | null { + const result = lookupsOverflowed ? null : lookups + lookups = null + return result +} + +// Repeats recorded lookups in order. Returns false at the first entry no longer cached, which +// a full render would have to recreate. +export function repeatLookups(recorded: Lookups): boolean { + for (const [cache, key] of recorded) { + if (cache.get(key) === undefined) return false + } + return true +} + // Create a bounded cache that participates in `clearIntlCache` invalidation. export function createCache(max?: number): LRUCache { const cache = new LRUCache(max) diff --git a/src/relative-time-element.ts b/src/relative-time-element.ts index acad5708..53a7e647 100644 --- a/src/relative-time-element.ts +++ b/src/relative-time-element.ts @@ -11,7 +11,15 @@ import { Unit, unitNames, } from './duration.js' -import {dateTimeFormat, relativeTimeFormat, getLocale as intlLocale} from './intl-cache.js' +import { + Lookups, + dateTimeFormat, + relativeTimeFormat, + getLocale as intlLocale, + repeatLookups, + startLookups, + stopLookups, +} from './intl-cache.js' const HTMLElement = globalThis.HTMLElement || (null as unknown as typeof window['HTMLElement']) export type DeprecatedFormat = 'auto' | 'micro' | 'elapsed' @@ -101,15 +109,69 @@ function isBrowser12hCycle(): boolean { return browser12hCycle } +// Incremented on `languagechange`, which can change formatting without any DOM change. +let environmentVersion = 0 + // The resolved hour-cycle preference can change when the host locale changes, so // drop the memoized value on `languagechange` (cached `Intl` formatters are // invalidated separately in intl-cache). if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') { window.addEventListener('languagechange', () => { browser12hCycle = undefined + environmentVersion++ }) } +const msPerSecond = 1000 +const msPerMinute = 60 * msPerSecond +const msPerHour = 60 * msPerMinute +const msPerDay = 24 * msPerHour +const msPerQuarterHour = 15 * msPerMinute + +// Host settings that change formatting without a `languagechange` event. The host reports +// changes between tasks, so they are read once per tick and once per microtask checkpoint. +// Null when they cannot be read, which never matches. +let hostSettingsRead = false +let hostSettings: string | null = null +function forgetHostSettings(): void { + hostSettingsRead = false +} +function getHostSettings(): string | null { + if (!hostSettingsRead) { + try { + queueMicrotask(forgetHostSettings) + hostSettingsRead = true + } catch { + // Without a way to forget them, read them on every call + } + try { + hostSettings = `${new Date().getTimezoneOffset()} ${new Intl.DateTimeFormat().resolvedOptions().timeZone}` + } catch { + hostSettings = null + } + } + return hostSettings +} + +// Names an instance could shadow to change what `update()` reads, such as `date` or `getAttribute` +let inheritedNames: Set | null = null +function shadowsInheritedMember(el: RelativeTimeElement): boolean { + if (!inheritedNames) { + inheritedNames = new Set() + for (let proto = Object.getPrototypeOf(el); proto; proto = Object.getPrototypeOf(proto)) { + for (const key of Reflect.ownKeys(proto)) inheritedNames.add(key) + } + } + for (const key of Reflect.ownKeys(el)) { + if (inheritedNames.has(key)) return true + } + return false +} + +// Re-renders an element unless its output is known to be unchanged. Module-private so the +// public `update()` keeps always re-rendering. +const refresh = Symbol('refresh') + const dateObserver = new (class { elements: Set = new Set() time = Infinity @@ -143,13 +205,16 @@ const dateObserver = new (class { let nearestDistance = Infinity this.updating = true try { + // Discard lookups left recording by a render that threw + stopLookups() + forgetHostSettings() for (const timeEl of this.elements) { if (!timeEl.isConnected) { this.elements.delete(timeEl) continue } nearestDistance = Math.min(nearestDistance, getUnitFactor(timeEl)) - timeEl.update() + timeEl[refresh]() } } finally { this.updating = false @@ -169,8 +234,30 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor #customTitle = false #updating: false | Promise = false + // Until `#stableUntil`, re-rendering produces the same output as long as every input recorded + // here is unchanged. See `#isStable` and `#stableFor`. + #attributeVersion = 0 + #stableUntil = -Infinity + #renderedAt = 0 + #renderedAttributeVersion = -1 + #renderedEnvironment = 0 + #renderedHostSettings: string | null = null + #renderedLookups: Lookups = [] + #renderedLang: string | null = null + #renderedTimeZone: string | undefined + #renderedHourCycle: Intl.DateTimeFormatOptions['hourCycle'] + #renderedRootAbsolute: string | null = null + #renderedBodyAbsolute: string | null | undefined + #renderedText: string | null = null + #renderedAriaHidden: string | null = null + #renderedFontVariantNumeric = '' + + get #rawLang() { + return this.closest('[lang]')?.getAttribute('lang') || this.ownerDocument.documentElement.getAttribute('lang') + } + get #lang() { - const lang = this.closest('[lang]')?.getAttribute('lang') || this.ownerDocument.documentElement.getAttribute('lang') + const lang = this.#rawLang if (!lang) return 'default' try { return intlLocale(lang).toString() @@ -678,6 +765,7 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor // Internal: Refresh the time element's formatted date when an attribute changes. attributeChangedCallback(attrName: string, oldValue: unknown, newValue: unknown): void { if (oldValue === newValue) return + this.#attributeVersion++ if (attrName === 'datetime' && typeof newValue === 'string' && newValue && !isValidDateTimeFormat(newValue)) { // eslint-disable-next-line i18n-text/no-en globalThis.console.warn(`The datetime attribute must use the ECMAScript date-time string format: ${newValue}`) @@ -690,7 +778,7 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor if (!this.#updating && !(attrName === 'title' && this.#customTitle)) { this.#updating = (async () => { await Promise.resolve() - this.update() + this[refresh]() this.#updating = false })() } @@ -702,9 +790,11 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor let newTitle: string = oldTitle const date = this.date if (typeof Intl === 'undefined' || !Intl.DateTimeFormat || !date) { + this.#stableUntil = -Infinity this.#renderRoot.textContent = oldText return } + startLookups() const now = Date.now() // Resolve the locale/time-zone/hour-cycle once per update. Each getter walks // ancestors via `closest(...)`, and they are read by several formatters per @@ -747,6 +837,10 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor this.#updateRenderRootContent(this.textContent) } + // Record the inputs this render used before event listeners can change any of them + const lookups = stopLookups() + this.#recordRender(date, now, format, displayUserPreferredAbsoluteTime, timeZone, hourCycle, newTitle, lookups) + if (newText !== oldText || newTitle !== oldTitle) { this.dispatchEvent(new RelativeTimeUpdatedEvent(oldText, newText, oldTitle, newTitle)) } @@ -762,6 +856,135 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor dateObserver.unobserve(this) } } + + #recordRender( + date: Date, + now: number, + format: ResolvedFormat, + displayUserPreferredAbsoluteTime: boolean, + timeZone: string | undefined, + hourCycle: Intl.DateTimeFormatOptions['hourCycle'], + newTitle: string, + lookups: Lookups | null, + ): void { + // Subclasses and overriding instances always render, so reading their inputs again is wasted + // and could run their code a different number of times + if (Object.getPrototypeOf(this) !== RelativeTimeElement.prototype || shadowsInheritedMember(this)) { + this.#stableUntil = -Infinity + return + } + // A title that is computed but not written (`no-title`) fires an event on every render + const titleSettled = newTitle === (this.getAttribute('title') || '') + const stableFor = + titleSettled && lookups ? this.#stableFor(date, now, format, displayUserPreferredAbsoluteTime, timeZone) : 0 + this.#stableUntil = now + stableFor + if (stableFor <= 0) return + this.#renderedLookups = lookups! + this.#renderedAt = now + this.#renderedAttributeVersion = this.#attributeVersion + this.#renderedEnvironment = environmentVersion + this.#renderedHostSettings = getHostSettings() + this.#renderedLang = this.#rawLang + this.#renderedTimeZone = timeZone + this.#renderedHourCycle = hourCycle + this.#renderedRootAbsolute = this.ownerDocument.documentElement.getAttribute('data-prefers-absolute-time') + this.#renderedBodyAbsolute = this.ownerDocument.body?.getAttribute('data-prefers-absolute-time') + const span = this.#renderRoot.firstElementChild as HTMLElement | null + this.#renderedText = span?.textContent ?? null + this.#renderedAriaHidden = span?.getAttribute('aria-hidden') ?? null + this.#renderedFontVariantNumeric = span?.style.fontVariantNumeric ?? '' + } + + [refresh](): void { + if ( + // Subclasses may compute any input differently, so they always render + Object.getPrototypeOf(this) !== RelativeTimeElement.prototype || + shadowsInheritedMember(this) || + typeof Intl === 'undefined' || + !Intl.DateTimeFormat || + !Intl.RelativeTimeFormat || + !this.#isStable(Date.now()) + ) { + this.update() + } else if (this.isConnected) { + // A stable element renders relative text, which `update()` always observes + dateObserver.observe(this) + } else { + dateObserver.unobserve(this) + } + } + + // Whether `update()` would render exactly what is already rendered and fire no event: before + // `#stableUntil`, with no attribute, ancestor, locale, time zone or rendered DOM change since + // the last render. The DOM checks match the ones `#updateRenderRootContent` uses to skip writes. + #isStable(now: number): boolean { + if ( + !(now < this.#stableUntil) || + now < this.#renderedAt || + this.#attributeVersion !== this.#renderedAttributeVersion || + environmentVersion !== this.#renderedEnvironment + ) { + return false + } + const root = this.#renderRoot + const span = root.firstElementChild as HTMLElement | null + return ( + span !== null && + root.childNodes.length === 1 && + span.getAttribute('part') === 'root' && + span.textContent === this.#renderedText && + span.getAttribute('aria-hidden') === this.#renderedAriaHidden && + span.style.fontVariantNumeric === this.#renderedFontVariantNumeric && + this.#rawLang === this.#renderedLang && + this.timeZone === this.#renderedTimeZone && + this.hourCycle === this.#renderedHourCycle && + this.ownerDocument.documentElement.getAttribute('data-prefers-absolute-time') === this.#renderedRootAbsolute && + this.ownerDocument.body?.getAttribute('data-prefers-absolute-time') === this.#renderedBodyAbsolute && + this.#renderedHostSettings !== null && + getHostSettings() === this.#renderedHostSettings && + // Last, as it updates the caches' least-recently-used order + repeatLookups(this.#renderedLookups) + ) + } + + // Milliseconds for which the rendered text cannot change, or 0 when unknown. Covers only + // `relative` output with `second` precision between a minute and 28 days, where + // `roundToSingleUnit` changes the text only 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 calendar date changes locally, in `timeZone`, or in UTC (used by `Duration.compare`) + // - the elapsed time reaches `threshold`, switching to `datetime` + #stableFor( + date: Date, + now: number, + format: ResolvedFormat, + displayUserPreferredAbsoluteTime: boolean, + timeZone: string | undefined, + ): number { + if (format !== 'relative' || displayUserPreferredAbsoluteTime || this.precision !== 'second') return 0 + const delta = date.getTime() - now + const elapsed = Math.abs(delta) + if (elapsed < msPerMinute || elapsed >= 28 * msPerDay) return 0 + const step = elapsed < msPerHour ? msPerMinute : msPerHour + const roundUp = elapsed < msPerHour ? 55 * msPerSecond : 54 * msPerMinute + 55 * msPerSecond + const position = elapsed % step + let stable: number + if (delta < 0) { + stable = position < roundUp ? roundUp - position : step - position + } else { + // Elapsed time shrinks toward a future date + stable = position >= roundUp ? position - roundUp : position + } + stable = Math.min(stable, msUntilNextDay(now), msPerDay - (now % msPerDay)) + // Every time zone's UTC offset, and every change to it, falls on a quarter hour, so the date in + // `timeZone` can only change then. Unlike its exact midnight, that needs no Intl lookup. + if (timeZone) stable = Math.min(stable, msPerQuarterHour - (now % msPerQuarterHour)) + if (this.tense === 'auto' && delta < 0) { + const threshold = Math.abs(applyDuration(now, Duration.from(this.threshold)).getTime() - now) + stable = Math.min(stable, threshold - elapsed - msPerSecond) + } + return Math.max(0, stable) + } } export default RelativeTimeElement diff --git a/test/intl-cache.ts b/test/intl-cache.ts index 2998ebd2..37013727 100644 --- a/test/intl-cache.ts +++ b/test/intl-cache.ts @@ -1,5 +1,14 @@ import {assert} from '@open-wc/testing' -import {dateTimeFormat, relativeTimeFormat, numberFormat, getLocale, clearIntlCache} from '../src/intl-cache.ts' +import { + dateTimeFormat, + relativeTimeFormat, + numberFormat, + getLocale, + clearIntlCache, + startLookups, + stopLookups, + repeatLookups, +} from '../src/intl-cache.ts' suite('intl-cache', function () { test('returns the same DateTimeFormat instance for identical locale + options', () => { @@ -48,4 +57,38 @@ suite('intl-cache', function () { const after = dateTimeFormat('en', {year: 'numeric', month: 'short'}) assert.notStrictEqual(before, after) }) + + test('repeating recorded lookups restores the least-recently-used order', () => { + clearIntlCache() + const recorded = dateTimeFormat('en', {year: 'numeric'}) + startLookups() + dateTimeFormat('en', {year: 'numeric'}) + const lookups = stopLookups() + for (let i = 0; i < 99; i++) dateTimeFormat(`en-x-b${i}`, {year: 'numeric'}) + assert.isTrue(repeatLookups(lookups)) + // One more entry evicts the least recently used, which is no longer the recorded one + dateTimeFormat('en-x-c', {year: 'numeric'}) + assert.strictEqual(dateTimeFormat('en', {year: 'numeric'}), recorded) + }) + + test('repeating lookups reports an entry that is no longer cached', () => { + clearIntlCache() + dateTimeFormat('en', {year: 'numeric'}) + startLookups() + dateTimeFormat('en', {year: 'numeric'}) + relativeTimeFormat('en', {numeric: 'auto'}) + const lookups = stopLookups() + clearIntlCache() + assert.isFalse(repeatLookups(lookups)) + }) + + test('only records lookups between startLookups and stopLookups', () => { + dateTimeFormat('en', {year: 'numeric'}) + startLookups() + relativeTimeFormat('en', {numeric: 'auto'}) + const lookups = stopLookups() + dateTimeFormat('en', {month: 'short'}) + assert.equal(lookups.length, 1) + assert.isNull(stopLookups()) + }) }) diff --git a/test/relative-time.js b/test/relative-time.js index 6ff90d88..80582d33 100644 --- a/test/relative-time.js +++ b/test/relative-time.js @@ -3536,4 +3536,566 @@ suite('relative-time', function () { } }) }) + + suite('skipping re-renders that cannot change output', () => { + const originalSetTimeout = window.setTimeout + const originalUpdate = RelativeTimeElement.prototype.update + let realDate + let tick + let updates + + // A clock that `Date.now()` and `new Date()` both follow, unlike `freezeTime`. + function useClock(start) { + realDate = Date + let current = start + function ClockDate(...args) { + return args.length ? new realDate(...args) : new realDate(current) + } + ClockDate.UTC = realDate.UTC + ClockDate.parse = realDate.parse + ClockDate.now = () => current + ClockDate.prototype = realDate.prototype + // eslint-disable-next-line no-global-assign + Date = ClockDate + return ms => { + current = ms + } + } + + setup(() => { + tick = null + updates = new Map() + globalThis.setTimeout = window.setTimeout = function (callback) { + tick = callback + return 0 + } + RelativeTimeElement.prototype.update = function () { + updates.set(this, (updates.get(this) || 0) + 1) + return originalUpdate.call(this) + } + }) + + teardown(() => { + globalThis.setTimeout = window.setTimeout = originalSetTimeout + RelativeTimeElement.prototype.update = originalUpdate + if (realDate) { + // eslint-disable-next-line no-global-assign + Date = realDate + realDate = null + } + }) + + function relativeTime(attributes, parent = fixture) { + const el = document.createElement('relative-time') + for (const [name, value] of Object.entries(attributes)) el.setAttribute(name, value) + parent.append(el) + return el + } + + // What a full render produces right now for an element with the same attributes and ancestors. + function freshRender(el) { + const copy = document.createElement('relative-time') + for (const {name, value} of el.attributes) { + // A rendered title is recomputed; only a title set by the page is kept + if (name !== 'title' || el.hasAttribute('data-custom-title')) copy.setAttribute(name, value) + } + el.after(copy) + const result = `${copy.shadowRoot.textContent} | ${copy.getAttribute('title')}` + copy.remove() + return result + } + + function rendered(el) { + return `${el.shadowRoot.textContent} | ${el.getAttribute('title')}` + } + + // Ticks the shared timer at every step and checks each element against a fresh render. + function assertMatchesFreshRenders(elements, setClock, from, to, step, onTick) { + const mismatches = [] + for (let now = from; now <= to; now += step) { + setClock(now) + if (onTick) onTick(now) + tick() + for (const el of elements) { + const expected = freshRender(el) + if (rendered(el) !== expected) { + mismatches.push(`${new realDate(now).toISOString()} ${el.outerHTML}: "${rendered(el)}" != "${expected}"`) + } + } + } + assert.deepEqual(mismatches.slice(0, 5), []) + } + + function seededRandom(seed) { + return () => { + seed = (seed * 1664525 + 1013904223) % 4294967296 + return seed / 4294967296 + } + } + + const second = 1000 + const minute = 60 * second + const hour = 60 * minute + const day = 24 * hour + + // Ages chosen to cross every point where the text can change: :55 and :00 seconds, + // 54:55 and 00:00 within the hour, 21 hours, whole days, 28 days, and thresholds. + const ages = [ + 50 * second, + 3 * minute + 50 * second, + 54 * minute + 30 * second, + 59 * minute + 50 * second, + 2 * hour + 54 * minute + 30 * second, + 20 * hour + 54 * minute + 30 * second, + 23 * hour + 54 * minute + 30 * second, + day + 23 * hour + 55 * minute, + 6 * day + 23 * hour + 55 * minute, + 27 * day + 23 * hour + 55 * minute, + 29 * day + 23 * hour + 55 * minute, + ] + + function elementsAround(start, extraAttributes = {}) { + const elements = [] + for (const age of ages) { + for (const [direction, tense] of [ + [-1, 'auto'], + [1, 'auto'], + [-1, 'past'], + [1, 'future'], + ]) { + const datetime = new realDate(start + direction * age + 123).toISOString() + elements.push(relativeTime({datetime, tense, ...extraAttributes})) + } + } + return elements + } + + for (const timeZone of [null, 'UTC', 'America/New_York', 'Australia/Lord_Howe', 'Pacific/Chatham']) { + test(`matches full renders across minutes, hours and midnights (time-zone: ${timeZone})`, async function () { + // eslint-disable-next-line @typescript-eslint/no-invalid-this + this.timeout(60000) + // 23:55 in the test runner's Asia/Dubai time zone, so the window crosses local midnight + const start = Date.parse('2026-03-07T19:55:00.000Z') + const setClock = useClock(start) + const elements = elementsAround(start, timeZone ? {'time-zone': timeZone} : {}) + await Promise.resolve() + assertMatchesFreshRenders(elements, setClock, start, start + 6 * minute, second) + }) + } + + test('matches full renders across daylight saving and zone midnights', async function () { + // eslint-disable-next-line @typescript-eslint/no-invalid-this + this.timeout(60000) + const windows = [ + ['America/New_York', '2026-03-08T04:55:00.000Z'], // midnight + ['America/New_York', '2026-03-08T06:55:00.000Z'], // clocks go forward + ['Australia/Lord_Howe', '2026-04-04T14:55:00.000Z'], // clocks go back 30 minutes + ['Pacific/Chatham', '2026-04-04T13:40:00.000Z'], // midnight at +13:45 + ['UTC', '2026-03-07T23:55:00.000Z'], // UTC midnight, used by thresholds + ] + for (const [timeZone, iso] of windows) { + fixture.innerHTML = '' + const start = Date.parse(iso) + if (realDate) { + // eslint-disable-next-line no-global-assign + Date = realDate + } + const setClock = useClock(start) + const elements = elementsAround(start, {'time-zone': timeZone}) + await Promise.resolve() + assertMatchesFreshRenders(elements, setClock, start, start + 6 * minute, second) + } + }) + + test('matches full renders across thresholds and local month ends', async function () { + // eslint-disable-next-line @typescript-eslint/no-invalid-this + this.timeout(60000) + const windows = [ + // 23:55 on the last day of February in the test runner's Asia/Dubai time zone + ['2026-02-28T19:55:00.000Z', null], + ['2026-02-28T19:55:00.000Z', 'America/New_York'], + ['2026-02-28T19:55:00.000Z', 'Pacific/Chatham'], + ] + const thresholds = [ + ['PT45M', 44 * minute], + ['PT45M30S', 45 * minute], + ['PT1H30M', hour + 27 * minute], + ['PT2H', hour + 57 * minute], + ['P1D', 23 * hour + 57 * minute], + ['P7D', 6 * day + 23 * hour + 57 * minute], + ['P1M', 27 * day + 23 * hour + 57 * minute], + ] + for (const [iso, timeZone] of windows) { + fixture.innerHTML = '' + if (realDate) { + // eslint-disable-next-line no-global-assign + Date = realDate + } + const start = Date.parse(iso) + const setClock = useClock(start) + const elements = [] + for (const [threshold, age] of thresholds) { + const attributes = {threshold, datetime: new realDate(start - age).toISOString()} + if (timeZone) attributes['time-zone'] = timeZone + elements.push(relativeTime(attributes)) + } + // Around four weeks, rounding to weeks or months depends on the local calendar month + const monthEndAges = [27 * day, 27 * day + 6 * hour, 27 * day + 12 * hour, 27 * day + 18 * hour] + for (const age of [...ages, ...monthEndAges]) { + const attributes = {datetime: new realDate(start - age).toISOString()} + if (timeZone) attributes['time-zone'] = timeZone + elements.push(relativeTime(attributes)) + } + await Promise.resolve() + assertMatchesFreshRenders(elements, setClock, start, start + 6 * minute, second) + } + }) + + test('matches full renders for random dates at sub-second steps', async function () { + // eslint-disable-next-line @typescript-eslint/no-invalid-this + this.timeout(60000) + const random = seededRandom(42) + const start = Date.parse('2026-06-01T11:58:00.000Z') + const setClock = useClock(start) + const thresholds = [null, 'PT2H', 'P1D', 'PT45M'] + const elements = [] + for (let i = 0; i < 40; i++) { + const age = Math.floor(random() * 30 * day) + const direction = random() < 0.7 ? -1 : 1 + const attributes = {datetime: new realDate(start + direction * age).toISOString()} + const threshold = thresholds[i % thresholds.length] + if (threshold) attributes.threshold = threshold + if (i % 5 === 0) attributes.format = 'relative' + elements.push(relativeTime(attributes)) + } + // Ages that sit just before change points, with a random millisecond offset + for (const age of ages) { + const datetime = new realDate(start - age - Math.floor(random() * second)).toISOString() + elements.push(relativeTime({datetime})) + } + await Promise.resolve() + assertMatchesFreshRenders(elements, setClock, start, start + 3 * minute, 250) + }) + + test('matches full renders when ancestors, preferences or the clock change', async function () { + // eslint-disable-next-line @typescript-eslint/no-invalid-this + this.timeout(60000) + const start = Date.parse('2026-03-07T12:00:00.000Z') + const setClock = useClock(start) + const parent = document.createElement('div') + fixture.append(parent) + const elements = ages.map(age => relativeTime({datetime: new realDate(start - age).toISOString()}, parent)) + await Promise.resolve() + const changes = [ + () => parent.setAttribute('lang', 'de'), + () => parent.setAttribute('time-zone', 'America/New_York'), + () => parent.setAttribute('hour-cycle', 'h12'), + () => document.body.setAttribute('data-prefers-absolute-time', 'true'), + () => document.body.removeAttribute('data-prefers-absolute-time'), + () => parent.removeAttribute('lang'), + () => window.dispatchEvent(new Event('languagechange')), + () => { + elements[4].shadowRoot.firstElementChild.textContent = 'tampered' + }, + () => { + elements[5].setAttribute('data-custom-title', '') + elements[5].setAttribute('title', 'custom title') + }, + () => setClock(Date.now() - 3 * hour), + ] + let step = 0 + assertMatchesFreshRenders(elements, setClock, start, start + 2 * minute, second, now => { + if (now % (10 * second) === 0 && step < changes.length) changes[step++]() + }) + assert.equal(step, changes.length) + }) + + test('ticks do not re-render elements whose text cannot change', async () => { + const start = Date.parse('2026-03-07T12:00:00.000Z') + const setClock = useClock(start) + const old = [] + for (let i = 0; i < 100; i++) { + old.push(relativeTime({datetime: new realDate(start - 3 * hour - 10 * minute - i * second).toISOString()})) + } + const fresh = relativeTime({datetime: new realDate(start - 2 * second).toISOString()}) + await Promise.resolve() + updates.clear() + + // The fresh element stays under a minute old, so its text changes every second + for (let i = 1; i <= 50; i++) { + setClock(start + i * second) + tick() + } + + assert.equal(updates.get(fresh), 50) + assert.equal( + old.reduce((sum, el) => sum + (updates.get(el) || 0), 0), + 0, + ) + assert.equal(old[0].shadowRoot.textContent, '3 hours ago') + }) + + test('an inserted element renders once', async () => { + useClock(Date.parse('2026-03-07T12:00:00.000Z')) + const el = relativeTime({datetime: '2026-03-07T09:00:00.000Z'}) + await Promise.resolve() + await Promise.resolve() + assert.equal(updates.get(el), 1) + assert.equal(el.shadowRoot.textContent, '3 hours ago') + }) + + test('update() always re-renders', async () => { + useClock(Date.parse('2026-03-07T12:00:00.000Z')) + const el = relativeTime({datetime: '2026-03-07T09:00:00.000Z'}) + await Promise.resolve() + const before = updates.get(el) + el.update() + el.update() + assert.equal(updates.get(el), before + 2) + }) + + test('an attribute changed by an update listener re-renders', async () => { + useClock(Date.parse('2026-03-07T12:00:00.000Z')) + const el = document.createElement('relative-time') + el.addEventListener('relative-time-updated', () => el.setAttribute('format', 'datetime'), {once: true}) + el.setAttribute('datetime', '2026-03-07T09:00:00.000Z') + fixture.append(el) + await Promise.resolve() + await Promise.resolve() + assert.equal(rendered(el), freshRender(el)) + assert.notEqual(el.shadowRoot.textContent, '3 hours ago') + }) + + test('no-title elements keep firing an event on every tick', async () => { + const start = Date.parse('2026-03-07T12:00:00.000Z') + const setClock = useClock(start) + const el = relativeTime({'no-title': '', datetime: '2026-03-07T09:00:00.000Z'}) + relativeTime({datetime: new realDate(start - 2 * second).toISOString()}) + await Promise.resolve() + let events = 0 + el.addEventListener('relative-time-updated', () => events++) + for (let i = 1; i <= 3; i++) { + setClock(start + i * second) + tick() + } + assert.equal(events, 3) + }) + + test('a changed render root is repaired on the next tick', async () => { + const start = Date.parse('2026-03-07T12:00:00.000Z') + const setClock = useClock(start) + const elements = [0, 1, 2, 3].map(() => relativeTime({datetime: '2026-03-07T09:00:00.000Z'})) + relativeTime({datetime: new realDate(start - 2 * second).toISOString()}) + await Promise.resolve() + const hidden = document.createElement('div') + hidden.hidden = true + hidden.textContent = '3 hours ago' + elements[0].shadowRoot.replaceChildren(hidden) + elements[1].shadowRoot.firstElementChild.setAttribute('aria-hidden', 'true') + elements[2].shadowRoot.firstElementChild.style.fontVariantNumeric = 'tabular-nums' + elements[3].shadowRoot.append(document.createTextNode('')) + setClock(start + second) + tick() + for (const el of elements) { + const [span, ...rest] = el.shadowRoot.childNodes + assert.equal(rest.length, 0) + assert.equal(span.getAttribute('part'), 'root') + assert.equal(span.textContent, '3 hours ago') + assert.isFalse(span.hasAttribute('aria-hidden')) + assert.equal(span.style.fontVariantNumeric, '') + } + }) + + test('a host time zone change re-renders', async () => { + const start = Date.parse('2026-03-07T12:00:00.000Z') + const setClock = useClock(start) + const el = relativeTime({datetime: '2026-03-07T09:00:00.000Z'}) + relativeTime({datetime: new realDate(start - 2 * second).toISOString()}) + await Promise.resolve() + setClock(start + second) + tick() + const before = updates.get(el) + const resolvedOptions = Intl.DateTimeFormat.prototype.resolvedOptions + Intl.DateTimeFormat.prototype.resolvedOptions = function () { + return {...resolvedOptions.call(this), timeZone: 'America/Puerto_Rico'} + } + try { + setClock(start + 2 * second) + tick() + } finally { + Intl.DateTimeFormat.prototype.resolvedOptions = resolvedOptions + } + assert.equal(updates.get(el), before + 1) + }) + + test('subclass instances always re-render', async () => { + const start = Date.parse('2026-03-07T12:00:00.000Z') + const setClock = useClock(start) + let date = new realDate('2026-03-07T09:00:00.000Z') + class DynamicTime extends RelativeTimeElement { + get date() { + return new realDate(date) + } + } + // eslint-disable-next-line custom-elements/extends-correct-class + if (!customElements.get('dynamic-time')) customElements.define('dynamic-time', DynamicTime) + const el = document.createElement('dynamic-time') + el.setAttribute('datetime', '2026-03-07T09:00:00.000Z') + fixture.append(el) + relativeTime({datetime: new realDate(start - 2 * second).toISOString()}) + await Promise.resolve() + assert.equal(el.shadowRoot.textContent, '3 hours ago') + date = new realDate('2026-01-01T09:00:00.000Z') + setClock(start + second) + tick() + assert.equal(el.shadowRoot.textContent, 'on Jan 1') + }) + + test('removing Intl.RelativeTimeFormat re-renders', async () => { + const start = Date.parse('2026-03-07T12:00:00.000Z') + const setClock = useClock(start) + const el = relativeTime({datetime: '2026-03-07T09:00:00.000Z'}) + relativeTime({datetime: new realDate(start - 2 * second).toISOString()}) + await Promise.resolve() + const RelativeTimeFormat = Intl.RelativeTimeFormat + Intl.RelativeTimeFormat = undefined + try { + setClock(start + second) + tick() + } finally { + Intl.RelativeTimeFormat = RelativeTimeFormat + } + assert.equal(el.shadowRoot.textContent, 'on Mar 7') + }) + + test('a skipped tick keeps formatter caches in the same order as a render', async () => { + const start = Date.parse('2026-03-07T12:00:00.000Z') + const setClock = useClock(start) + // Empties the formatter caches + window.dispatchEvent(new Event('languagechange')) + const el = relativeTime({lang: 'en-x-lru', datetime: '2026-03-07T09:00:00.000Z'}) + relativeTime({datetime: new realDate(start - 2 * second).toISOString()}) + // Fill the 100-entry locale cache, leaving the element's locale least recently used + const datetime = '2026-03-07T09:00:00.000Z' + for (let i = 0; i < 99; i++) relativeTime({lang: `en-x-fill${i}`, format: 'datetime', datetime}) + await Promise.resolve() + + // Rendering on this tick looks the element's locale up again, so must skipping it + setClock(start + second) + tick() + relativeTime({lang: 'en-x-fill-last', format: 'datetime', datetime}) + await Promise.resolve() + + const Locale = Intl.Locale + let constructed = 0 + Intl.Locale = new Proxy(Locale, { + construct(target, args) { + constructed++ + return Reflect.construct(target, args) + }, + }) + try { + el.update() + } finally { + Intl.Locale = Locale + } + assert.equal(constructed, 0) + }) + + test('instances that override an inherited member always re-render', async () => { + const start = Date.parse('2026-03-07T12:00:00.000Z') + const setClock = useClock(start) + const el = relativeTime({datetime: '2026-03-07T09:00:00.000Z'}) + relativeTime({datetime: new realDate(start - 2 * second).toISOString()}) + await Promise.resolve() + Object.defineProperty(el, 'date', {get: () => new realDate('2026-01-01T09:00:00.000Z')}) + setClock(start + second) + tick() + assert.equal(el.shadowRoot.textContent, 'on Jan 1') + }) + + test('a host time zone change re-renders even if the clock has not moved', async () => { + const start = Date.parse('2026-03-07T12:00:00.000Z') + useClock(start) + const el = relativeTime({datetime: '2026-03-07T09:00:00.000Z'}) + relativeTime({datetime: new realDate(start - 2 * second).toISOString()}) + await Promise.resolve() + tick() + const before = updates.get(el) + const resolvedOptions = Intl.DateTimeFormat.prototype.resolvedOptions + Intl.DateTimeFormat.prototype.resolvedOptions = function () { + return {...resolvedOptions.call(this), timeZone: 'America/Puerto_Rico'} + } + try { + tick() + } finally { + Intl.DateTimeFormat.prototype.resolvedOptions = resolvedOptions + } + assert.equal(updates.get(el), before + 1) + }) + + test('working out when text can change does not add formatters to the caches', async () => { + const start = Date.parse('2026-03-07T12:00:00.000Z') + useClock(start) + // Empties the formatter caches + window.dispatchEvent(new Event('languagechange')) + relativeTime({'time-zone': 'UTC', datetime: new realDate(start - 3 * minute).toISOString()}) + await Promise.resolve() + + // A render two days back reads the wall clock in UTC, which nothing has needed yet + const DateTimeFormat = Intl.DateTimeFormat + let wallClockFormatters = 0 + Intl.DateTimeFormat = new Proxy(DateTimeFormat, { + construct(target, args) { + if (args[1]?.era === 'short') wallClockFormatters++ + return Reflect.construct(target, args) + }, + }) + try { + relativeTime({'time-zone': 'UTC', datetime: new realDate(start - 2 * day).toISOString()}) + } finally { + Intl.DateTimeFormat = DateTimeFormat + } + assert.equal(wallClockFormatters, 1) + }) + + test('overriding instances have their inputs read as often as a render reads them', async () => { + useClock(Date.parse('2026-03-07T12:00:00.000Z')) + const el = relativeTime({datetime: '2026-03-07T09:00:00.000Z'}) + await Promise.resolve() + let reads = 0 + Object.defineProperty(el, 'precision', { + get() { + reads++ + return 'second' + }, + }) + el.update() + assert.equal(reads, 1) + }) + + test('renders without queueMicrotask', async () => { + useClock(Date.parse('2026-03-07T12:00:00.000Z')) + const original = globalThis.queueMicrotask + globalThis.queueMicrotask = undefined + let el + try { + el = relativeTime({datetime: '2026-03-07T09:00:00.000Z'}) + } finally { + globalThis.queueMicrotask = original + } + assert.equal(el.shadowRoot.textContent, '3 hours ago') + }) + + test('an attribute change re-renders', async () => { + useClock(Date.parse('2026-03-07T12:00:00.000Z')) + const el = relativeTime({datetime: '2026-03-07T09:00:00.000Z'}) + await Promise.resolve() + const before = updates.get(el) + el.setAttribute('format-style', 'narrow') + await Promise.resolve() + assert.equal(updates.get(el), before + 1) + assert.equal(el.shadowRoot.textContent, '3h ago') + }) + }) }) From ad8003f55e9ff5b4cd897651a0c92855251b9631 Mon Sep 17 00:00:00 2001 From: Matthew Costabile <8616962+mattcosta7@users.noreply.github.com> Date: Sun, 8 Nov 2026 12:18:56 -0500 Subject: [PATCH 2/6] Fix bundle load, time-zone and pre-1970 bounds when skipping ticks 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> --- src/relative-time-element.ts | 27 ++++++++++++--------- test/bundle.js | 47 ++++++++++++++++++++++++++++++++++++ test/relative-time.js | 36 +++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 11 deletions(-) create mode 100644 test/bundle.js diff --git a/src/relative-time-element.ts b/src/relative-time-element.ts index 53a7e647..f5756878 100644 --- a/src/relative-time-element.ts +++ b/src/relative-time-element.ts @@ -126,7 +126,6 @@ const msPerSecond = 1000 const msPerMinute = 60 * msPerSecond const msPerHour = 60 * msPerMinute const msPerDay = 24 * msPerHour -const msPerQuarterHour = 15 * msPerMinute // Host settings that change formatting without a `languagechange` event. The host reports // changes between tasks, so they are read once per tick and once per microtask checkpoint. @@ -168,6 +167,15 @@ function shadowsInheritedMember(el: RelativeTimeElement): boolean { return false } +// Whether `el` is a plain `relative-time` that reads its inputs only through this class. Subclasses +// and overriding instances always render, so reading their inputs again is wasted and could run +// their code a different number of times. This is a function rather than a class member because +// tsc hoists private methods into the computed `[refresh]` key, which esbuild's bundle evaluates +// before the `RelativeTimeElement` binding exists. +function isPlainElement(el: RelativeTimeElement): boolean { + return Object.getPrototypeOf(el) === RelativeTimeElement.prototype && !shadowsInheritedMember(el) +} + // Re-renders an element unless its output is known to be unchanged. Module-private so the // public `update()` keeps always re-rendering. const refresh = Symbol('refresh') @@ -867,9 +875,7 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor newTitle: string, lookups: Lookups | null, ): void { - // Subclasses and overriding instances always render, so reading their inputs again is wasted - // and could run their code a different number of times - if (Object.getPrototypeOf(this) !== RelativeTimeElement.prototype || shadowsInheritedMember(this)) { + if (!isPlainElement(this)) { this.#stableUntil = -Infinity return } @@ -897,9 +903,7 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor [refresh](): void { if ( - // Subclasses may compute any input differently, so they always render - Object.getPrototypeOf(this) !== RelativeTimeElement.prototype || - shadowsInheritedMember(this) || + !isPlainElement(this) || typeof Intl === 'undefined' || !Intl.DateTimeFormat || !Intl.RelativeTimeFormat || @@ -952,8 +956,11 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor // `roundToSingleUnit` changes the text only 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 calendar date changes locally, in `timeZone`, or in UTC (used by `Duration.compare`) + // - the calendar date changes locally or in UTC (used by `Duration.compare`) // - the elapsed time reaches `threshold`, switching to `datetime` + // Elements with a `time-zone` are never covered: a fixed offset can be any number of minutes, and + // historical offsets have seconds, so only an Intl lookup could place that zone's midnight. Clocks + // before 1970 are not covered either, as `now % msPerDay` is negative for them. #stableFor( date: Date, now: number, @@ -962,6 +969,7 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor timeZone: string | undefined, ): number { if (format !== 'relative' || displayUserPreferredAbsoluteTime || this.precision !== 'second') return 0 + if (timeZone || now < 0) return 0 const delta = date.getTime() - now const elapsed = Math.abs(delta) if (elapsed < msPerMinute || elapsed >= 28 * msPerDay) return 0 @@ -976,9 +984,6 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor stable = position >= roundUp ? position - roundUp : position } stable = Math.min(stable, msUntilNextDay(now), msPerDay - (now % msPerDay)) - // Every time zone's UTC offset, and every change to it, falls on a quarter hour, so the date in - // `timeZone` can only change then. Unlike its exact midnight, that needs no Intl lookup. - if (timeZone) stable = Math.min(stable, msPerQuarterHour - (now % msPerQuarterHour)) if (this.tense === 'auto' && delta < 0) { const threshold = Math.abs(applyDuration(now, Duration.from(this.threshold)).getTime() - now) stable = Math.min(stable, threshold - elapsed - msPerSecond) diff --git a/test/bundle.js b/test/bundle.js new file mode 100644 index 00000000..134aef31 --- /dev/null +++ b/test/bundle.js @@ -0,0 +1,47 @@ +import {assert} from '@open-wc/testing' +// The published `main`. It is a lowered esbuild bundle, so it can fail where `dist/index.js` and +// `src` do not. Run after `npm run build`, and keep this file free of `src` imports so that only +// the bundle defines `relative-time`. +import '../dist/bundle.js' + +suite('dist/bundle.js', () => { + const originalSetTimeout = window.setTimeout + let fixture + + suiteSetup(() => { + fixture = document.createElement('div') + document.body.appendChild(fixture) + document.documentElement.lang = 'en' + }) + + teardown(() => { + globalThis.setTimeout = window.setTimeout = originalSetTimeout + fixture.innerHTML = '' + }) + + test('defines and renders relative-time', async () => { + const el = document.createElement('relative-time') + el.setAttribute('datetime', new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString()) + fixture.append(el) + await Promise.resolve() + assert.equal(el.shadowRoot.textContent, '3 hours ago') + }) + + test('re-renders on the shared timer', async () => { + let tick + globalThis.setTimeout = window.setTimeout = function (callback) { + tick = callback + return 0 + } + const el = document.createElement('relative-time') + el.setAttribute('datetime', new Date(Date.now() - 3 * 60 * 1000).toISOString()) + fixture.append(el) + await Promise.resolve() + assert.equal(el.shadowRoot.textContent, '3 minutes ago') + tick() + tick() + assert.equal(el.shadowRoot.textContent, '3 minutes ago') + el.update() + assert.equal(el.shadowRoot.textContent, '3 minutes ago') + }) +}) diff --git a/test/relative-time.js b/test/relative-time.js index 80582d33..6953e51a 100644 --- a/test/relative-time.js +++ b/test/relative-time.js @@ -4097,5 +4097,41 @@ suite('relative-time', function () { assert.equal(updates.get(el), before + 1) assert.equal(el.shadowRoot.textContent, '3h ago') }) + + // Zone offsets are not always whole quarter hours: fixed offsets accept any minute, and + // historical offsets have odd minutes and seconds. + for (const [timeZone, datetime, before, after] of [ + ['+00:01', '2026-03-06T00:29:00.000Z', '2026-03-07T23:58:00.000Z', '2026-03-07T23:59:30.000Z'], + ['Africa/Monrovia', '1971-06-13T01:14:00.000Z', '1971-06-15T00:44:00.000Z', '1971-06-15T00:44:40.000Z'], + ]) { + test(`renders when a tick crosses midnight in ${timeZone}`, async () => { + const setClock = useClock(Date.parse(before)) + const el = relativeTime({datetime, 'time-zone': timeZone}) + await Promise.resolve() + const initial = rendered(el) + let events = 0 + el.addEventListener('relative-time-updated', () => events++) + + setClock(Date.parse(after)) + tick() + + assert.notEqual(rendered(el), initial) + assert.equal(rendered(el), freshRender(el)) + assert.equal(events, 1) + }) + } + + test('renders when a tick crosses UTC midnight before 1970', async () => { + const setClock = useClock(Date.parse('1969-06-10T23:58:00.000Z')) + const el = relativeTime({datetime: '1969-06-10T20:48:00.000Z'}) + await Promise.resolve() + updates.clear() + + setClock(Date.parse('1969-06-11T00:00:30.000Z')) + tick() + + assert.equal(updates.get(el), 1) + assert.equal(rendered(el), freshRender(el)) + }) }) }) From bf74009afa6d01349345e4b5a5855ff737ea6a2e Mon Sep 17 00:00:00 2001 From: Matthew Costabile <8616962+mattcosta7@users.noreply.github.com> Date: Sun, 8 Nov 2026 13:32:43 -0500 Subject: [PATCH 3/6] Never skip when the class was changed, and keep skipping for invalid 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> --- src/intl-cache.ts | 25 +++++++++--- src/relative-time-element.ts | 77 ++++++++++++++++++++++++++++++------ test/intl-cache.ts | 15 +++++++ test/relative-time.js | 52 +++++++++++++++++++++--- 4 files changed, 147 insertions(+), 22 deletions(-) diff --git a/src/intl-cache.ts b/src/intl-cache.ts index 28502eff..82aaf0b4 100644 --- a/src/intl-cache.ts +++ b/src/intl-cache.ts @@ -24,7 +24,7 @@ class LRUCache { get(key: string): V | undefined { if (lookups) { - if (lookups.length < maxLookups) lookups.push([this as LRUCache, key]) + if (lookups.length < maxLookups) lookups.push([this as LRUCache, key, false]) else lookupsOverflowed = true } const value = this.#map.get(key) @@ -35,6 +35,10 @@ class LRUCache { return value } + has(key: string): boolean { + return this.#map.has(key) + } + set(key: string, value: V): void { this.#map.delete(key) this.#map.set(key, value) @@ -50,7 +54,9 @@ class LRUCache { const registeredCaches: Array> = [] -export type Lookups = Array<[LRUCache, string]> +// Each lookup records whether the render left its entry missing, as when constructing a formatter +// for an invalid locale throws. Looking up a missing entry changes nothing. +export type Lookups = Array<[LRUCache, string, boolean]> // Lookups made while rendering an element. Repeating them when the element skips a render // leaves every cache in the same least-recently-used order as a full render would. @@ -68,14 +74,21 @@ export function startLookups(): void { export function stopLookups(): Lookups | null { const result = lookupsOverflowed ? null : lookups lookups = null + if (result) { + for (const lookup of result) lookup[2] = !lookup[0].has(lookup[1]) + } return result } -// Repeats recorded lookups in order. Returns false at the first entry no longer cached, which -// a full render would have to recreate. +// Repeats recorded lookups in order. Returns false at the first entry a full render would find +// differently: no longer cached, or cached now when the render left it missing. export function repeatLookups(recorded: Lookups): boolean { - for (const [cache, key] of recorded) { - if (cache.get(key) === undefined) return false + for (const [cache, key, missing] of recorded) { + if (missing) { + if (cache.has(key)) return false + } else if (cache.get(key) === undefined) { + return false + } } return true } diff --git a/src/relative-time-element.ts b/src/relative-time-element.ts index f5756878..62f500f5 100644 --- a/src/relative-time-element.ts +++ b/src/relative-time-element.ts @@ -128,21 +128,29 @@ const msPerHour = 60 * msPerMinute const msPerDay = 24 * msPerHour // Host settings that change formatting without a `languagechange` event. The host reports -// changes between tasks, so they are read once per tick and once per microtask checkpoint. -// Null when they cannot be read, which never matches. +// changes between tasks, so they are read once per tick and once per microtask checkpoint, as is +// whether the class has been changed. Null settings mean they cannot be read, which never matches. let hostSettingsRead = false let hostSettings: string | null = null -function forgetHostSettings(): void { +let classChecked = false +let classUnchanged = false +function forgetTaskReads(): void { hostSettingsRead = false + classChecked = false +} +// Whether the reads can be kept until the microtask checkpoint; if not, they are read every time +function keepUntilCheckpoint(): boolean { + if (hostSettingsRead || classChecked) return true + try { + queueMicrotask(forgetTaskReads) + return true + } catch { + return false + } } function getHostSettings(): string | null { if (!hostSettingsRead) { - try { - queueMicrotask(forgetHostSettings) - hostSettingsRead = true - } catch { - // Without a way to forget them, read them on every call - } + hostSettingsRead = keepUntilCheckpoint() try { hostSettings = `${new Date().getTimezoneOffset()} ${new Intl.DateTimeFormat().resolvedOptions().timeZone}` } catch { @@ -173,7 +181,44 @@ function shadowsInheritedMember(el: RelativeTimeElement): boolean { // tsc hoists private methods into the computed `[refresh]` key, which esbuild's bundle evaluates // before the `RelativeTimeElement` binding exists. function isPlainElement(el: RelativeTimeElement): boolean { - return Object.getPrototypeOf(el) === RelativeTimeElement.prototype && !shadowsInheritedMember(el) + return ( + Object.getPrototypeOf(el) === RelativeTimeElement.prototype && isClassUnchanged() && !shadowsInheritedMember(el) + ) +} + +// Whether `RelativeTimeElement.prototype` still has the members it was defined with. A page that +// replaces one, such as the `date` getter, can change what `update()` reads at any time. +function isClassUnchanged(): boolean { + if (!classChecked) { + classChecked = keepUntilCheckpoint() + classUnchanged = membersMatch(RelativeTimeElement.prototype, definedMembers) + } + return classUnchanged +} + +function membersMatch(proto: object, members: DefinedMembers): boolean { + if (Object.getPrototypeOf(proto) !== members.parent) return false + const keys = Reflect.ownKeys(proto) + if (keys.length !== members.descriptors.size) return false + for (const key of keys) { + const defined = members.descriptors.get(key) + const current = Object.getOwnPropertyDescriptor(proto, key) + if ( + !defined || + !current || + defined.get !== current.get || + defined.set !== current.set || + defined.value !== current.value + ) { + return false + } + } + return true +} + +interface DefinedMembers { + parent: object | null + descriptors: Map } // Re-renders an element unless its output is known to be unchanged. Module-private so the @@ -215,7 +260,7 @@ const dateObserver = new (class { try { // Discard lookups left recording by a render that threw stopLookups() - forgetHostSettings() + forgetTaskReads() for (const timeEl of this.elements) { if (!timeEl.isConnected) { this.elements.delete(timeEl) @@ -992,4 +1037,14 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor } } +const definedMembers: DefinedMembers = { + parent: Object.getPrototypeOf(RelativeTimeElement.prototype), + descriptors: new Map( + Reflect.ownKeys(RelativeTimeElement.prototype).map(key => [ + key, + Object.getOwnPropertyDescriptor(RelativeTimeElement.prototype, key)!, + ]), + ), +} + export default RelativeTimeElement diff --git a/test/intl-cache.ts b/test/intl-cache.ts index 37013727..554ea719 100644 --- a/test/intl-cache.ts +++ b/test/intl-cache.ts @@ -91,4 +91,19 @@ suite('intl-cache', function () { assert.equal(lookups.length, 1) assert.isNull(stopLookups()) }) + + test('a lookup the render left missing repeats as missing until the entry appears', () => { + clearIntlCache() + startLookups() + assert.throws(() => getLocale('en_US')) + const lookups = stopLookups() + assert.isTrue(repeatLookups(lookups)) + // An entry that now exists would be found by a full render, so repeating it must fail + dateTimeFormat('en', {year: 'numeric'}) + startLookups() + dateTimeFormat('en', {month: 'short'}) + const present = stopLookups() + clearIntlCache() + assert.isFalse(repeatLookups(present)) + }) }) diff --git a/test/relative-time.js b/test/relative-time.js index 6953e51a..59497538 100644 --- a/test/relative-time.js +++ b/test/relative-time.js @@ -3539,9 +3539,11 @@ suite('relative-time', function () { suite('skipping re-renders that cannot change output', () => { const originalSetTimeout = window.setTimeout - const originalUpdate = RelativeTimeElement.prototype.update + const hasAttribute = Element.prototype.hasAttribute let realDate let tick + // Renders per element, counted without changing the element's class, which would turn skipping + // off. A render without a custom title reads `no-title` exactly once. let updates // A clock that `Date.now()` and `new Date()` both follow, unlike `freezeTime`. @@ -3569,15 +3571,15 @@ suite('relative-time', function () { tick = callback return 0 } - RelativeTimeElement.prototype.update = function () { - updates.set(this, (updates.get(this) || 0) + 1) - return originalUpdate.call(this) + Element.prototype.hasAttribute = function (name) { + if (name === 'no-title' && this instanceof RelativeTimeElement) updates.set(this, (updates.get(this) || 0) + 1) + return hasAttribute.call(this, name) } }) teardown(() => { globalThis.setTimeout = window.setTimeout = originalSetTimeout - RelativeTimeElement.prototype.update = originalUpdate + Element.prototype.hasAttribute = hasAttribute if (realDate) { // eslint-disable-next-line no-global-assign Date = realDate @@ -4087,6 +4089,46 @@ suite('relative-time', function () { assert.equal(el.shadowRoot.textContent, '3 hours ago') }) + test('a prototype member replaced before the first render always re-renders', async () => { + const start = Date.parse('2026-03-09T12:00:00.000Z') + const setClock = useClock(start) + const original = Object.getOwnPropertyDescriptor(RelativeTimeElement.prototype, 'date') + let date = new realDate('2026-03-09T09:00:00.000Z') + Object.defineProperty(RelativeTimeElement.prototype, 'date', { + configurable: true, + get() { + return new realDate(date) + }, + }) + try { + const el = relativeTime({datetime: '2026-03-09T09:00:00.000Z'}) + relativeTime({datetime: new realDate(start - 2 * second).toISOString()}) + await Promise.resolve() + assert.equal(el.shadowRoot.textContent, '3 hours ago') + date = new realDate('2026-01-01T09:00:00.000Z') + setClock(start + second) + tick() + assert.equal(el.shadowRoot.textContent, 'on Jan 1') + } finally { + Object.defineProperty(RelativeTimeElement.prototype, 'date', original) + } + }) + + test('an invalid lang still renders once and skips unchanged ticks', async () => { + const start = Date.parse('2026-03-10T12:00:00.000Z') + const setClock = useClock(start) + const el = relativeTime({lang: 'en_US', datetime: '2026-03-10T09:00:00.000Z'}) + relativeTime({datetime: new realDate(start - 2 * second).toISOString()}) + await Promise.resolve() + assert.equal(updates.get(el), 1) + for (let i = 1; i <= 5; i++) { + setClock(start + i * second) + tick() + } + assert.equal(updates.get(el), 1) + assert.equal(rendered(el), freshRender(el)) + }) + test('an attribute change re-renders', async () => { useClock(Date.parse('2026-03-07T12:00:00.000Z')) const el = relativeTime({datetime: '2026-03-07T09:00:00.000Z'}) From 17e6cc8ac01508ac6341302eddbcb1ee3c0af1e7 Mon Sep 17 00:00:00 2001 From: Matthew Costabile <8616962+mattcosta7@users.noreply.github.com> Date: Sun, 8 Nov 2026 14:18:04 -0500 Subject: [PATCH 4/6] Check the class on every call, and before a render reads its inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- src/relative-time-element.ts | 113 +++++++++++++++++++---------------- test/relative-time.js | 94 +++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+), 52 deletions(-) diff --git a/src/relative-time-element.ts b/src/relative-time-element.ts index 62f500f5..7527c260 100644 --- a/src/relative-time-element.ts +++ b/src/relative-time-element.ts @@ -128,29 +128,21 @@ const msPerHour = 60 * msPerMinute const msPerDay = 24 * msPerHour // Host settings that change formatting without a `languagechange` event. The host reports -// changes between tasks, so they are read once per tick and once per microtask checkpoint, as is -// whether the class has been changed. Null settings mean they cannot be read, which never matches. +// changes between tasks, so they are read once per tick and once per microtask checkpoint. +// Null when they cannot be read, which never matches. let hostSettingsRead = false let hostSettings: string | null = null -let classChecked = false -let classUnchanged = false -function forgetTaskReads(): void { +function forgetHostSettings(): void { hostSettingsRead = false - classChecked = false -} -// Whether the reads can be kept until the microtask checkpoint; if not, they are read every time -function keepUntilCheckpoint(): boolean { - if (hostSettingsRead || classChecked) return true - try { - queueMicrotask(forgetTaskReads) - return true - } catch { - return false - } } function getHostSettings(): string | null { if (!hostSettingsRead) { - hostSettingsRead = keepUntilCheckpoint() + try { + queueMicrotask(forgetHostSettings) + hostSettingsRead = true + } catch { + // Without a way to forget them, read them on every call + } try { hostSettings = `${new Date().getTimezoneOffset()} ${new Intl.DateTimeFormat().resolvedOptions().timeZone}` } catch { @@ -186,30 +178,39 @@ function isPlainElement(el: RelativeTimeElement): boolean { ) } -// Whether `RelativeTimeElement.prototype` still has the members it was defined with. A page that -// replaces one, such as the `date` getter, can change what `update()` reads at any time. +// The prototype members a render reads, through `this` or the shared timer. Replacing any of them, +// such as the `date` getter or `update()` itself, can change what a render produces. +const renderedMembers = [ + 'update', + 'date', + 'datetime', + 'format', + 'formatStyle', + 'precision', + 'tense', + 'threshold', + 'noTitle', + 'timeZone', + 'hourCycle', + 'second', + 'minute', + 'hour', + 'weekday', + 'day', + 'month', + 'year', + 'timeZoneName', + 'prefix', +] as const + +// Whether those members are still the ones `RelativeTimeElement` was defined with. Checked on every +// call, because page code can replace one at any time, including from an update listener. function isClassUnchanged(): boolean { - if (!classChecked) { - classChecked = keepUntilCheckpoint() - classUnchanged = membersMatch(RelativeTimeElement.prototype, definedMembers) - } - return classUnchanged -} - -function membersMatch(proto: object, members: DefinedMembers): boolean { - if (Object.getPrototypeOf(proto) !== members.parent) return false - const keys = Reflect.ownKeys(proto) - if (keys.length !== members.descriptors.size) return false - for (const key of keys) { - const defined = members.descriptors.get(key) - const current = Object.getOwnPropertyDescriptor(proto, key) - if ( - !defined || - !current || - defined.get !== current.get || - defined.set !== current.set || - defined.value !== current.value - ) { + const proto = RelativeTimeElement.prototype + if (Object.getPrototypeOf(proto) !== definedMembers.parent) return false + for (const [name, defined] of definedMembers.descriptors) { + const current = Object.getOwnPropertyDescriptor(proto, name) + if (!current || defined.get !== current.get || defined.set !== current.set || defined.value !== current.value) { return false } } @@ -218,7 +219,7 @@ function membersMatch(proto: object, members: DefinedMembers): boolean { interface DefinedMembers { parent: object | null - descriptors: Map + descriptors: Map } // Re-renders an element unless its output is known to be unchanged. Module-private so the @@ -260,7 +261,7 @@ const dateObserver = new (class { try { // Discard lookups left recording by a render that threw stopLookups() - forgetTaskReads() + forgetHostSettings() for (const timeEl of this.elements) { if (!timeEl.isConnected) { this.elements.delete(timeEl) @@ -838,6 +839,8 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor } update() { + // Before reading any input, in case an override removes itself while being read + const plainAtStart = isPlainElement(this) const oldText: string = this.#renderRoot.textContent || this.textContent || '' const oldTitle: string = this.getAttribute('title') || '' let newTitle: string = oldTitle @@ -892,7 +895,17 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor // Record the inputs this render used before event listeners can change any of them const lookups = stopLookups() - this.#recordRender(date, now, format, displayUserPreferredAbsoluteTime, timeZone, hourCycle, newTitle, lookups) + this.#recordRender( + date, + now, + format, + displayUserPreferredAbsoluteTime, + timeZone, + hourCycle, + newTitle, + lookups, + plainAtStart, + ) if (newText !== oldText || newTitle !== oldTitle) { this.dispatchEvent(new RelativeTimeUpdatedEvent(oldText, newText, oldTitle, newTitle)) @@ -919,17 +932,16 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor hourCycle: Intl.DateTimeFormatOptions['hourCycle'], newTitle: string, lookups: Lookups | null, + plainAtStart: boolean, ): void { - if (!isPlainElement(this)) { - this.#stableUntil = -Infinity - return - } + this.#stableUntil = -Infinity + if (!plainAtStart || !isPlainElement(this)) return // A title that is computed but not written (`no-title`) fires an event on every render const titleSettled = newTitle === (this.getAttribute('title') || '') const stableFor = titleSettled && lookups ? this.#stableFor(date, now, format, displayUserPreferredAbsoluteTime, timeZone) : 0 - this.#stableUntil = now + stableFor if (stableFor <= 0) return + this.#stableUntil = now + stableFor this.#renderedLookups = lookups! this.#renderedAt = now this.#renderedAttributeVersion = this.#attributeVersion @@ -1040,10 +1052,7 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor const definedMembers: DefinedMembers = { parent: Object.getPrototypeOf(RelativeTimeElement.prototype), descriptors: new Map( - Reflect.ownKeys(RelativeTimeElement.prototype).map(key => [ - key, - Object.getOwnPropertyDescriptor(RelativeTimeElement.prototype, key)!, - ]), + renderedMembers.map(name => [name, Object.getOwnPropertyDescriptor(RelativeTimeElement.prototype, name)!]), ), } diff --git a/test/relative-time.js b/test/relative-time.js index 59497538..96324408 100644 --- a/test/relative-time.js +++ b/test/relative-time.js @@ -4129,6 +4129,100 @@ suite('relative-time', function () { assert.equal(rendered(el), freshRender(el)) }) + // Replaces the prototype's `date` getter until the returned function is called + function replaceDateGetter(get) { + const original = Object.getOwnPropertyDescriptor(RelativeTimeElement.prototype, 'date') + Object.defineProperty(RelativeTimeElement.prototype, 'date', {configurable: true, get}) + return () => Object.defineProperty(RelativeTimeElement.prototype, 'date', original) + } + + test('a prototype change made by an update listener during a tick re-renders later elements', async () => { + const start = Date.parse('2026-03-11T12:00:00.000Z') + const setClock = useClock(start) + const fresh = relativeTime({datetime: new realDate(start - 2 * second).toISOString()}) + const old = relativeTime({datetime: '2026-03-11T09:00:00.000Z'}) + await Promise.resolve() + let restore = null + fresh.addEventListener( + 'relative-time-updated', + () => { + restore = replaceDateGetter(() => new realDate('2020-01-01T09:00:00.000Z')) + }, + {once: true}, + ) + try { + setClock(start + 10 * second) + tick() + assert.equal(rendered(old), freshRender(old)) + assert.equal(old.shadowRoot.textContent, 'on Jan 1, 2020') + } finally { + if (restore) restore() + } + }) + + test('a prototype change made right after inserting is seen by the queued attribute update', async () => { + const start = Date.parse('2026-03-12T12:00:00.000Z') + useClock(start) + const el = relativeTime({datetime: '2026-03-12T09:00:00.000Z'}) + const restore = replaceDateGetter(() => new realDate('2026-03-12T07:00:00.000Z')) + try { + await Promise.resolve() + await Promise.resolve() + assert.equal(el.shadowRoot.textContent, '5 hours ago') + } finally { + restore() + } + const update = RelativeTimeElement.prototype.update + let wrapped = 0 + const other = relativeTime({datetime: '2026-03-12T09:00:00.000Z'}) + RelativeTimeElement.prototype.update = function () { + wrapped++ + return update.call(this) + } + try { + await Promise.resolve() + await Promise.resolve() + } finally { + RelativeTimeElement.prototype.update = update + } + assert.equal(wrapped, 1) + assert.equal(other.shadowRoot.textContent, '3 hours ago') + }) + + test('an instance override that removes itself during a render is not skipped next time', async () => { + const start = Date.parse('2026-03-13T12:00:00.000Z') + const setClock = useClock(start) + const el = relativeTime({datetime: '2026-03-13T09:00:00.000Z'}) + relativeTime({datetime: new realDate(start - 2 * second).toISOString()}) + await Promise.resolve() + Object.defineProperty(el, 'date', { + configurable: true, + get() { + delete el.date + return new realDate('2026-03-13T07:00:00.000Z') + }, + }) + el.update() + assert.equal(el.shadowRoot.textContent, '5 hours ago') + setClock(start + second) + tick() + assert.equal(el.shadowRoot.textContent, '3 hours ago') + }) + + test('a clock moving back after a render that cannot be skipped re-renders', async () => { + const start = Date.parse('2026-03-14T12:00:00.000Z') + const setClock = useClock(start) + const el = relativeTime({datetime: new realDate(start - 27 * day - 23 * hour).toISOString()}) + relativeTime({datetime: new realDate(start - 2 * second).toISOString()}) + await Promise.resolve() + // Past 28 days the text is never skipped + setClock(start + 2 * hour) + tick() + setClock(start + 30 * minute) + tick() + assert.equal(rendered(el), freshRender(el)) + }) + test('an attribute change re-renders', async () => { useClock(Date.parse('2026-03-07T12:00:00.000Z')) const el = relativeTime({datetime: '2026-03-07T09:00:00.000Z'}) From 285cacc61508c60f2b676e4d4a967a1aa90fd5c7 Mon Sep 17 00:00:00 2001 From: Matthew Costabile <8616962+mattcosta7@users.noreply.github.com> Date: Sun, 8 Nov 2026 15:01:01 -0500 Subject: [PATCH 5/6] Detect members added to the prototype, and check overrides before the 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> --- src/relative-time-element.ts | 41 +++++++++++++++++++++++++++++++----- test/relative-time.js | 37 ++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/src/relative-time-element.ts b/src/relative-time-element.ts index 7527c260..e8aab457 100644 --- a/src/relative-time-element.ts +++ b/src/relative-time-element.ts @@ -203,11 +203,31 @@ const renderedMembers = [ 'prefix', ] as const -// Whether those members are still the ones `RelativeTimeElement` was defined with. Checked on every -// call, because page code can replace one at any time, including from an update listener. +// Whether the prototype still has exactly the members it was defined with, and the members a render +// reads are unchanged. An added member, such as `getAttribute`, would shadow a built-in a render +// calls. Page code can change the prototype whenever it runs, so the answer is only reused until +// `pageCodeMayHaveRun()`: within a tick, between elements that neither render nor run overrides. +let pageCodeRuns = 0 +let classCheckedAfter = -1 +let classUnchanged = false +function pageCodeMayHaveRun(): void { + pageCodeRuns++ +} function isClassUnchanged(): boolean { + if (classCheckedAfter !== pageCodeRuns) { + classCheckedAfter = pageCodeRuns + classUnchanged = classMatchesDefinition() + } + return classUnchanged +} +function classMatchesDefinition(): boolean { const proto = RelativeTimeElement.prototype if (Object.getPrototypeOf(proto) !== definedMembers.parent) return false + const keys = Reflect.ownKeys(proto) + if (keys.length !== definedMembers.keys.size) return false + for (const key of keys) { + if (!definedMembers.keys.has(key)) return false + } for (const [name, defined] of definedMembers.descriptors) { const current = Object.getOwnPropertyDescriptor(proto, name) if (!current || defined.get !== current.get || defined.set !== current.set || defined.value !== current.value) { @@ -219,6 +239,7 @@ function isClassUnchanged(): boolean { interface DefinedMembers { parent: object | null + keys: Set descriptors: Map } @@ -262,13 +283,18 @@ const dateObserver = new (class { // Discard lookups left recording by a render that threw stopLookups() forgetHostSettings() + pageCodeMayHaveRun() for (const timeEl of this.elements) { + // Before anything reads the element, as an override can run page code or remove itself + const plain = isPlainElement(timeEl) if (!timeEl.isConnected) { this.elements.delete(timeEl) + if (!plain) pageCodeMayHaveRun() continue } nearestDistance = Math.min(nearestDistance, getUnitFactor(timeEl)) - timeEl[refresh]() + if (!plain) pageCodeMayHaveRun() + timeEl[refresh](plain) } } finally { this.updating = false @@ -839,6 +865,7 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor } update() { + pageCodeMayHaveRun() // Before reading any input, in case an override removes itself while being read const plainAtStart = isPlainElement(this) const oldText: string = this.#renderRoot.textContent || this.textContent || '' @@ -909,6 +936,7 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor if (newText !== oldText || newTitle !== oldTitle) { this.dispatchEvent(new RelativeTimeUpdatedEvent(oldText, newText, oldTitle, newTitle)) + pageCodeMayHaveRun() } const shouldObserve = @@ -958,9 +986,11 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor this.#renderedFontVariantNumeric = span?.style.fontVariantNumeric ?? '' } - [refresh](): void { + // `plain` is passed when the shared timer checked the element before reading it + [refresh](plain?: boolean): void { + if (!dateObserver.updating) pageCodeMayHaveRun() if ( - !isPlainElement(this) || + !(plain ?? isPlainElement(this)) || typeof Intl === 'undefined' || !Intl.DateTimeFormat || !Intl.RelativeTimeFormat || @@ -1051,6 +1081,7 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor const definedMembers: DefinedMembers = { parent: Object.getPrototypeOf(RelativeTimeElement.prototype), + keys: new Set(Reflect.ownKeys(RelativeTimeElement.prototype)), descriptors: new Map( renderedMembers.map(name => [name, Object.getOwnPropertyDescriptor(RelativeTimeElement.prototype, name)!]), ), diff --git a/test/relative-time.js b/test/relative-time.js index 96324408..869d03f5 100644 --- a/test/relative-time.js +++ b/test/relative-time.js @@ -4223,6 +4223,43 @@ suite('relative-time', function () { assert.equal(rendered(el), freshRender(el)) }) + test('a member added to the prototype that shadows a built-in always re-renders', async () => { + const start = Date.parse('2026-03-15T12:00:00.000Z') + const setClock = useClock(start) + const el = relativeTime({datetime: '2026-03-15T09:00:00.000Z'}) + relativeTime({datetime: new realDate(start - 2 * second).toISOString()}) + await Promise.resolve() + RelativeTimeElement.prototype.getAttribute = function (name) { + return name === 'format-style' ? 'narrow' : Element.prototype.getAttribute.call(this, name) + } + try { + setClock(start + second) + tick() + assert.equal(el.shadowRoot.textContent, '3h ago') + } finally { + delete RelativeTimeElement.prototype.getAttribute + } + }) + + test('an instance override that removes itself when the timer reads it is still rendered', async () => { + const start = Date.parse('2026-03-16T12:00:00.000Z') + const setClock = useClock(start) + const el = relativeTime({datetime: '2026-03-16T09:00:00.000Z'}) + relativeTime({datetime: new realDate(start - 2 * second).toISOString()}) + await Promise.resolve() + Object.defineProperty(el, 'date', { + configurable: true, + get() { + delete el.date + return new realDate('2026-03-16T09:00:00.000Z') + }, + }) + const before = updates.get(el) + setClock(start + second) + tick() + assert.equal(updates.get(el), before + 1) + }) + test('an attribute change re-renders', async () => { useClock(Date.parse('2026-03-07T12:00:00.000Z')) const el = relativeTime({datetime: '2026-03-07T09:00:00.000Z'}) From fa27819cc2b292d6ddecc4d89403a144ebcd5052 Mon Sep 17 00:00:00 2001 From: Matthew Costabile <8616962+mattcosta7@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:05:30 -0500 Subject: [PATCH 6/6] Compare every prototype member, and record a render's inputs before it writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- src/relative-time-element.ts | 116 ++++++++++++++++++++--------------- test/relative-time.js | 82 +++++++++++++++++++++++++ 2 files changed, 148 insertions(+), 50 deletions(-) diff --git a/src/relative-time-element.ts b/src/relative-time-element.ts index e8aab457..bec4b55e 100644 --- a/src/relative-time-element.ts +++ b/src/relative-time-element.ts @@ -178,35 +178,11 @@ function isPlainElement(el: RelativeTimeElement): boolean { ) } -// The prototype members a render reads, through `this` or the shared timer. Replacing any of them, -// such as the `date` getter or `update()` itself, can change what a render produces. -const renderedMembers = [ - 'update', - 'date', - 'datetime', - 'format', - 'formatStyle', - 'precision', - 'tense', - 'threshold', - 'noTitle', - 'timeZone', - 'hourCycle', - 'second', - 'minute', - 'hour', - 'weekday', - 'day', - 'month', - 'year', - 'timeZoneName', - 'prefix', -] as const - -// Whether the prototype still has exactly the members it was defined with, and the members a render -// reads are unchanged. An added member, such as `getAttribute`, would shadow a built-in a render -// calls. Page code can change the prototype whenever it runs, so the answer is only reused until -// `pageCodeMayHaveRun()`: within a tick, between elements that neither render nor run overrides. +// Whether the prototype still has exactly the members it was defined with, each unchanged. A replaced +// member, such as the `date` getter or `update()`, or an added one, such as a `getAttribute` that +// shadows the built-in, can change what a render produces. Page code can change the prototype +// whenever it runs, so the answer is only reused until `pageCodeMayHaveRun()`: within a tick, +// between elements that neither render nor have overrides. let pageCodeRuns = 0 let classCheckedAfter = -1 let classUnchanged = false @@ -224,23 +200,34 @@ function classMatchesDefinition(): boolean { const proto = RelativeTimeElement.prototype if (Object.getPrototypeOf(proto) !== definedMembers.parent) return false const keys = Reflect.ownKeys(proto) - if (keys.length !== definedMembers.keys.size) return false + if (keys.length !== definedMembers.descriptors.size) return false for (const key of keys) { - if (!definedMembers.keys.has(key)) return false - } - for (const [name, defined] of definedMembers.descriptors) { - const current = Object.getOwnPropertyDescriptor(proto, name) - if (!current || defined.get !== current.get || defined.set !== current.set || defined.value !== current.value) { + const defined = definedMembers.descriptors.get(key) + const current = Object.getOwnPropertyDescriptor(proto, key) + if ( + !defined || + !current || + defined.get !== current.get || + defined.set !== current.set || + defined.value !== current.value + ) { return false } } return true } +interface RenderInputs { + attributeVersion: number + environment: number + lang: string | null + rootAbsolute: string | null + bodyAbsolute: string | null | undefined +} + interface DefinedMembers { parent: object | null - keys: Set - descriptors: Map + descriptors: Map } // Re-renders an element unless its output is known to be unchanged. Module-private so the @@ -295,6 +282,8 @@ const dateObserver = new (class { nearestDistance = Math.min(nearestDistance, getUnitFactor(timeEl)) if (!plain) pageCodeMayHaveRun() timeEl[refresh](plain) + // An override can run page code after calling the inherited `update()` + if (!plain) pageCodeMayHaveRun() } } finally { this.updating = false @@ -317,6 +306,8 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor // Until `#stableUntil`, re-rendering produces the same output as long as every input recorded // here is unchanged. See `#isStable` and `#stableFor`. #attributeVersion = 0 + // Set while `update()` writes the title it computed, which is not a change to its inputs + #settingTitle = false #stableUntil = -Infinity #renderedAt = 0 #renderedAttributeVersion = -1 @@ -845,7 +836,7 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor // Internal: Refresh the time element's formatted date when an attribute changes. attributeChangedCallback(attrName: string, oldValue: unknown, newValue: unknown): void { if (oldValue === newValue) return - this.#attributeVersion++ + if (!this.#settingTitle) this.#attributeVersion++ if (attrName === 'datetime' && typeof newValue === 'string' && newValue && !isValidDateTimeFormat(newValue)) { // eslint-disable-next-line i18n-text/no-en globalThis.console.warn(`The datetime attribute must use the ECMAScript date-time string format: ${newValue}`) @@ -866,8 +857,10 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor update() { pageCodeMayHaveRun() - // Before reading any input, in case an override removes itself while being read + // Before reading any input, in case an override removes itself while being read, and before + // writing to the DOM, which can run page code that changes an input const plainAtStart = isPlainElement(this) + const inputs = plainAtStart ? this.#readInputs() : null const oldText: string = this.#renderRoot.textContent || this.textContent || '' const oldTitle: string = this.getAttribute('title') || '' let newTitle: string = oldTitle @@ -875,6 +868,7 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor if (typeof Intl === 'undefined' || !Intl.DateTimeFormat || !date) { this.#stableUntil = -Infinity this.#renderRoot.textContent = oldText + pageCodeMayHaveRun() return } startLookups() @@ -887,7 +881,14 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor const hourCycle = this.hourCycle if (!this.#customTitle) { newTitle = this.#getFormattedTitle(date, locale, timeZone, hourCycle) || '' - if (newTitle && !this.noTitle && newTitle !== oldTitle) this.setAttribute('title', newTitle) + if (newTitle && !this.noTitle && newTitle !== oldTitle) { + this.#settingTitle = true + try { + this.setAttribute('title', newTitle) + } finally { + this.#settingTitle = false + } + } } const duration = elapsedTime(date, this.precision, now) @@ -931,7 +932,7 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor hourCycle, newTitle, lookups, - plainAtStart, + inputs, ) if (newText !== oldText || newTitle !== oldTitle) { @@ -949,6 +950,19 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor } else { dateObserver.unobserve(this) } + // Writing to the DOM can run page code, such as a removed custom element's disconnectedCallback + pageCodeMayHaveRun() + } + + // Inputs that can change without a call into this element, read before a render uses them + #readInputs(): RenderInputs { + return { + attributeVersion: this.#attributeVersion, + environment: environmentVersion, + lang: this.#rawLang, + rootAbsolute: this.ownerDocument.documentElement.getAttribute('data-prefers-absolute-time'), + bodyAbsolute: this.ownerDocument.body?.getAttribute('data-prefers-absolute-time'), + } } #recordRender( @@ -960,10 +974,10 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor hourCycle: Intl.DateTimeFormatOptions['hourCycle'], newTitle: string, lookups: Lookups | null, - plainAtStart: boolean, + inputs: RenderInputs | null, ): void { this.#stableUntil = -Infinity - if (!plainAtStart || !isPlainElement(this)) return + if (!inputs || !isPlainElement(this)) return // A title that is computed but not written (`no-title`) fires an event on every render const titleSettled = newTitle === (this.getAttribute('title') || '') const stableFor = @@ -972,14 +986,14 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor this.#stableUntil = now + stableFor this.#renderedLookups = lookups! this.#renderedAt = now - this.#renderedAttributeVersion = this.#attributeVersion - this.#renderedEnvironment = environmentVersion + this.#renderedAttributeVersion = inputs.attributeVersion + this.#renderedEnvironment = inputs.environment this.#renderedHostSettings = getHostSettings() - this.#renderedLang = this.#rawLang + this.#renderedLang = inputs.lang this.#renderedTimeZone = timeZone this.#renderedHourCycle = hourCycle - this.#renderedRootAbsolute = this.ownerDocument.documentElement.getAttribute('data-prefers-absolute-time') - this.#renderedBodyAbsolute = this.ownerDocument.body?.getAttribute('data-prefers-absolute-time') + this.#renderedRootAbsolute = inputs.rootAbsolute + this.#renderedBodyAbsolute = inputs.bodyAbsolute const span = this.#renderRoot.firstElementChild as HTMLElement | null this.#renderedText = span?.textContent ?? null this.#renderedAriaHidden = span?.getAttribute('aria-hidden') ?? null @@ -1081,9 +1095,11 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor const definedMembers: DefinedMembers = { parent: Object.getPrototypeOf(RelativeTimeElement.prototype), - keys: new Set(Reflect.ownKeys(RelativeTimeElement.prototype)), descriptors: new Map( - renderedMembers.map(name => [name, Object.getOwnPropertyDescriptor(RelativeTimeElement.prototype, name)!]), + Reflect.ownKeys(RelativeTimeElement.prototype).map(key => [ + key, + Object.getOwnPropertyDescriptor(RelativeTimeElement.prototype, key)!, + ]), ), } diff --git a/test/relative-time.js b/test/relative-time.js index 869d03f5..3f56b63c 100644 --- a/test/relative-time.js +++ b/test/relative-time.js @@ -4260,6 +4260,88 @@ suite('relative-time', function () { assert.equal(updates.get(el), before + 1) }) + test('a prototype change made by an instance override after rendering re-renders later elements', async () => { + const start = Date.parse('2026-03-17T12:00:00.000Z') + const setClock = useClock(start) + const first = relativeTime({datetime: '2026-03-17T09:00:00.000Z'}) + const later = relativeTime({datetime: '2026-03-17T09:00:00.000Z'}) + relativeTime({datetime: new realDate(start - 2 * second).toISOString()}) + await Promise.resolve() + let restore = null + first.update = function () { + RelativeTimeElement.prototype.update.call(this) + if (!restore) restore = replaceDateGetter(() => new realDate('2020-01-01T09:00:00.000Z')) + } + try { + setClock(start + second) + tick() + assert.equal(later.shadowRoot.textContent, 'on Jan 1, 2020') + } finally { + if (restore) restore() + } + }) + + test('replacing any prototype member always re-renders', async () => { + const start = Date.parse('2026-03-18T12:00:00.000Z') + const setClock = useClock(start) + const el = relativeTime({datetime: '2026-03-18T09:00:00.000Z'}) + relativeTime({datetime: new realDate(start - 2 * second).toISOString()}) + await Promise.resolve() + for (const name of ['connectedCallback', 'attributeChangedCallback', 'onRelativeTimeUpdated']) { + const original = Object.getOwnPropertyDescriptor(RelativeTimeElement.prototype, name) + // A wrapper that behaves the same, so only the replacement itself can matter + const replacement = original.get + ? { + ...original, + get() { + return original.get.call(this) + }, + } + : { + ...original, + value(...args) { + return original.value.apply(this, args) + }, + } + Object.defineProperty(RelativeTimeElement.prototype, name, replacement) + try { + const before = updates.get(el) + setClock(Date.now() + second) + tick() + assert.equal(updates.get(el), before + 1, name) + } finally { + Object.defineProperty(RelativeTimeElement.prototype, name, original) + } + } + }) + + test('an input changed by page code while a render writes the DOM re-renders', async () => { + const start = Date.parse('2026-03-19T12:00:00.000Z') + const setClock = useClock(start) + const el = relativeTime({datetime: '2026-03-19T09:00:00.000Z'}) + relativeTime({datetime: new realDate(start - 2 * second).toISOString()}) + await Promise.resolve() + if (!customElements.get('datetime-changer')) { + customElements.define( + 'datetime-changer', + class extends HTMLElement { + disconnectedCallback() { + this.host.setAttribute('datetime', '2026-03-19T08:00:00.000Z') + } + }, + ) + } + const changer = document.createElement('datetime-changer') + changer.host = el + el.shadowRoot.append(changer) + setClock(start + second) + tick() + await Promise.resolve() + await Promise.resolve() + assert.equal(el.shadowRoot.textContent, '4 hours ago') + assert.equal(rendered(el), freshRender(el)) + }) + test('an attribute change re-renders', async () => { useClock(Date.parse('2026-03-07T12:00:00.000Z')) const el = relativeTime({datetime: '2026-03-07T09:00:00.000Z'})