Skip to content
38 changes: 28 additions & 10 deletions src/relative-time-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,19 +271,37 @@ const dateObserver = new (class {
stopLookups()
forgetHostSettings()
pageCodeMayHaveRun()
// An element that fails is not visited again this tick, even if it rejoined the Set by moving
// itself, so it can't keep the tick from finishing
let failed: Set<RelativeTimeElement> | undefined
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 (failed?.has(timeEl)) continue
// One element failing must not stop the others updating or the timer rescheduling. The error
// is rethrown from a task of its own so it is still reported, uncaught, exactly once.
// (`setTimeout` rather than `reportError`, which older browsers and non-browser hosts lack.)
try {
// 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
}
const factor = getUnitFactor(timeEl)
nearestDistance = Math.min(nearestDistance, factor)
if (!plain) pageCodeMayHaveRun()
timeEl[refresh](plain)
// An override can run page code after calling the inherited `update()`
if (!plain) pageCodeMayHaveRun()
continue
} catch (error) {
;(failed ??= new Set()).add(timeEl)
// Page code may have run before the throw, and a render may have left lookups recording
pageCodeMayHaveRun()
stopLookups()
setTimeout(() => {
throw error
})
}
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
Expand Down
305 changes: 305 additions & 0 deletions test/relative-time.js
Original file line number Diff line number Diff line change
Expand Up @@ -3537,6 +3537,311 @@ suite('relative-time', function () {
})
})

suite('errors thrown during a tick', () => {
const originalSetTimeout = window.setTimeout
const originalOnError = window.onerror
const minute = 60 * 1000
// Later tests start a day after the last one so that each schedules a timer of its own
let clockStart = Date.parse('2100-06-01T00:00:00.000Z')
let clock
let timers
let rethrows
let reportRethrowsToWindow

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
}

function ago(ms) {
return new dateNow(clock - ms).toISOString()
}

function tick() {
timers.at(-1).callback()
}

function thrown(callback) {
try {
callback()
} catch (error) {
return error
}
return null
}

setup(() => {
clockStart += 24 * 60 * minute
clock = clockStart
freezeTime(new Date(clock))
timers = []
rethrows = []
reportRethrowsToWindow = false
// The observer's timer has a delay; a rethrown error has none
globalThis.setTimeout = window.setTimeout = function (callback, delay) {
if (delay !== undefined) {
timers.push({callback, delay})
} else if (reportRethrowsToWindow) {
return originalSetTimeout(callback)
} else {
rethrows.push(callback)
}
return 0
}
})

teardown(() => {
globalThis.setTimeout = window.setTimeout = originalSetTimeout
window.onerror = originalOnError
})

async function settled() {
await Promise.resolve()
assert.isAbove(timers.length, 0)
}

function advance(ms) {
clock += ms
Date.now = () => clock
}

test('an element whose update throws does not stop the others or the timer', async () => {
const failure = new Error('update failed')
const bad = relativeTime({datetime: ago(3 * minute)})
const good = relativeTime({datetime: ago(3 * minute)})
await settled()
bad.update = () => {
throw failure
}
const scheduled = timers.length

advance(minute)
tick()

assert.equal(good.shadowRoot.textContent, '4 minutes ago')
assert.isAbove(timers.length, scheduled)
assert.equal(timers.at(-1).delay, minute)
assert.equal(rethrows.length, 1)
assert.strictEqual(thrown(rethrows[0]), failure)
})

test('an element that moves itself and throws is handled once per tick', async () => {
const failure = new Error('moved and failed')
const host = document.createElement('div')
fixture.append(host)
const bad = relativeTime({datetime: ago(3 * minute)}, host)
const good = relativeTime({datetime: ago(3 * minute)})
await settled()
const update = RelativeTimeElement.prototype.update
let calls = 0
let moving = false
bad.update = function () {
// Moving it renders it from connectedCallback, which adds it back to the end of the Set
if (moving) return update.call(this)
calls++
if (calls < 50) {
moving = true
try {
fixture.append(bad)
} finally {
moving = false
}
}
throw failure
}
const scheduled = timers.length

advance(minute)
tick()

assert.equal(calls, 1)
assert.equal(good.shadowRoot.textContent, '4 minutes ago')
assert.isAbove(timers.length, scheduled)
assert.equal(rethrows.length, 1)
assert.strictEqual(thrown(rethrows[0]), failure)
})

test('an element whose isConnected getter throws does not stop the others or the timer', async () => {
const failure = new Error('isConnected failed')
const bad = relativeTime({datetime: ago(3 * minute)})
const good = relativeTime({datetime: ago(3 * minute)})
await settled()
Object.defineProperty(bad, 'isConnected', {
get() {
throw failure
},
})
const scheduled = timers.length

advance(minute)
assert.isNull(thrown(tick))

assert.equal(good.shadowRoot.textContent, '4 minutes ago')
assert.isAbove(timers.length, scheduled)
assert.equal(timers.at(-1).delay, minute)
assert.equal(rethrows.length, 1)
assert.strictEqual(thrown(rethrows[0]), failure)
})

test('a render that fails part way leaves later elements skipping and rendering as before', async () => {
const zone = document.createElement('div')
fixture.append(zone)
const bad = relativeTime({datetime: ago(3 * minute)}, zone)
const old = relativeTime({datetime: ago(3 * 60 * minute)})
const fresh = relativeTime({datetime: ago(2000)})
await settled()
// An invalid zone on an ancestor makes the next render throw after it has started
zone.setAttribute('time-zone', 'Not/AZone')
// Count renders without changing the element's class, which would turn skipping off: a render
// without a custom title reads `no-title` exactly once
const hasAttribute = Element.prototype.hasAttribute
const updates = new Map()
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)
}
try {
advance(1000)
assert.isNull(thrown(tick))
} finally {
Element.prototype.hasAttribute = hasAttribute
}

// The failing render throws while formatting its title, before it reads `no-title`
assert.equal(rethrows.length, 1)
assert.instanceOf(thrown(rethrows[0]), RangeError)
assert.equal(bad.shadowRoot.textContent, '3 minutes ago')
assert.isUndefined(updates.get(old))
assert.equal(old.shadowRoot.textContent, '3 hours ago')
assert.equal(updates.get(fresh), 1)
assert.equal(fresh.shadowRoot.textContent, 'now')
})

