Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Fix @teleport leaking detached nodes on every Livewire render#4822

Merged
calebporzio merged 2 commits into
mainfrom
josh/fix-teleport-morph-leak
Apr 30, 2026
Merged

Fix @teleport leaking detached nodes on every Livewire render#4822
calebporzio merged 2 commits into
mainfrom
josh/fix-teleport-morph-leak

Conversation

@joshhanley
Copy link
Copy Markdown
Collaborator

@joshhanley joshhanley commented Apr 22, 2026

The Scenario

A Livewire component containing a @teleport block leaks detached DOM nodes on every render. Each server round-trip accumulates another orphaned copy of the teleported subtree, so long-lived pages build up hundreds of retained clones. wire:ignore avoids the leak but isn't viable when the teleported content needs to stay reactive.

<?php

use Livewire\Component;

new class extends Component {
    public function action(): void
    {
        //
    }
}; ?>

<div>
    <button wire:click="action">refresh</button>

    @teleport('body')
        <div>teleported content</div>
    @endteleport
</div>

The Problem

When Alpine first boots a <template x-teleport="#target">, the directive clones the template content, places the clone inside the target, and stashes references on both sides so the morph plugin can find them later:

let target = getTarget(expression)
let clone = el.content.cloneNode(true).firstElementChild

el._x_teleport = clone
clone._x_teleportBack = el
...
placeInDom(clone, target, modifiers)

Livewire updates the page by calling Alpine.morph(from, newHtml). Morph parses the new HTML into a to tree and walks both trees in parallel, and for each pair of elements it calls Alpine.cloneNode(from, to) to re-run directives against to in "clone mode". Clone mode is Alpine's signal to directives to skip real side effects (DOM writes, subscriptions, cleanup registrations), the to tree is throwaway scaffolding for the morph, not something we actually want to wire up.

x-teleport didn't honour that. Even in clone mode it still called placeInDom(clone, target, modifiers), and getTarget() fell back to a module-level singleton div:

let teleportContainerDuringClone = document.createElement('div')

function getTarget(expression) {
    let target = skipDuringClone(() => {
        return document.querySelector(expression)
    }, () => {
        return teleportContainerDuringClone
    })()
    ...
}

That container is never cleared. Every morph of a teleport template appended a fresh clone into it, and because it's a live module-scoped <div> holding them as children, nothing could be garbage collected, one leaked subtree per render, forever.

The Solution

The only thing the directive needed to stop doing in clone mode was placeInDom, the one call that attaches the clone to the singleton and creates the leak. Extending the existing skipDuringClone block to also cover it is enough:

     mutateDom(() => {
-        placeInDom(clone, target, modifiers)
-
         skipDuringClone(() => {
+            placeInDom(clone, target, modifiers)
+
             initTree(clone)
         })()
     })

The clone still exists on to._x_teleport so @alpinejs/morph can patch it via context.patch(from._x_teleport, to._x_teleport), but it's no longer attached anywhere. The _x_teleportPutBack assignment and cleanup callback are still set on the to template in clone mode, but that's harmless, they live on the throwaway to and get garbage collected along with it.

Added a Cypress test that morphs a teleport template five times, then probes the container a fresh Alpine.cloneNode would use. Before the fix it found six leaked clones; after, the clone has no parent. Existing x-teleport and morph specs still pass.

Fixes livewire/livewire#10235

During `Alpine.cloneNode` (used by `Alpine.morph` to seed new server HTML),
the `x-teleport` directive was creating a fresh clone and appending it to
a module-level `teleportContainerDuringClone` div that was never cleared.
Each morph of a component containing `<template x-teleport>` leaked one
detached clone per call, accumulating over the lifetime of the page.

Skip `placeInDom`, `initTree`, `_x_teleportPutBack` assignment, and cleanup
registration during clone mode. The clone still exists on `el._x_teleport`
so `Alpine.morph` can patch it, but it's never attached anywhere, so no
detached node is retained.
@joshhanley joshhanley changed the title Fix @teleport leaking detached nodes on every Livewire render Fix @teleport leaking detached nodes on every Livewire render Apr 22, 2026
Keep the unused `teleportContainerDuringClone` and `getTarget` clone-mode
fallback in place. The only change needed is wrapping `placeInDom` in
`skipDuringClone` so it doesn't leak a clone into the singleton on every
morph. The rest (`_x_teleportPutBack`, `cleanup`) is scaffolding stored on
the throwaway `to` template, which gets GC'd along with it.
@calebporzio
Copy link
Copy Markdown
Collaborator

thanks!

@calebporzio calebporzio merged commit 8f519b7 into main Apr 30, 2026
2 checks passed
@calebporzio calebporzio deleted the josh/fix-teleport-morph-leak branch April 30, 2026 20:33
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.

@teleport creates detached nodes on every render, causing memory leaks

2 participants