Repository navigation
Stop retaining disconnected elements in the shared date observer - #396
Open
mattcosta7 wants to merge 1 commit into
Open
mattcosta7 wants to merge 1 commit into
mattcosta7 wants to merge 1 commit into
Conversation
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>
This was referenced Nov 8, 2026
mattcosta7
marked this pull request as ready for review
November 8, 2026 17:36
Contributor
There was a problem hiding this comment.
🟢 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 was referenced Nov 8, 2026
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
relative-time-element.tskeeps a module-leveldateObserverwith a strongelements = new Set().update()ends with:observe()is called regardless of whether the element is connected, andattributeChangedCallbackrunsupdate()afterawait 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 callingupdate()on it.Leak paths
datetime, and never attached.attributeChangedCallback→update()→observe(this), and nothing ever callsdisconnectedCallback.cloneNode(true)of a connected element. Clones are upgraded and receiveattributeChangedCallbackfor the copied attributes, so they are observed while detached (typically template/clone-and-insert patterns, or clones that are discarded).disconnectedCallbackrunsunobserve()immediately, and then the microtask-scheduledupdate()re-observes the now-disconnected element.Fix
update(): only observe when connected:if (shouldObserve && this.isConnected) observe else unobserve.dateObserver.update(): while iteratingthis.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.
Tests
Added a
shared update timersuite intest/relative-time.js. The Set is module-private, so membership is detected by spying onSet.prototype.add(restored in teardown) and checking the Sets that received aRelativeTimeElement:setTimeoutcallback) updates connected elements and removes a disconnected element that was forced into the Set, without calling itsupdate()Three existing tests relied on observing elements that were never attached to the document; they now
appendthe element to the fixture first.Commands (Node 22.3.0, matching CI's Node 22):
npx web-test-runnerwith new tests, without thesrcfixnpm run lint(tsc --noEmit && eslint) with fixnpx prettier --check src testwith fixnpm test(build + lint + web-test-runner) with fix(Node 26 can't run
web-test-runnerhere because of ayargsESM/CJSrequireerror, unrelated to this change.)