From 462113b9766ae71334c8f16d88dc644d189ba9e7 Mon Sep 17 00:00:00 2001 From: Matthew Costabile <8616962+mattcosta7@users.noreply.github.com> Date: Sun, 8 Nov 2026 17:42:03 -0500 Subject: [PATCH 1/2] Isolate errors thrown by one element during a shared-timer tick All relative-time elements share one timer. If anything threw while the tick handled one element, including its isConnected check, getUnitFactor (for example an invalid time-zone on an element an hour or more old) or update(), the loop was abandoned: later elements weren't updated and the next tick was never scheduled, so every timestamp on the page froze. Each element is now handled in its own try/catch, and the error is rethrown from a task of its own so it is still reported, uncaught, exactly once. An element that fails is skipped if the tick reaches it again, as when it moved itself and rejoined the observer's set, so the tick always finishes. Supersedes #371. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/relative-time-element.ts | 25 +++- test/relative-time.js | 271 +++++++++++++++++++++++++++++++++++ 2 files changed, 291 insertions(+), 5 deletions(-) diff --git a/src/relative-time-element.ts b/src/relative-time-element.ts index acad570..09d6df2 100644 --- a/src/relative-time-element.ts +++ b/src/relative-time-element.ts @@ -143,13 +143,28 @@ const dateObserver = new (class { let nearestDistance = Infinity this.updating = true try { + // 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 | undefined for (const timeEl of this.elements) { - if (!timeEl.isConnected) { - this.elements.delete(timeEl) - continue + 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 { + if (!timeEl.isConnected) { + this.elements.delete(timeEl) + continue + } + const factor = getUnitFactor(timeEl) + nearestDistance = Math.min(nearestDistance, factor) + timeEl.update() + } catch (error) { + ;(failed ??= new Set()).add(timeEl) + setTimeout(() => { + throw error + }) } - nearestDistance = Math.min(nearestDistance, getUnitFactor(timeEl)) - timeEl.update() } } finally { this.updating = false diff --git a/test/relative-time.js b/test/relative-time.js index 6ff90d8..a4acb62 100644 --- a/test/relative-time.js +++ b/test/relative-time.js @@ -3536,4 +3536,275 @@ 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('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) + }) + }) }) From 8ddc52cec2d470e219cbfdec116f061524f8ae6d Mon Sep 17 00:00:00 2001 From: Matthew Costabile <8616962+mattcosta7@users.noreply.github.com> Date: Sun, 8 Nov 2026 17:51:08 -0500 Subject: [PATCH 2/2] Check that a failing update still sets the next tick Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- test/relative-time.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/test/relative-time.js b/test/relative-time.js index a4acb62..1025f93 100644 --- a/test/relative-time.js +++ b/test/relative-time.js @@ -3609,20 +3609,21 @@ suite('relative-time', function () { 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)}) + const bad = relativeTime({datetime: ago(2000)}) + const good = relativeTime({datetime: ago(55 * 1000)}) await settled() bad.update = () => { throw failure } const scheduled = timers.length - advance(minute) + advance(10 * 1000) tick() - assert.equal(good.shadowRoot.textContent, '4 minutes ago') + assert.equal(good.shadowRoot.textContent, '1 minute ago') assert.isAbove(timers.length, scheduled) - assert.equal(timers.at(-1).delay, minute) + // The failing element still sets the next tick, so it is retried when its own text would change + assert.equal(timers.at(-1).delay, 1000) assert.equal(rethrows.length, 1) assert.strictEqual(thrown(rethrows[0]), failure) })