Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 26 additions & 4 deletions src/relative-time-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,16 +100,25 @@ if (typeof window !== 'undefined' && typeof window.addEventListener === 'functio

const dateObserver = new (class {
elements: Set<RelativeTimeElement> = new Set()
// Per-element next-update deadline (ms since epoch). Looked up by iterating
// this.elements, so a WeakMap (no iteration needed, better GC) suffices.
deadlines: WeakMap<RelativeTimeElement, number> = new WeakMap()
time = Infinity
updating = false

observe(element: RelativeTimeElement) {
this.elements.add(element)
// During a tick, deadline management is handled by update() itself after
// each element's update() call completes. Skip here to avoid clobbering
// the freshly-computed deadline.
if (this.updating) return
const date = element.date
if (date && date.getTime()) {
const ms = getUnitFactor(element)
const time = Date.now() + ms
// Always refresh the deadline so that attribute/datetime changes are
// reflected immediately rather than retaining a stale value.
this.deadlines.set(element, time)
if (time < this.time || this.time <= Date.now()) {
clearTimeout(this.timer)
this.timer = setTimeout(() => this.update(), ms)
Expand All @@ -121,24 +130,37 @@ const dateObserver = new (class {
unobserve(element: RelativeTimeElement) {
if (!this.elements.has(element)) return
this.elements.delete(element)
this.deadlines.delete(element)
}

timer: ReturnType<typeof setTimeout> = -1 as unknown as ReturnType<typeof setTimeout>
update() {
clearTimeout(this.timer)
if (!this.elements.size) return

let nearestDistance = Infinity
const now = Date.now()
let nearest = Infinity
this.updating = true
try {
for (const timeEl of this.elements) {
nearestDistance = Math.min(nearestDistance, getUnitFactor(timeEl))
timeEl.update()
let due = this.deadlines.get(timeEl) ?? 0
if (due <= now) {
try {
timeEl.update()
} catch (error) {
setTimeout(() => {
throw error
})
}
due = now + getUnitFactor(timeEl)
this.deadlines.set(timeEl, due)
}
nearest = Math.min(nearest, due - now)
}
} finally {
this.updating = false
}
this.time = Math.min(60 * 60 * 1000, nearestDistance)
this.time = Math.min(60 * 60 * 1000, Math.max(nearest, 0))
this.timer = setTimeout(() => this.update(), this.time)
this.time += Date.now()
}
Expand Down
231 changes: 231 additions & 0 deletions test/relative-time.js
Original file line number Diff line number Diff line change
Expand Up @@ -3262,4 +3262,235 @@ suite('relative-time', function () {
document.documentElement.removeAttribute('time-zone')
})
})

suite('per-element deadline scheduling', () => {
// Helpers shared by all tests in this suite.
let currentTime
let originalDateNow
let originalSetTimeout
let originalClearTimeout
// Map from fake timer id → {cb, due} where due is the absolute timestamp
// at which the timer should fire (in terms of our frozen currentTime).
let pendingTimers
let nextTimerId
// Track the final fake-clock value from each test so that the next test's
// currentTime starts ahead of any stale dateObserver.time left by the
// previous test. dateObserver.time ≤ prevFakeClock + maxInterval (1h), so
// adding 2h is always sufficient.
let lastCurrentTime = 0

// Advance simulated time by `ms` milliseconds and fire any timers that
// become due during that period, in chronological order. New timers
// scheduled by a callback are eligible to fire within the same call.
function advanceTicks(ms) {
const targetTime = currentTime + ms
let moreToFire = true
while (moreToFire) {
moreToFire = false
let earliestId = -1
let earliestDue = Infinity
for (const [id, timer] of pendingTimers) {
if (timer.due < earliestDue) {
earliestDue = timer.due
earliestId = id
}
}
if (earliestId >= 0 && earliestDue <= targetTime) {
currentTime = earliestDue
const cb = pendingTimers.get(earliestId).cb
pendingTimers.delete(earliestId)
try {
cb()
} catch (_) {
// Per-element error-isolation in dateObserver.update() re-throws
// errors via setTimeout(0). In a real browser those become
// unhandled-error events; in this synchronous simulator we swallow
// them so they don't abort advanceTicks and mask subsequent updates.
}
moreToFire = true
}
}
currentTime = targetTime
}

setup(() => {
// Start 2h ahead of the last fake-clock value from any previous test.
// dateObserver.time ≤ prevFakeClockEnd + maxInterval(1h), so +2h guarantees
// this.time <= Date.now() in observe(), which forces timer rescheduling
// when the first element is added in each test.
currentTime = Math.max(Date.now(), lastCurrentTime) + 2 * 60 * 60 * 1000
originalDateNow = Date.now
Date.now = () => currentTime

pendingTimers = new Map()
nextTimerId = 1

originalSetTimeout = window.setTimeout
originalClearTimeout = window.clearTimeout
globalThis.setTimeout = window.setTimeout = function (cb, ms) {
const id = nextTimerId++
pendingTimers.set(id, {cb, due: currentTime + (ms != null ? ms : 0)})
return id
}
globalThis.clearTimeout = window.clearTimeout = function (id) {
pendingTimers.delete(id)
}
})

teardown(() => {
// Save the final fake-clock value before restoring the real clock.
lastCurrentTime = currentTime
Date.now = originalDateNow
globalThis.setTimeout = window.setTimeout = originalSetTimeout
globalThis.clearTimeout = window.clearTimeout = originalClearTimeout
pendingTimers = null
})

test('slow elements are not re-formatted on fast-cadence ticks', async () => {
// One second-precision element forces a 1s tick interval.
const fastEl = document.createElement('relative-time')
fastEl.setAttribute('format', 'duration')
fastEl.setAttribute('precision', 'second')
fastEl.setAttribute('datetime', new Date(currentTime - 5000).toISOString())

// 3-day-old elements: unit factor = 1 h, still within the P30D threshold
// so shouldObserve is true.
const slowEls = Array.from({length: 5}, () => {
const el = document.createElement('relative-time')
el.setAttribute('datetime', new Date(currentTime - 3 * 24 * 60 * 60 * 1000).toISOString())
return el
})

try {
fixture.append(fastEl)
for (const el of slowEls) fixture.append(el)
await Promise.resolve()

// Install spies AFTER the initial synchronous render.
const slowUpdateCounts = slowEls.map(el => {
let count = 0
const orig = el.update.bind(el)
el.update = function () {
count++
return orig()
}
return {get: () => count}
})

// 5 simulated seconds → ~5 ticks at the 1s fast cadence.
advanceTicks(5000)

for (let i = 0; i < slowUpdateCounts.length; i++) {
assert.equal(slowUpdateCounts[i].get(), 0, `slow element ${i} must not update during fast ticks`)
}
} finally {
fastEl.disconnectedCallback()
for (const el of slowEls) el.disconnectedCallback()
}
})

test('slow elements still update when their own deadline passes', async () => {
const el = document.createElement('relative-time')
// 3-day-old timestamp → unit factor = 1 h.
el.setAttribute('datetime', new Date(currentTime - 3 * 24 * 60 * 60 * 1000).toISOString())

try {
fixture.append(el)
await Promise.resolve()

let updateCount = 0
const orig = el.update.bind(el)
el.update = function () {
updateCount++
return orig()
}

// Advance more than 1 hour so the element's 1h deadline is exceeded.
advanceTicks(61 * 60 * 1000)

assert.isAbove(updateCount, 0, 'slow element must update after its hour deadline passes')
} finally {
el.disconnectedCallback()
}
})

test('first render is synchronous on connectedCallback', () => {
const el = document.createElement('relative-time')
el.setAttribute('datetime', new Date(currentTime - 5000).toISOString())
assert.equal(el.shadowRoot.textContent, '')
el.connectedCallback()
// update() is called synchronously by connectedCallback.
assert.ok(el.shadowRoot.textContent.length > 0, 'expected rendered text right after connectedCallback')
el.disconnectedCallback()
})

test('datetime change resets the deadline to the new cadence', async () => {
const el = document.createElement('relative-time')
// Start with a 3-day-old timestamp (hour-cadence).
el.setAttribute('datetime', new Date(currentTime - 3 * 24 * 60 * 60 * 1000).toISOString())

try {
fixture.append(el)
await Promise.resolve()

let updateCount = 0
const orig = el.update.bind(el)
el.update = function () {
updateCount++
return orig()
}

// Change to a recent timestamp → deadline resets from 1 h → 1 s.
el.setAttribute('datetime', new Date(currentTime - 5000).toISOString())
await Promise.resolve()
// Discard the count from the microtask-triggered re-render.
updateCount = 0

// 3 simulated seconds → 3 ticks at the new 1s cadence.
advanceTicks(3000)

assert.isAbove(updateCount, 0, 'element must update after datetime change resets deadline to fast cadence')
} finally {
el.disconnectedCallback()
}
})

test('error in one element does not prevent others from updating', async () => {
const badEl = document.createElement('relative-time')
badEl.setAttribute('datetime', new Date(currentTime - 5000).toISOString())
badEl.setAttribute('format', 'duration')
badEl.setAttribute('precision', 'second')

const goodEl = document.createElement('relative-time')
goodEl.setAttribute('datetime', new Date(currentTime - 5000).toISOString())
goodEl.setAttribute('format', 'duration')
goodEl.setAttribute('precision', 'second')

try {
fixture.append(badEl)
fixture.append(goodEl)
await Promise.resolve()

badEl.update = function () {
throw new Error('simulated element error')
}

let goodUpdateCount = 0
const origGood = goodEl.update.bind(goodEl)
goodEl.update = function () {
goodUpdateCount++
return origGood()
}

// Advance 2 seconds — both elements' deadlines pass.
advanceTicks(2000)

assert.isAbove(goodUpdateCount, 0, 'goodEl must still update even though badEl threw')
assert.isAbove(pendingTimers.size, 0, 'observer must reschedule after an error')
} finally {
badEl.disconnectedCallback()
goodEl.disconnectedCallback()
}
})
})
})
Loading