diff --git a/src/intl-cache.ts b/src/intl-cache.ts index 9ed1cd9..82aaf0b 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, false]) + else lookupsOverflowed = true + } const value = this.#map.get(key) if (value !== undefined) { this.#map.delete(key) @@ -31,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) @@ -46,6 +54,45 @@ class LRUCache { const registeredCaches: Array> = [] +// 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. +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 + 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 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, missing] of recorded) { + if (missing) { + if (cache.has(key)) return false + } else 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 acad570..bec4b55 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,131 @@ 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 + +// 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 +} + +// 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 && isClassUnchanged() && !shadowsInheritedMember(el) + ) +} + +// 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 +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.descriptors.size) return false + for (const key of keys) { + 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 + descriptors: Map +} + +// 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 +267,23 @@ const dateObserver = new (class { let nearestDistance = Infinity this.updating = true try { + // 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.update() + if (!plain) pageCodeMayHaveRun() + timeEl[refresh](plain) + // An override can run page code after calling the inherited `update()` + if (!plain) pageCodeMayHaveRun() } } finally { this.updating = false @@ -169,8 +303,32 @@ 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 + // Set while `update()` writes the title it computed, which is not a change to its inputs + #settingTitle = false + #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 +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 + 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}`) @@ -690,21 +849,29 @@ 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 })() } } update() { + pageCodeMayHaveRun() + // 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 const date = this.date if (typeof Intl === 'undefined' || !Intl.DateTimeFormat || !date) { + this.#stableUntil = -Infinity this.#renderRoot.textContent = oldText + pageCodeMayHaveRun() 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 @@ -714,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) @@ -747,8 +921,23 @@ 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, + inputs, + ) + if (newText !== oldText || newTitle !== oldTitle) { this.dispatchEvent(new RelativeTimeUpdatedEvent(oldText, newText, oldTitle, newTitle)) + pageCodeMayHaveRun() } const shouldObserve = @@ -761,7 +950,157 @@ 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( + date: Date, + now: number, + format: ResolvedFormat, + displayUserPreferredAbsoluteTime: boolean, + timeZone: string | undefined, + hourCycle: Intl.DateTimeFormatOptions['hourCycle'], + newTitle: string, + lookups: Lookups | null, + inputs: RenderInputs | null, + ): void { + this.#stableUntil = -Infinity + 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 = + titleSettled && lookups ? this.#stableFor(date, now, format, displayUserPreferredAbsoluteTime, timeZone) : 0 + if (stableFor <= 0) return + this.#stableUntil = now + stableFor + this.#renderedLookups = lookups! + this.#renderedAt = now + this.#renderedAttributeVersion = inputs.attributeVersion + this.#renderedEnvironment = inputs.environment + this.#renderedHostSettings = getHostSettings() + this.#renderedLang = inputs.lang + this.#renderedTimeZone = timeZone + this.#renderedHourCycle = hourCycle + 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 + this.#renderedFontVariantNumeric = span?.style.fontVariantNumeric ?? '' + } + + // `plain` is passed when the shared timer checked the element before reading it + [refresh](plain?: boolean): void { + if (!dateObserver.updating) pageCodeMayHaveRun() + if ( + !(plain ?? isPlainElement(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 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, + format: ResolvedFormat, + displayUserPreferredAbsoluteTime: boolean, + 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 + 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)) + 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) + } +} + +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/bundle.js b/test/bundle.js new file mode 100644 index 0000000..134aef3 --- /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/intl-cache.ts b/test/intl-cache.ts index 2998ebd..554ea71 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,53 @@ 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()) + }) + + 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 6ff90d8..3f56b63 100644 --- a/test/relative-time.js +++ b/test/relative-time.js @@ -3536,4 +3536,857 @@ suite('relative-time', function () { } }) }) + + suite('skipping re-renders that cannot change output', () => { + const originalSetTimeout = window.setTimeout + 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`. + 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 + } + 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 + Element.prototype.hasAttribute = hasAttribute + 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('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)) + }) + + // 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('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('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'}) + 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') + }) + + // 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)) + }) + }) })