diff --git a/src/relative-time-element.ts b/src/relative-time-element.ts index dec6e8b3..3859a3f4 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,13 +368,28 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor } #updateRenderRootContent(content: string | null): void { - const span = document.createElement('span') - span.setAttribute('part', 'root') - if (this.hasAttribute('aria-hidden') && this.getAttribute('aria-hidden') === 'true') { - span.setAttribute('aria-hidden', 'true') + const root = this.#renderRoot + const ariaHidden = this.hasAttribute('aria-hidden') && this.getAttribute('aria-hidden') === 'true' + // 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"). + 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) + } + if (ariaHidden) { + 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) { + span.textContent = content } - span.textContent = content - ;(this.#renderRoot as Element).replaceChildren(span) } #shouldDisplayUserPreferredAbsoluteTime(format: ResolvedFormat): boolean { @@ -626,7 +645,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..307ba594 100644 --- a/test/relative-time.js +++ b/test/relative-time.js @@ -109,6 +109,98 @@ 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('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) + 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.equal(rootAfter, root, 'render root node should be reused across a text change') + 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', '')