test('an element whose date getter throws does not stop the others or the timer', async () => {
const failure = new Error('date failed')
const bad = relativeTime({datetime: ago(3 * minute)})
const good = relativeTime({datetime: ago(3 * minute)})
await settled()
Object.defineProperty(bad, 'date', {
get() {
throw failure
},
})
const scheduled = timers.length

advance(minute)
tick()

assert.equal(good.shadowRoot.textContent, '4 minutes ago')
assert.isAbove(timers.length, scheduled)
assert.equal(timers.at(-1).delay, minute)
assert.equal(rethrows.length, 1)
assert.strictEqual(thrown(rethrows[0]), failure)
})

test('an old element with an invalid time zone does not stop the others or the timer', async () => {
const zoned = document.createElement('div')
zoned.setAttribute('time-zone', 'UTC')
fixture.append(zoned)
relativeTime({datetime: ago(3 * 60 * minute)}, zoned)
const good = relativeTime({datetime: ago(3 * minute)})
await settled()
zoned.setAttribute('time-zone', 'Not/AZone')
const scheduled = timers.length

advance(minute)
tick()

assert.equal(good.shadowRoot.textContent, '4 minutes ago')
assert.isAbove(timers.length, scheduled)
assert.equal(timers.at(-1).delay, minute)
assert.equal(rethrows.length, 1)
assert.instanceOf(thrown(rethrows[0]), RangeError)
})

test('a failing element is reported once per tick and retried on the next', async () => {
const failures = []
const bad = relativeTime({datetime: ago(3 * minute)})
const good = relativeTime({datetime: ago(3 * minute)})
await settled()
bad.update = () => {
const failure = new Error(`failure ${failures.length}`)
failures.push(failure)
throw failure
}

advance(minute)
tick()
assert.equal(rethrows.length, 1)
advance(minute)
tick()

assert.equal(failures.length, 2)
assert.equal(rethrows.length, 2)
assert.deepEqual(rethrows.map(thrown), failures)
assert.equal(good.shadowRoot.textContent, '5 minutes ago')
})

test('every failing element is reported', async () => {
const first = new Error('first')
const second = new Error('second')
const a = relativeTime({datetime: ago(3 * minute)})
const b = relativeTime({datetime: ago(3 * minute)})
const good = relativeTime({datetime: ago(3 * minute)})
await settled()
a.update = () => {
throw first
}
Object.defineProperty(b, 'date', {
get() {
throw second
},
})

advance(minute)
tick()

assert.deepEqual(rethrows.map(thrown), [first, second])
assert.equal(good.shadowRoot.textContent, '4 minutes ago')
})

test('the error reaches the page as an uncaught error', async () => {
const failure = new Error('uncaught')
const bad = relativeTime({datetime: ago(3 * minute)})
await settled()
bad.update = () => {
throw failure
}
const reported = []
const listener = event => {
event.preventDefault()
reported.push(event.error)
}
window.onerror = null
window.addEventListener('error', listener)
reportRethrowsToWindow = true
try {
advance(minute)
tick()
await new Promise(resolve => originalSetTimeout(resolve, 10))
} finally {
window.removeEventListener('error', listener)
}
assert.deepEqual(reported, [failure])
})

test('elements that do not throw are not reported', async () => {
const good = relativeTime({datetime: ago(3 * minute)})
await settled()
advance(minute)
tick()
assert.equal(good.shadowRoot.textContent, '4 minutes ago')
assert.equal(rethrows.length, 0)
})
})

suite('skipping re-renders that cannot change output', () => {
const originalSetTimeout = window.setTimeout
const hasAttribute = Element.prototype.hasAttribute
Expand Down
Loading