Describe the bug
Every <Portal> instance leaves one empty text node behind in its mount target after cleanup: the disposer removes the nodes in [startMarker, endMarker) but never removes endMarker itself.
- Mount + dispose a Portal once → 1 stray empty text node stays in the mount element.
- Toggle a Portal inside
<Show> N times (the classic modal pattern, mounting into document.body) → N empty text nodes accumulate, unbounded.
Related to (but distinct from) #2757, which was about the Portal's content accumulating on reactive swaps and is fixed in beta.15 — this is marker accumulation on unmount. It is invisible to innerHTML-based checks (empty text nodes don't serialize), but shows up in childNodes.length, breaks :empty CSS selectors on the mount target, and is a real unbounded DOM leak for long-lived apps that open/close modals.
Regression from 1.x (verified on 1.9.14): the mount target returns to 0 child nodes after every close and after final disposal.
Your Example Website or App
https://stackblitz.com/edit/solidjs-templates-kwwlzkir?file=src%2FApp.tsx
import { createSignal, Show } from "solid-js";
import { Portal } from "@solidjs/web";
const target =
document.getElementById("portal-target") ??
document.body.appendChild(document.createElement("div"));
target.id = "portal-target";
target.replaceChildren();
function dump() {
return [...target.childNodes]
.map(node => `${node.nodeName}(${JSON.stringify(node.textContent)})`)
.join(", ");
}
export default function App() {
const [open, setOpen] = createSignal(false);
const [count, setCount] = createSignal(target.childNodes.length);
const [nodes, setNodes] = createSignal(dump() || "(empty)");
function openThenClose() {
setOpen(true);
// open and close in separate ticks so each phase actually renders,
// then sample the mount target after the close has been applied
setTimeout(() => {
setOpen(false);
setTimeout(() => {
setCount(target.childNodes.length);
setNodes(dump() || "(empty)");
}, 0);
}, 0);
}
return (
<div style={{ "font-family": "system-ui", padding: "16px" }}>
<button onClick={openThenClose}>open then close Portal</button>
<p>
target.childNodes.length: <b>{count()}</b>
</p>
<pre>{nodes()}</pre>
<Show when={open()}>
<Portal mount={target}>
<div>modal content</div>
</Portal>
</Show>
</div>
);
}
Steps to Reproduce the Bug or Issue
- Click open then close Portal repeatedly.
- Watch the reported
childNodes count of the mount target.
Actual — each open/close cycle permanently adds one empty text node:
after 1 click: target.childNodes.length === 1
after 2 clicks: target.childNodes.length === 2
after 3 clicks: target.childNodes.length === 3
The displayed dump shows the leftovers are empty text nodes:
#text(""), #text(""), #text("")
Expected behavior
Disposing a Portal removes everything it put into the mount target, including its own markers — the count must return to 0 after every close and stay flat:
modal closed — mount childNodes: 0 (after every cycle)
Screenshots or Videos
No response
Platform
- OS: macOS
- Browser: Chrome
- Version: 2.0.0-beta.15 (verified at
next @ bad66625)
Additional context
Root cause in packages/solid-web/src/index.ts:237-245 (Portal's render-effect cleanup): the loop removes nodes strictly before endMarker and then stops, so endMarker — appended to the mount by this same effect at index.ts:226 (m.appendChild(endMarker)) — survives every cleanup:
return () => {
dispose!();
let c: Node | null = startMarker;
while (c && c !== endMarker) { // ← half-open range: endMarker itself is never removed
const n: Node | null = c.nextSibling;
m.removeChild(c);
c = n;
}
};
(Switching props.mount does not leak into the previous mount — the next effect run's m.appendChild(endMarker) moves the surviving marker into the new mount, so the stranding only materializes on final disposal, i.e. every time the Portal unmounts.)
Suggested fix direction: make the removal inclusive of the marker the effect itself appended — e.g. loop while (c) { … } over [startMarker, endMarker] ending after removing endMarker, or simply m.removeChild(endMarker) after the loop.
Does this exist in Solid 1.x?
Regression from 1.x. Verified against solid-js 1.9.14: toggling <Show><Portal mount={target}>... on and off five times leaves the mount target completely empty (target.childNodes.length === 0). In 2.0 each mount/unmount cycle strands one empty text node in the target.
Describe the bug
Every
<Portal>instance leaves one empty text node behind in its mount target after cleanup: the disposer removes the nodes in[startMarker, endMarker)but never removesendMarkeritself.<Show>N times (the classic modal pattern, mounting intodocument.body) → N empty text nodes accumulate, unbounded.Related to (but distinct from) #2757, which was about the Portal's content accumulating on reactive swaps and is fixed in beta.15 — this is marker accumulation on unmount. It is invisible to
innerHTML-based checks (empty text nodes don't serialize), but shows up inchildNodes.length, breaks:emptyCSS selectors on the mount target, and is a real unbounded DOM leak for long-lived apps that open/close modals.Regression from 1.x (verified on 1.9.14): the mount target returns to 0 child nodes after every close and after final disposal.
Your Example Website or App
https://stackblitz.com/edit/solidjs-templates-kwwlzkir?file=src%2FApp.tsx
Steps to Reproduce the Bug or Issue
childNodescount of the mount target.Actual — each open/close cycle permanently adds one empty text node:
The displayed dump shows the leftovers are empty text nodes:
Expected behavior
Disposing a Portal removes everything it put into the mount target, including its own markers — the count must return to 0 after every close and stay flat:
Screenshots or Videos
No response
Platform
next@bad66625)Additional context
Root cause in
packages/solid-web/src/index.ts:237-245(Portal's render-effect cleanup): the loop removes nodes strictly beforeendMarkerand then stops, soendMarker— appended to the mount by this same effect atindex.ts:226(m.appendChild(endMarker)) — survives every cleanup:(Switching
props.mountdoes not leak into the previous mount — the next effect run'sm.appendChild(endMarker)moves the surviving marker into the new mount, so the stranding only materializes on final disposal, i.e. every time the Portal unmounts.)Suggested fix direction: make the removal inclusive of the marker the effect itself appended — e.g. loop
while (c) { … }over[startMarker, endMarker]ending after removingendMarker, or simplym.removeChild(endMarker)after the loop.Does this exist in Solid 1.x?
Regression from 1.x. Verified against solid-js 1.9.14: toggling
<Show><Portal mount={target}>...on and off five times leaves the mount target completely empty (target.childNodes.length === 0). In 2.0 each mount/unmount cycle strands one empty text node in the target.