From d1745dd180a0b106d6b0c132fee418ba6fc57aa9 Mon Sep 17 00:00:00 2001 From: Matthew Costabile <8616962+mattcosta7@users.noreply.github.com> Date: Sun, 8 Nov 2026 12:22:13 -0500 Subject: [PATCH 1/4] Isolate errors thrown by one element during a shared-timer tick A throw while updating one element aborted the whole tick: the elements after it were not updated and the timer was never rescheduled, so every timestamp on the page stopped updating. Each element's `getUnitFactor` and re-render now run in their own try/catch. A failing element is skipped for scheduling when its `getUnitFactor` threw, and its error is rethrown from a zero-delay `setTimeout`, so it still reaches the page as an uncaught error exactly once per throw. `setTimeout` is used rather than `reportError`, which older browsers and non-browser hosts lack and which the shared timer does not otherwise need. Lookups left recording by the failed render are discarded. This covers errors thrown by `getUnitFactor` as well as by the render (a throwing `date` getter, or an element over an hour old whose `time-zone` is invalid and so fails in `msUntilNextDay`). Errors in `connectedCallback` and the attribute-change microtask are unchanged. Supersedes #371, which wrapped only `update()`. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/relative-time-element.ts | 16 ++- test/relative-time.js | 213 +++++++++++++++++++++++++++++++++++ 2 files changed, 227 insertions(+), 2 deletions(-) diff --git a/src/relative-time-element.ts b/src/relative-time-element.ts index f575687..05084f1 100644 --- a/src/relative-time-element.ts +++ b/src/relative-time-element.ts @@ -221,8 +221,20 @@ const dateObserver = new (class { this.elements.delete(timeEl) continue } - nearestDistance = Math.min(nearestDistance, getUnitFactor(timeEl)) - timeEl[refresh]() + // 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 { + const factor = getUnitFactor(timeEl) + nearestDistance = Math.min(nearestDistance, factor) + timeEl[refresh]() + } catch (error) { + // Discard lookups left recording by a render that threw + stopLookups() + setTimeout(() => { + throw error + }) + } } } finally { this.updating = false diff --git a/test/relative-time.js b/test/relative-time.js index 6953e51..5cf7395 100644 --- a/test/relative-time.js +++ b/test/relative-time.js @@ -3537,6 +3537,219 @@ 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 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 originalUpdate = RelativeTimeElement.prototype.update From c35c916f90afa24d2ba965293bf0485e6d18feec Mon Sep 17 00:00:00 2001 From: Matthew Costabile <8616962+mattcosta7@users.noreply.github.com> Date: Sun, 8 Nov 2026 13:23:25 -0500 Subject: [PATCH 2/4] Isolate errors from the connected check too, and test a render failing part way An element whose isConnected getter throws still aborted the tick, because the check ran before the per-element try. It now runs inside it. A new test fails a render after it has started recording formatter lookups and checks that later elements still skip or render as before. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/relative-time-element.ts | 8 +++--- test/relative-time.js | 53 ++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/src/relative-time-element.ts b/src/relative-time-element.ts index 05084f1..a0d0a48 100644 --- a/src/relative-time-element.ts +++ b/src/relative-time-element.ts @@ -217,14 +217,14 @@ const dateObserver = new (class { stopLookups() forgetHostSettings() for (const timeEl of this.elements) { - if (!timeEl.isConnected) { - this.elements.delete(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[refresh]() diff --git a/test/relative-time.js b/test/relative-time.js index 5cf7395..98336ea 100644 --- a/test/relative-time.js +++ b/test/relative-time.js @@ -3627,6 +3627,59 @@ suite('relative-time', function () { 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') + const update = RelativeTimeElement.prototype.update + const updates = new Map() + RelativeTimeElement.prototype.update = function () { + updates.set(this, (updates.get(this) || 0) + 1) + return update.call(this) + } + try { + advance(1000) + assert.isNull(thrown(tick)) + } finally { + RelativeTimeElement.prototype.update = update + } + + assert.equal(updates.get(bad), 1) + assert.equal(rethrows.length, 1) + assert.instanceOf(thrown(rethrows[0]), RangeError) + 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)}) From 19b06cd851e9fa864eb0aa720daf2bf365194ffb Mon Sep 17 00:00:00 2001 From: Matthew Costabile <8616962+mattcosta7@users.noreply.github.com> Date: Sun, 8 Nov 2026 13:35:31 -0500 Subject: [PATCH 3/4] Count renders in the mid-render failure test without changing the class Patching RelativeTimeElement.prototype.update now turns skipping off, so the test counts renders through Element.prototype.hasAttribute instead. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- test/relative-time.js | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/test/relative-time.js b/test/relative-time.js index a5f8d25..245f241 100644 --- a/test/relative-time.js +++ b/test/relative-time.js @@ -3658,22 +3658,25 @@ suite('relative-time', function () { await settled() // An invalid zone on an ancestor makes the next render throw after it has started zone.setAttribute('time-zone', 'Not/AZone') - const update = RelativeTimeElement.prototype.update + // 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() - RelativeTimeElement.prototype.update = function () { - updates.set(this, (updates.get(this) || 0) + 1) - return update.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) } try { advance(1000) assert.isNull(thrown(tick)) } finally { - RelativeTimeElement.prototype.update = update + Element.prototype.hasAttribute = hasAttribute } - assert.equal(updates.get(bad), 1) + // 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) From a211efcac884c70b7db62aaf61c39124d9108def Mon Sep 17 00:00:00 2001 From: Matthew Costabile <8616962+mattcosta7@users.noreply.github.com> Date: Sun, 8 Nov 2026 15:28:44 -0500 Subject: [PATCH 4/4] Visit an element that failed at most once per tick An element whose update threw after moving itself in the document was added back to the end of the observer's Set and visited again, throwing every time, so the tick never finished. An element that fails is now skipped if the tick reaches it again. Elements added during a tick are still visited, as before. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/relative-time-element.ts | 5 +++++ test/relative-time.js | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/src/relative-time-element.ts b/src/relative-time-element.ts index 273d022..43ef4d5 100644 --- a/src/relative-time-element.ts +++ b/src/relative-time-element.ts @@ -284,7 +284,11 @@ 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 | undefined for (const timeEl of this.elements) { + 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.) @@ -301,6 +305,7 @@ const dateObserver = new (class { if (!plain) pageCodeMayHaveRun() timeEl[refresh](plain) } 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() diff --git a/test/relative-time.js b/test/relative-time.js index 530e214..7ab2ec6 100644 --- a/test/relative-time.js +++ b/test/relative-time.js @@ -3627,6 +3627,42 @@ suite('relative-time', function () { 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)})