Skip to content

2.0.0-beta.15: Portal leaks one empty text node in the mount target per instance #2833

Description

@yumemi-thomas

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.

  1. Mount + dispose a Portal once → 1 stray empty text node stays in the mount element.
  2. 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

  1. Click open then close Portal repeatedly.
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions