fix: removeBetween leaves the end marker it was asked to remove - #1292
Conversation
|
Design note: how to write a test that can actually SEE a renderer leak Worth writing down, because it cost me two useless tests and it will cost the next person the same. A test that drives the renderer with two separate Concretely, this shape cannot observe the bug: render(html`<div>${a()}</div>`, el); // site 1
render(html`<div>${b()}</div>`, el); // site 2, different `strings`, full rebuildand this one can: const view = (inner) => html`<div>${inner}</div>`; // ONE site
render(view(a()), el);
render(view(b()), el); // updates in place, hits the child-hole teardownThe same trap is why the leak survived this long in the first place. It is invisible to The rule I would apply next time: for anything asserting on what a teardown left behind, render from a single template site and assert on comment nodes directly, then prove the assertion reds with the fix reverted before trusting it. |
vivek7405
left a comment
There was a problem hiding this comment.
Went back over this one properly. The fix itself holds up: every removeBetween caller discards the instance right after, nothing anchors on the end marker afterwards, and the counterfactual is convincing on all three engines. I am happy with the terminator choice and with keeping the guard.
Two things I want changed before this goes in, both about the reasoning rather than the behaviour.
The docblock oversells why terminating on end is safe, in a way the very next paragraph contradicts. And the slot case does not hold itself to the standard the unit file's own docblock sets out, which matters because the two are asserting the same invariant and only one of them survives a rename.
Neither changes what ships.
vivek7405
left a comment
There was a problem hiding this comment.
Second pass, scoped to the docblock commit. It does not hold up, and the problem is that I was defending a design decision this change never made.
The terminator was already the end marker before any of this. Only the parent capture changed. Once I started arguing for it in prose I ended up claiming the walk is bounded by the instance's range, which it is not: it steps nextSibling and stops on end, so a range mutated underneath it overruns in exactly the way I was ascribing to the alternative. The rewrite therefore left no reason to prefer either terminator, and the sentence it replaced was the only thing that had ever made the contrast work.
Two knock-on problems from the same habit. The unit case pointed at the wrong catch block for that overrun, and the browser case said it justified the terminator when its callback only ever writes outside the torn-down range, so it proves nothing of the sort.
Cut the argument instead of patching it again.
vivek7405
left a comment
There was a problem hiding this comment.
Third pass, scoped to the docblock cleanup. The code is untouched by all of this and has been settled since the first commit; what keeps turning up is the same retracted argument surviving in places the previous cleanup did not reach.
Two of the three are in the PR description, which matters more than it looks: this squash-merges, so the description becomes the commit body on main. Merging as written would put an argument in the permanent history that the merged code explicitly says is wrong. The third is the browser suite header, fixed at the case but not at the top of the file.
I swept the whole diff for the rest of it this time rather than fixing what was pointed at. Nothing else survives.
vivek7405
left a comment
There was a problem hiding this comment.
Fourth pass. One real error of fact and two overclaims, all mine, all in prose again.
I had written that linkedom never runs disconnectedCallback, and used that as the reason the second browser case exists. It is not true. linkedom flips a reactive flag on the first customElements.define and fires the callback synchronously out of removeChild, walking the removed subtree, and this repo's own lifecycle test at packages/core/test/lifecycle/component-lifecycle.test.js already leans on it. I checked it directly before touching anything: a nested element inside a removed row fires. The case still earns its place, but on fidelity, running the three real engines, not on linkedom being incapable, and I have left a note in the file so the stronger version does not creep back.
The unit header claimed all seven cases count markers and assert rendered output. The guard case does neither, deliberately. And the non-zero baseline assertion I added two commits ago to stop a marker rename hollowing these out went into three of the four places that need it.
The code has not moved since the first commit.
vivek7405
left a comment
There was a problem hiding this comment.
Went through every remaining factual claim in the two new test files and the docblock myself, rather than waiting to be told about them one at a time. Three were wrong and are fixed in the commit above.
The docblock said a replacement instance gets its markers from buildDetached. The child-hole caller creates them inline, so that was right for most callers and wrong for the first one listed.
The unit header said one case per caller, when the two array cases share removeArrayItem.
And the guard case hedged that linkedom might not throw for a removeChild of a node under a different parent. It does throw, same as a browser. That hedge was hiding something better than it claimed: both of that case's arms discriminate, an unguarded removeChild reds the first and an unconditional end.remove() reds the second. I toggled each to confirm rather than reasoning about it.
The code is unchanged and has been since the first commit. Everything since has been me writing prose that outran what I had checked.
vivek7405
left a comment
There was a problem hiding this comment.
Fifth pass. One of these is the best finding of the review so far and it is worth stating plainly: the marker counter matches MARKER plus an s / e suffix spelled in the test file, so it only ever tracked half the marker text. Rename the other half in the renderer and all six leak cases stayed green, comparing zero against zero, satisfied by a counter that had gone blind. The rendered-output assertions do not cover it, because they catch rendering nothing, not counting nothing. Verified by mutating the suffix.
Every baseline now goes through a helper that refuses an empty or unpaired count, so the same mutation reds all seven where it used to red one. That is the guard I had added to the browser and slot cases two commits ago and, embarrassingly, called a completed sweep.
The remaining two are the same accounting error in two places: a case label naming a caller path nothing in the file actually takes, and the PR description still carrying the per-caller claim I corrected in the file but not in the body.
vivek7405
left a comment
There was a problem hiding this comment.
Sixth pass, and I am stopping the cycle here rather than running another.
Two findings, both the same shape as the last three rounds: a claim I corrected in one place and left standing in another. The slot helper's comment still credited reading MARKER with preventing a vacuous pass, which is the reasoning the previous commit disowned for the unit file, and the PR description still named buildDetached as the source of a replacement's markers when the child-hole path builds them inline. Both fixed above.
The pattern is worth naming, because it is mine and not the change's. The code has not moved since the first commit and every reviewer that has looked at it has confirmed it: the guard fires, the six callers all discard the instance, nothing anchors on the end marker, and the counterfactual reproduces exactly. What kept failing was the prose around it, in three ways. I wrote explanations broader than what I had actually checked. When one was corrected I fixed the instance I was shown instead of grepping every surface carrying the same sentence. And the docblock spent most of its length defending a terminator choice this change never made, which is argument surface that exists only to be wrong.
What I would keep from this: an explanatory comment should assert only what a reader could mechanically check, and a correction is not done until the source, the tests, and the PR body have all been grepped for the same claim.
The fixes in the commit above are unreviewed, so this stays a draft.
Review round 8: independent regression audit + reference cross-checkCorrecting the header: this is the EIGHTH round on this PR, not the first. Seven preceded it on 2026-08-05. Six of those changed prose only; one (the fifth) found a real defect, the marker counter that tracked half the marker text and passed on zero against zero. The code has not moved since the first commit. This round adds the evidence no earlier round gathered, the counterfactual run in both directions over the full suite and an independent check of the lit / React / Vue claims, and it finds nothing to fix. Verdict: no regressions found, approve. Nothing here is a must-fix. What follows is what I verified rather than took on trust. Regression surface, enumeratedThe change is observable only at teardown, so the question is what could depend on an orphan
Empirical counterfactual, both directionsFull node suite, same worktree, toggling only the two tokens:
The 6 failures are byte-identical in both runs (blog integration, Bun listener, elision fixture), all environment-local and all green in CI. Reverting adds exactly the 6 leak cases and nothing else. So this change breaks nothing and the new tests are load-bearing. Browser, all three engines with the fix: 740 / 730 / 740 passed, 0 failed. Reverted, on the two touched files: 3 failed on every engine (both browser cases plus the slot case). The tests are not vacuous. The three reference claims, checked against the local clonesAll three hold.
Gate claims, checked
Two notes, neither blocking
|
2cbfaab to
6dde006
Compare
6dde006 to
7496d59
Compare
Every rendered template instance is bracketed by a wjm-s / wjm-e comment pair, and removeBetween is what takes a range back out. The guard deciding whether to remove the end marker read start.parentNode AFTER the walk had already detached start, so it compared the live parent against null, could never fire, and every teardown left one orphan comment in the document. They accumulate for the life of the region and nothing collects them, so a long-lived list that churns rows grows comment nodes without limit. A 3-row repeat() cycled empty and back five times went from 3 end markers to 18. Capture the parent before the walk consumes start. The guard stays, because a marker moved under a different parent is not this region's to remove, and reaching into it would both steal a node the renderer does not own and throw NotFoundError out of a teardown that has to stay total. The terminator stays end itself rather than a stop sentinel captured up front. removeChild runs disconnectedCallback synchronously, so a sentinel pointing at a sibling this region does not own can move mid-walk, and the walk would then run off the end of the child list and take the part's own marker with it. The leak is invisible to textContent and to querySelectorAll, which is why nothing caught it, so the new tests count comment nodes directly.
Two cases rendered the outer shell from two separate template literal sites, so the container saw two different `strings` identities, rebuilt the whole instance on every render, and wiped the orphan markers with `replaceChildren` before the assertion could see them. Both stayed green with the bug present, which makes them worse than no test. Render the shell from ONE site so the outer instance updates in place and the swap actually reaches the child-hole teardown. Both now fail on the reverted fix, 2 start markers against 12 end markers over five swaps.
linkedom never runs disconnectedCallback, so the unit suite cannot show the removal staying on its rails while author code executes synchronously from inside removeChild. That re-entrancy is why the walk terminates on the end marker rather than on a stop sentinel, so it needs a real browser. The slot case is the one that is not a duplicate of the renderer cases: the fix hands the light-DOM backstop one extra removedNodes record per torn-down row, and those comments were never in the authored record, so churning a slotted list must leave both the record and the assigned set untouched. Also correct the cache suite's comment-stripping helper, which described a marker syntax the renderer does not use and gave no hint that stripping comments makes marker accounting unobservable.
The docblock said the end marker is "reachable only through the instance", which the paragraph immediately below it contradicts: that guard exists precisely because the marker CAN be moved under a different parent, and the new unit case does exactly that. Say the smaller, true thing instead. The walk needs to be bounded by the range the instance defines, not for its terminator to be immovable. Also read MARKER in the slot case rather than hardcoding the prefix, and assert the baseline count is non-zero. Without both, a rename of the marker text leaves that case counting nothing before and after, passing while asserting nothing, which is the failure mode the unit file's own docblock claims the suite avoids.
The terminator was already the end marker before this fix; only the parent capture changed. Arguing for it in the docblock meant defending a property the walk does not have, since stepping nextSibling to `end` overruns exactly like a stop sentinel would if the range is mutated underneath it. Drop the comparison and say the two things that are true: the guard refuses a marker that sits somewhere else, and the walk assumes an intact range, which it did before this change too. The consequence of a desynced range is already written up on reconcileRepeat's catch, so point there. The unit case cited the wrong catch for that overrun, and the browser case claimed to justify the terminator while its disconnectedCallback only writes outside the range, so it proved no such thing. Both now say what they do.
The header still said the callback runs "on a range the walk is still stepping through", which is the same overclaim the case's own inline comment now denies: it writes outside the range on purpose. What is true, and all that is needed, is that the callback runs part-way through the removal rather than after it.
I wrote that linkedom never runs disconnectedCallback. It does: it flips a reactive flag on the first customElements.define and fires the callback synchronously from removeChild, walking the removed subtree, and the repo's own lifecycle unit test already depends on that. So the browser case earns its place on fidelity, running the real engines, not on linkedom being incapable. Say that, and leave a note so the stronger claim does not come back. The unit file's header also claimed every case counts markers and asserts rendered output. The guard case does neither, by design, so scope the claim to the six leak cases and say what the seventh is for. Add the non-zero baseline assertion to the second browser case, which is the one place the earlier sweep for it missed, so a marker rename reds it rather than leaving every count comparing zero against zero.
The docblock said a replacement instance gets its markers from buildDetached. The child-hole caller creates them inline, so say markers of its own and stop naming a source that is only sometimes right. The unit header said one case per caller when the two array cases share removeArrayItem, one shrinking and one emptying. The guard case hedged that linkedom might not throw for a removeChild of a foreign node. It does throw, same as a browser, so both of that case's arms discriminate: an unguarded removeChild reds the first, an unconditional end.remove() reds the second. Verified by toggling each. It also pointed at reconcileRepeat's catch for the wrong reason; that catch describes the same overrun, but as grounds for rejecting an alternative.
countBookends matches MARKER plus an `s` / `e` suffix spelled in the test, so it tracks half the marker text. Rename the other half in the renderer and all six leak cases stayed green, comparing zero against zero: the assertions were satisfied by a counter that had stopped seeing anything. The rendered output assertions do not cover this, since they catch rendering nothing rather than counting nothing. Take every baseline through a helper that refuses an empty or unpaired count. Mutating the suffix now reds all seven cases where it reded one. U5's label also named teardownChild reaching teardownRepeat, a path no case in the file takes. The two directions go through applyChildInnerRaw's own branch and teardownChild's strings branch, so say that.
The comment credited reading MARKER with stopping a rename from leaving the counter blind. It does not: the helper spells the `e` suffix locally, so it tracks half the marker text, and the non-zero assertion two lines below is what actually holds. That is the same conclusion the unit file reached, and this comment was still asserting the opposite mechanism.
7496d59 to
21a6021
Compare
Closes #1289
Every rendered template instance is bracketed by a
wjm-s/wjm-ecomment pair, andremoveBetweenis what takes a range back out. The guard deciding whether to remove the end marker readstart.parentNodeAFTER the walk had already detachedstart, so it compared the live parent againstnull, could never fire, and every teardown left one orphan comment in the document. They accumulate for the life of the region and nothing collects them, so a long-lived list that churns rows grows comment nodes without limit. A 3-rowrepeat()cycled empty and back five times went from 3 end markers to 18.The fix captures the parent before the walk consumes
start. Two tokens of behaviour, and a docblock carrying the reasoning.What I kept, and why
The guard stays. A marker moved under a different parent is not this region's to remove. Reaching into it would steal a node the renderer does not own and throw NotFoundError out of a teardown that has to stay total. There is a test for exactly that.
The walk is untouched. It already stepped
nextSiblingfromstartand stopped onend, and it still does. Worth being explicit that this is not a guarantee the range is safe from mutation: a range whose end no longer follows its start runs off the child list, before this change and after it, andreconcileRepeat's catch already spells out what that costs. The guard is the narrower promise, that a marker sitting somewhere else is left alone.No clear/remove split. lit splits
_$clear(keep the markers, render into them again) fromremovePart(destroy the part). Every caller here discards the instance, and a replacement, where there is one, is built with markers of its own (frombuildDetachedon the list paths, inline on the child-hole path), so a second helper would ship with zero callers. The contract is pinned in the docblock instead.For what it is worth, the shape I landed on is what React's
clearHydrationBoundarydoes (it takesparentInstanceas a parameter captured before any removal, and its Suspense boundaries are comment-delimited too), and what Vue'sremoveFragmentdoes. lit at HEAD removes only the start marker, and its own tests strip comment nodes, so it cannot see the difference.Correction to the issue
The issue body claimed SSR emits these markers and they round-trip through hydration. That is false.
packages/server/srcemits nowjm-*bookends at all, and the only three creation sites aredocument.createCommentinrender-client.js. This is client-teardown-only by construction, so there is no server-side half. I have corrected the issue body.Test plan
Unit
packages/core/test/rendering/marker-leak-on-teardown.test.js, six leak cases covering everyremoveBetweencaller (the two array cases share one, shrinking and emptying), each looping five cycles and counting comment nodes directly (the leak is invisible totextContentandquerySelectorAll, which is why nothing caught it). Each asserts the pair count is balanced AND back to baseline, plus the rendered output, so none can pass by rendering nothing. A seventh case pins the guard instead and counts no markers. 7/7 pass.Browser
packages/core/test/rendering/browser/marker-leak-on-teardown.test.js, two cases. One drives the churn through a real component's update pipeline rather than a barerender(). The other puts adisconnectedCallbackon every row, so author code runs synchronously part-way through the removal rather than after it, and asserts the removal still finishes and the region still renders. That second one is about fidelity rather than capability, since linkedom does fire the callback; it is pinned on the three engines people actually run.Every leak case takes its baseline through a helper that refuses an empty or unpaired count. Without that, the counter matches on a marker suffix spelled in the test, so renaming it would leave all six comparing zero against zero and passing while observing nothing. Confirmed by mutating the suffix in the renderer: before, six stayed green; now all seven red.
Slot one case added to
packages/core/test/slots/browser/record-self-heal.test.js. The fix hands the light-DOM backstop one extraremovedNodesrecord per torn-down row, so this churns a slotted list five times and asserts the marker count, the projected set, andassignedNodes()all return to baseline.Counterfactual, proven at
239f19cand re-checked since: reverting the two tokens reds all 6 leak cases (5 start markers against 15 end forrepeat(), 4 against 34 for a same-key shape swap, 2 against 12 for a child hole), both browser cases, and the slot case, which fails alone out of 71 in that file. The 7th unit case is the guard case, green either way by design.Two unit cases initially stayed green with the bug present. They rendered the outer shell from two separate template literal sites, so the container saw two different
stringsidentities and rebuilt the whole instance every render, wiping the orphans withreplaceChildrenbefore the assertion could see them. Fixed in the second commit, and written up in a comment below.Suites
npm run test:browser(chromium)npm testWEBJS_E2E=1e2e/,/docs/error-handling,/ui,/ui/buttonall 200, 0 broken preloadsEvery failure above is pre-existing in this worktree, and I did not take that on trust. The e2e set is byte-identical with the fix reverted (same 87/4, same four names, two of them JS-off cases where none of this code runs at all). The node failures reproduce identically on the reverted source too: the elision ones are 500s from a fixture app, and the Bun listener one needs a runtime this worktree does not resolve. An earlier e2e run showed 13 failures and was my own contamination, since I built
core/distwhile it was in flight and the e2e switches to dist mode whendist/exists.Bun: N/A,
render-client.jsis browser-only and never runs on the Bun server. It also matches none of the path patterns the Bun parity gate keys on.Docs
.agents/skills/webjs/references/components.mdandwebsite/app/docs/error-handling/page.ts, one sentence each on the teardown paragraphs. Both already claimed teardown is total, and unbounded comment growth is a dimension of that a user can watch in devtools.Notes for a reviewer
moveRangelooks similar and is NOT affected. It appends into aDocumentFragmentand breaks onn === end, so it carries the whole range including the end marker. It must not be "fixed" to match.nextArrayAnchorreads the START marker and guards on.parentNode, and bothapplyChildInnerRawandreconcileRepeatinsert before the PART marker.if (!parent) returnis byte-equivalent to the old first line.packages/core/dist/is gitignored and the browser prefers it oversrc/when present. Every test here importssrc/directly, so anyone hand-verifying in an example app wants a rebuilt dist first.