Skip to content

Commit 03bd4f4

Browse files
committed
perf: skip redundant render-root and title writes on no-op ticks
The shared dateObserver ticks every registered <relative-time> 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.
1 parent c69538b commit 03bd4f4

2 files changed

Lines changed: 49 additions & 3 deletions

File tree

src/relative-time-element.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -364,13 +364,28 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor
364364
}
365365

366366
#updateRenderRootContent(content: string | null): void {
367+
const root = this.#renderRoot as Element
368+
const ariaHidden = this.hasAttribute('aria-hidden') && this.getAttribute('aria-hidden') === 'true'
369+
// Avoid dirtying the DOM (and invalidating layout) when nothing has changed.
370+
// This is common on periodic ticks where the rendered text is identical to
371+
// the previous tick (e.g. an item that still reads "3mo").
372+
const current = root.firstElementChild
373+
if (
374+
current &&
375+
root.childNodes.length === 1 &&
376+
current.getAttribute('part') === 'root' &&
377+
current.textContent === content &&
378+
(current.getAttribute('aria-hidden') === 'true') === ariaHidden
379+
) {
380+
return
381+
}
367382
const span = document.createElement('span')
368383
span.setAttribute('part', 'root')
369-
if (this.hasAttribute('aria-hidden') && this.getAttribute('aria-hidden') === 'true') {
384+
if (ariaHidden) {
370385
span.setAttribute('aria-hidden', 'true')
371386
}
372387
span.textContent = content
373-
;(this.#renderRoot as Element).replaceChildren(span)
388+
root.replaceChildren(span)
374389
}
375390

376391
#shouldDisplayUserPreferredAbsoluteTime(format: ResolvedFormat): boolean {
@@ -626,7 +641,7 @@ export class RelativeTimeElement extends HTMLElement implements Intl.DateTimeFor
626641
const now = Date.now()
627642
if (!this.#customTitle) {
628643
newTitle = this.#getFormattedTitle(date) || ''
629-
if (newTitle && !this.noTitle) this.setAttribute('title', newTitle)
644+
if (newTitle && !this.noTitle && newTitle !== oldTitle) this.setAttribute('title', newTitle)
630645
}
631646

632647
const duration = elapsedTime(date, this.precision, now)

test/relative-time.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,37 @@ suite('relative-time', function () {
109109
assert.equal(counter, 1)
110110
})
111111

112+
test('does not rebuild the render root when the displayed text is unchanged', async () => {
113+
const el = document.createElement('relative-time')
114+
el.setAttribute('datetime', new Date(Date.now() - 3 * 60 * 1000).toISOString())
115+
fixture.append(el)
116+
await Promise.resolve()
117+
const root = el.shadowRoot.querySelector('[part="root"]')
118+
assert.ok(root, 'expected a rendered [part="root"] element')
119+
const text = root.textContent
120+
121+
// A subsequent update that produces the same text must not replace the node.
122+
el.update()
123+
await Promise.resolve()
124+
const rootAfter = el.shadowRoot.querySelector('[part="root"]')
125+
assert.equal(rootAfter, root, 'render root node should be reused when text is unchanged')
126+
assert.equal(rootAfter.textContent, text)
127+
})
128+
129+
test('rebuilds the render root when the displayed text changes', async () => {
130+
const el = document.createElement('relative-time')
131+
el.setAttribute('datetime', new Date(Date.now() - 3 * 60 * 1000).toISOString())
132+
fixture.append(el)
133+
await Promise.resolve()
134+
const root = el.shadowRoot.querySelector('[part="root"]')
135+
const text = root.textContent
136+
137+
el.setAttribute('datetime', new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString())
138+
await Promise.resolve()
139+
const rootAfter = el.shadowRoot.querySelector('[part="root"]')
140+
assert.notEqual(rootAfter.textContent, text, 'text should have changed')
141+
})
142+
112143
test('calls update even after nullish datetime', async () => {
113144
const el = document.createElement('relative-time')
114145
el.setAttribute('datetime', '')

0 commit comments

Comments
 (0)