Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 27 additions & 8 deletions src/relative-time-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand Down Expand Up @@ -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
}
Comment thread
Copilot marked this conversation as resolved.
span.textContent = content
;(this.#renderRoot as Element).replaceChildren(span)
}

#shouldDisplayUserPreferredAbsoluteTime(format: ResolvedFormat): boolean {
Expand Down Expand Up @@ -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)
Expand Down
92 changes: 92 additions & 0 deletions test/relative-time.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Comment on lines +121 to +125
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', '')
Expand Down
Loading