Fix @teleport leaking detached nodes on every Livewire render#4822
Merged
Conversation
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.
Closed
3 tasks
@teleport leaking detached nodes on every Livewire render
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.
Collaborator
|
thanks! |
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.
The Scenario
A Livewire component containing a
@teleportblock 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:ignoreavoids the leak but isn't viable when the teleported content needs to stay reactive.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:Livewire updates the page by calling
Alpine.morph(from, newHtml). Morph parses the new HTML into atotree and walks both trees in parallel, and for each pair of elements it callsAlpine.cloneNode(from, to)to re-run directives againsttoin "clone mode". Clone mode is Alpine's signal to directives to skip real side effects (DOM writes, subscriptions, cleanup registrations), thetotree is throwaway scaffolding for the morph, not something we actually want to wire up.x-teleportdidn't honour that. Even in clone mode it still calledplaceInDom(clone, target, modifiers), andgetTarget()fell back to a module-level singleton div: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 existingskipDuringCloneblock 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_teleportso@alpinejs/morphcan patch it viacontext.patch(from._x_teleport, to._x_teleport), but it's no longer attached anywhere. The_x_teleportPutBackassignment andcleanupcallback are still set on thetotemplate in clone mode, but that's harmless, they live on the throwawaytoand get garbage collected along with it.Added a Cypress test that morphs a teleport template five times, then probes the container a fresh
Alpine.cloneNodewould use. Before the fix it found six leaked clones; after, the clone has no parent. Existingx-teleportand morph specs still pass.Fixes livewire/livewire#10235