Skip to content

Stop retaining disconnected elements in the shared date observer - #396

Open
mattcosta7 wants to merge 1 commit into
mainfrom
fix-retain-disconnected-elements
Open

mattcosta7 wants to merge 1 commit into
mainfrom
fix-retain-disconnected-elements

Conversation

@mattcosta7

Copy link
Copy Markdown
Member

Summary

relative-time-element.ts keeps a module-level dateObserver with a strong elements = new Set(). update() ends with:

if (shouldObserve) dateObserver.observe(this)
else dateObserver.unobserve(this)

observe() is called regardless of whether the element is connected, and attributeChangedCallback runs update() after await Promise.resolve(). As a result, an element can be added to the Set and never removed. The Set then retains the element and its entire detached subtree forever, and every timer tick keeps calling update() on it.

Leak paths

  1. Never connected. An element is created, given a datetime, and never attached. attributeChangedCallback → update() → observe(this), and nothing ever calls disconnectedCallback.
  2. cloneNode(true) of a connected element. Clones are upgraded and receive attributeChangedCallback for the copied attributes, so they are observed while detached (typically template/clone-and-insert patterns, or clones that are discarded).
  3. Attribute change and removal in the same task. disconnectedCallback runs unobserve() immediately, and then the microtask-scheduled update() re-observes the now-disconnected element.

Fix

  • update(): only observe when connected: if (shouldObserve && this.isConnected) observe else unobserve.
  • dateObserver.update(): while iterating this.elements, drop any element that is no longer connected (delete + continue) as defensive cleanup.

Impact (observed on github.com with Turbo soft navigations)

Verified in Chromium and WebKit against the published 5.3.1 dist.

  • Chromium: total DOM nodes grew from ~3.6k to ~124k after 36 soft navigations, while live DOM stayed flat at ~3.4k. With the fix, total nodes plateau at ~9-10k.
  • WebKit: the observer Set grows by ~240 disconnected elements per 6 navigations.
  • Roughly ~20k DOM nodes were retained per 6 soft navigations.

Tests

Added a shared update timer suite in test/relative-time.js. The Set is module-private, so membership is detected by spying on Set.prototype.add (restored in teardown) and checking the Sets that received a RelativeTimeElement:

  • connected element is observed (no regression), and is unobserved on disconnect
  • never-connected element is not observed
  • clone of a connected element is not observed
  • element with an attribute change that is removed in the same task is not observed
  • a timer tick (captured setTimeout callback) updates connected elements and removes a disconnected element that was forced into the Set, without calling its update()

Three existing tests relied on observing elements that were never attached to the document; they now append the element to the fixture first.

Commands (Node 22.3.0, matching CI's Node 22):

Command Result
npx web-test-runner with new tests, without the src fix 666 passed, 4 failed (never connected, clone, attribute change + removal, timer tick)
npm run lint (tsc --noEmit && eslint) with fix passes
npx prettier --check src test with fix passes
npm test (build + lint + web-test-runner) with fix 670 passed, 0 failed

(Node 26 can't run web-test-runner here because of a yargs ESM/CJS require error, unrelated to this change.)

The module-level dateObserver holds a strong Set of every observed
element. update() re-observed the element whenever its format needed
ticking, regardless of connection state, so an element could be added
to the Set without ever being removed again:

 - an element given a datetime but never connected
 - a cloneNode(true) of a connected element (clones are upgraded and
   receive attributeChangedCallback)
 - an element whose attribute changed and which was removed in the
   same task: disconnectedCallback unobserves first, then the
   microtask-scheduled update() re-observes it

The Set then retains the element and its whole detached tree, and every
timer tick keeps calling update() on it.

Only observe elements that are connected, and drop any disconnected
element found while ticking as defensive cleanup.

Tests that relied on observing never-connected elements now attach them
to the fixture.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@mattcosta7
mattcosta7 marked this pull request as ready for review November 8, 2026 17:36
@mattcosta7
mattcosta7 requested a review from a team as a code owner November 8, 2026 17:36
Copilot AI auto review requested due to automatic review settings November 8, 2026 17:36

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

No active review issues remain for the observer lifecycle changes intended to prevent retention of detached elements.

No open issues 🎉

What changed in this PR

This PR prevents the shared date observer from retaining detached <relative-time> elements, reducing memory leaks and unnecessary timer updates.

Changes:

  • Observe elements only while they are connected to the document.
  • Remove disconnected elements during observer timer updates.
  • Add regression tests for detached elements, clones, removal timing, and timer cleanup; attach existing timer-test elements to the fixture.
File Description
test/​relative-time.js Adds observer lifecycle regression tests and updates timer-test setup.
src/​relative-time-element.ts Guards observer registration and removes disconnected elements during timer updates.

Review effort: Balanced (auto)

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants