From 03bd4f4cf023635ff6b71ece0e9694d003b89ad7 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Wed, 15 Jul 2026 00:09:20 +0000 Subject: [PATCH 1/3] perf: skip redundant render-root and title writes on no-op ticks The shared dateObserver ticks every registered on a single timer, calling update() on each. update() unconditionally rebuilt the [part=root] span via replaceChildren and re-set the title attribute even when the computed text/title were identical to what was already rendered (common on periodic ticks, e.g. an item that still reads "3mo"). Guard #updateRenderRootContent to reuse the existing span when the content and aria-hidden state are unchanged, and skip setAttribute('title') when the title is unchanged. This avoids needless DOM churn and style invalidation on every no-op tick. --- src/relative-time-element.ts | 21 ++++++++++++++++++--- test/relative-time.js | 31 +++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/relative-time-element.ts b/src/relative-time-element.ts index dec6e8b3..ad31d01e 100644 --- a/src/relative-time-element.ts +++ b/src/relative-time-element.ts @@ -364,13 +364,28 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor } #updateRenderRootContent(content: string | null): void { + const root = this.#renderRoot as Element + const ariaHidden = this.hasAttribute('aria-hidden') && this.getAttribute('aria-hidden') === 'true' + // Avoid dirtying the DOM (and invalidating layout) when nothing has changed. + // This is common on periodic ticks where the rendered text is identical to + // the previous tick (e.g. an item that still reads "3mo"). + const current = root.firstElementChild + if ( + current && + root.childNodes.length === 1 && + current.getAttribute('part') === 'root' && + current.textContent === content && + (current.getAttribute('aria-hidden') === 'true') === ariaHidden + ) { + return + } const span = document.createElement('span') span.setAttribute('part', 'root') - if (this.hasAttribute('aria-hidden') && this.getAttribute('aria-hidden') === 'true') { + if (ariaHidden) { span.setAttribute('aria-hidden', 'true') } span.textContent = content - ;(this.#renderRoot as Element).replaceChildren(span) + root.replaceChildren(span) } #shouldDisplayUserPreferredAbsoluteTime(format: ResolvedFormat): boolean { @@ -626,7 +641,7 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor const now = Date.now() if (!this.#customTitle) { newTitle = this.#getFormattedTitle(date) || '' - if (newTitle && !this.noTitle) this.setAttribute('title', newTitle) + if (newTitle && !this.noTitle && newTitle !== oldTitle) this.setAttribute('title', newTitle) } const duration = elapsedTime(date, this.precision, now) diff --git a/test/relative-time.js b/test/relative-time.js index d4ad5f2c..1937e29b 100644 --- a/test/relative-time.js +++ b/test/relative-time.js @@ -109,6 +109,37 @@ suite('relative-time', function () { assert.equal(counter, 1) }) + test('does not rebuild the render root when the displayed text is unchanged', async () => { + const el = document.createElement('relative-time') + el.setAttribute('datetime', new Date(Date.now() - 3 * 60 * 1000).toISOString()) + fixture.append(el) + await Promise.resolve() + const root = el.shadowRoot.querySelector('[part="root"]') + assert.ok(root, 'expected a rendered [part="root"] element') + const text = root.textContent + + // A subsequent update that produces the same text must not replace the node. + el.update() + await Promise.resolve() + const rootAfter = el.shadowRoot.querySelector('[part="root"]') + assert.equal(rootAfter, root, 'render root node should be reused when text is unchanged') + assert.equal(rootAfter.textContent, text) + }) + + test('rebuilds the render root when the displayed text changes', async () => { + const el = document.createElement('relative-time') + el.setAttribute('datetime', new Date(Date.now() - 3 * 60 * 1000).toISOString()) + fixture.append(el) + await Promise.resolve() + const root = el.shadowRoot.querySelector('[part="root"]') + const text = root.textContent + + el.setAttribute('datetime', new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString()) + await Promise.resolve() + const rootAfter = el.shadowRoot.querySelector('[part="root"]') + assert.notEqual(rootAfter.textContent, text, 'text should have changed') + }) + test('calls update even after nullish datetime', async () => { const el = document.createElement('relative-time') el.setAttribute('datetime', '') From a4659574a15651dfe76a7da6c6301fa8bf82fcb3 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Wed, 15 Jul 2026 00:29:24 +0000 Subject: [PATCH 2/3] perf: mutate render-root span in place instead of rebuilding Reuse the existing part="root" span across ticks and update textContent/aria-hidden in place, skipping the write when unchanged. Avoids node allocation + replaceChildren on every change and keeps a stable part node. Also types #renderRoot as Node & ParentNode to drop the Element cast. --- src/relative-time-element.ts | 36 ++++++++++++++++++++---------------- test/relative-time.js | 3 ++- 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/src/relative-time-element.ts b/src/relative-time-element.ts index ad31d01e..649e7fc9 100644 --- a/src/relative-time-element.ts +++ b/src/relative-time-element.ts @@ -164,7 +164,11 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor return isBrowser12hCycle() ? 'h12' : 'h23' } - #renderRoot: Node = this.shadowRoot ? this.shadowRoot : this.attachShadow ? this.attachShadow({mode: 'open'}) : this + #renderRoot: Node & ParentNode = this.shadowRoot + ? this.shadowRoot + : this.attachShadow + ? this.attachShadow({mode: 'open'}) + : this static get observedAttributes() { return [ @@ -364,28 +368,28 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor } #updateRenderRootContent(content: string | null): void { - const root = this.#renderRoot as Element + const root = this.#renderRoot const ariaHidden = this.hasAttribute('aria-hidden') && this.getAttribute('aria-hidden') === 'true' - // Avoid dirtying the DOM (and invalidating layout) when nothing has changed. + // Reuse the existing `part="root"` span across ticks and mutate it in place. + // Reading the DOM tree/attributes below does not force style or layout recalc + // (unlike geometry reads such as offsetWidth or innerText), so the only cost + // that matters is the write — which we skip when nothing actually changed. // This is common on periodic ticks where the rendered text is identical to // the previous tick (e.g. an item that still reads "3mo"). - const current = root.firstElementChild - if ( - current && - root.childNodes.length === 1 && - current.getAttribute('part') === 'root' && - current.textContent === content && - (current.getAttribute('aria-hidden') === 'true') === ariaHidden - ) { - return + let span = root.firstElementChild + if (!span || span.getAttribute('part') !== 'root' || root.childNodes.length !== 1) { + span = document.createElement('span') + span.setAttribute('part', 'root') + root.replaceChildren(span) } - const span = document.createElement('span') - span.setAttribute('part', 'root') if (ariaHidden) { span.setAttribute('aria-hidden', 'true') + } else { + span.removeAttribute('aria-hidden') + } + if (span.textContent !== content) { + span.textContent = content } - span.textContent = content - root.replaceChildren(span) } #shouldDisplayUserPreferredAbsoluteTime(format: ResolvedFormat): boolean { diff --git a/test/relative-time.js b/test/relative-time.js index 1937e29b..df92964f 100644 --- a/test/relative-time.js +++ b/test/relative-time.js @@ -126,7 +126,7 @@ suite('relative-time', function () { assert.equal(rootAfter.textContent, text) }) - test('rebuilds the render root when the displayed text changes', async () => { + test('reuses the render root node and updates text in place when the display changes', async () => { const el = document.createElement('relative-time') el.setAttribute('datetime', new Date(Date.now() - 3 * 60 * 1000).toISOString()) fixture.append(el) @@ -137,6 +137,7 @@ suite('relative-time', function () { el.setAttribute('datetime', new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString()) await Promise.resolve() const rootAfter = el.shadowRoot.querySelector('[part="root"]') + assert.equal(rootAfter, root, 'render root node should be reused across a text change') assert.notEqual(rootAfter.textContent, text, 'text should have changed') }) From 7ca8c841df2cedd5f0d7a08cd5c92cd587573590 Mon Sep 17 00:00:00 2001 From: Matthew Costabile Date: Wed, 15 Jul 2026 14:09:21 +0000 Subject: [PATCH 3/3] perf: guard aria-hidden no-op writes and add no-op mutation tests - Skip same-value aria-hidden setAttribute/removeAttribute on the render span - Add MutationObserver tests asserting no shadow-root writes on no-op ticks (including aria-hidden), and that an unchanged title is not rewritten --- src/relative-time-element.ts | 4 +-- test/relative-time.js | 60 ++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/src/relative-time-element.ts b/src/relative-time-element.ts index 649e7fc9..3859a3f4 100644 --- a/src/relative-time-element.ts +++ b/src/relative-time-element.ts @@ -383,8 +383,8 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor root.replaceChildren(span) } if (ariaHidden) { - span.setAttribute('aria-hidden', 'true') - } else { + if (span.getAttribute('aria-hidden') !== 'true') span.setAttribute('aria-hidden', 'true') + } else if (span.hasAttribute('aria-hidden')) { span.removeAttribute('aria-hidden') } if (span.textContent !== content) { diff --git a/test/relative-time.js b/test/relative-time.js index df92964f..307ba594 100644 --- a/test/relative-time.js +++ b/test/relative-time.js @@ -141,6 +141,66 @@ suite('relative-time', function () { assert.notEqual(rootAfter.textContent, text, 'text should have changed') }) + test('emits no shadow-root mutations on a no-op update', async () => { + const el = document.createElement('relative-time') + el.setAttribute('datetime', new Date(Date.now() - 3 * 60 * 1000).toISOString()) + fixture.append(el) + await Promise.resolve() + + const records = [] + const observer = new MutationObserver(mutations => records.push(...mutations)) + observer.observe(el.shadowRoot, {subtree: true, childList: true, characterData: true, attributes: true}) + try { + el.update() + await Promise.resolve() + } finally { + observer.disconnect() + } + assert.deepEqual(records, [], 'a no-op update must not mutate the shadow root') + }) + + test('emits no shadow-root mutations on a no-op update when aria-hidden', async () => { + const el = document.createElement('relative-time') + el.setAttribute('aria-hidden', 'true') + el.setAttribute('datetime', new Date(Date.now() - 3 * 60 * 1000).toISOString()) + fixture.append(el) + await Promise.resolve() + const span = el.shadowRoot.querySelector('[part="root"]') + assert.equal(span.getAttribute('aria-hidden'), 'true', 'aria-hidden should be mirrored onto the span') + + const records = [] + const observer = new MutationObserver(mutations => records.push(...mutations)) + observer.observe(el.shadowRoot, {subtree: true, childList: true, characterData: true, attributes: true}) + try { + el.update() + await Promise.resolve() + } finally { + observer.disconnect() + } + assert.deepEqual(records, [], 'a no-op update must not rewrite aria-hidden or text') + }) + + test('does not rewrite the title attribute on a no-op update', async () => { + const el = document.createElement('relative-time') + el.setAttribute('datetime', new Date(Date.now() - 3 * 60 * 1000).toISOString()) + fixture.append(el) + await Promise.resolve() + const title = el.getAttribute('title') + assert.ok(title, 'expected a formatted title to be set') + + const records = [] + const observer = new MutationObserver(mutations => records.push(...mutations)) + observer.observe(el, {attributes: true, attributeFilter: ['title']}) + try { + el.update() + await Promise.resolve() + } finally { + observer.disconnect() + } + assert.deepEqual(records, [], 'an unchanged formatted title must not be written again') + assert.equal(el.getAttribute('title'), title) + }) + test('calls update even after nullish datetime', async () => { const el = document.createElement('relative-time') el.setAttribute('datetime', '')