Skip to content

Commit 944c0da

Browse files
SteffenDEukutaht
andauthored
recursively teleport elements (#4059)
Closes #4048. Co-authored-by: Uku Taht <[email protected]>
1 parent c7a7a5f commit 944c0da

File tree

3 files changed

+103
-2
lines changed

3 files changed

+103
-2
lines changed

assets/js/phoenix_live_view/dom_patch.js

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ export default class DOMPatch {
121121
// as the portal target itself could be at the end of the DOM,
122122
// it may not be present while morphing previous parts;
123123
// therefore we apply all teleports after the morphing is done+
124-
const portalCallbacks = [];
124+
let portalCallbacks = [];
125125

126126
let externalFormTriggered = null;
127127

@@ -488,8 +488,17 @@ export default class DOMPatch {
488488
}
489489

490490
morph(targetContainer, html);
491+
491492
// normal patch complete, teleport elements now
492-
portalCallbacks.forEach((callback) => callback());
493+
// and handle nested teleportation up to depth 5
494+
let teleportCount = 0;
495+
while (portalCallbacks.length > 0 && teleportCount < 5) {
496+
const copy = portalCallbacks.slice();
497+
portalCallbacks = [];
498+
copy.forEach((callback) => callback());
499+
teleportCount++;
500+
}
501+
493502
// check for any teleported elements that are not in the view any more
494503
// and remove them
495504
this.view.portalElementIds.forEach((id) => {

test/e2e/support/portal.ex

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,8 @@ defmodule Phoenix.LiveViewTest.E2E.PortalLive do
101101
|> assign(:param_current, nil)
102102
|> assign(:count, 0)
103103
|> assign(:render_modal, true)
104+
|> assign(:render_nested_portals, true)
105+
|> assign(:nested_portal_count, 0)
104106
|> then(&{:ok, &1, layout: {__MODULE__, :live}})
105107
end
106108

@@ -128,6 +130,14 @@ defmodule Phoenix.LiveViewTest.E2E.PortalLive do
128130
{:noreply, assign(socket, :render_modal, !socket.assigns.render_modal)}
129131
end
130132

133+
def handle_event("toggle_nested_portals", _params, socket) do
134+
{:noreply, assign(socket, :render_nested_portals, !socket.assigns.render_nested_portals)}
135+
end
136+
137+
def handle_event("nested_portal_click", _params, socket) do
138+
{:noreply, assign(socket, :nested_portal_count, socket.assigns.nested_portal_count + 1)}
139+
end
140+
131141
@impl Phoenix.LiveView
132142
def render(assigns) do
133143
~H"""
@@ -181,6 +191,24 @@ defmodule Phoenix.LiveViewTest.E2E.PortalLive do
181191
Hey there! {@count}
182192
</Phoenix.LiveViewTest.E2E.PortalTooltip.tooltip>
183193
</div>
194+
195+
<div class="border border-purple-600 mt-8 p-4">
196+
<h2>Nested Portal Test</h2>
197+
<.button phx-click="toggle_nested_portals">Toggle nested portals</.button>
198+
<p>Nested portal count: <span id="nested-portal-count">{@nested_portal_count}</span></p>
199+
<.portal :if={@render_nested_portals} id="nested-portal-source" target="#root-portal">
200+
<div id="outer-portal" class="border border-blue-400 p-4 m-2">
201+
<h3>Outer Portal</h3>
202+
<.portal id="inner-portal-source" target="body">
203+
<div id="inner-portal" class="border border-green-400 p-2 m-2">
204+
<h4>Inner Portal (nested inside outer)</h4>
205+
<p id="nested-portal-content">Tick count: {@count}</p>
206+
<.button phx-click="nested_portal_click">Click nested portal button</.button>
207+
</div>
208+
</.portal>
209+
</div>
210+
</.portal>
211+
</div>
184212
"""
185213
end
186214

test/e2e/tests/portal.spec.js

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,3 +114,67 @@ test("teleported hook works correctly", async ({ page }) => {
114114
"true",
115115
);
116116
});
117+
118+
test("nested portals render and work correctly", async ({ page }) => {
119+
await page.goto("/portal?tick=false");
120+
await syncLV(page);
121+
122+
// Verify outer portal is teleported
123+
await expect(
124+
page.locator("[data-phx-teleported-src=nested-portal-source]"),
125+
).toHaveCount(1);
126+
await expect(page.locator("#outer-portal")).toHaveCount(1);
127+
128+
// Verify inner portal is teleported
129+
await expect(
130+
page.locator("[data-phx-teleported-src=inner-portal-source]"),
131+
).toHaveCount(1);
132+
await expect(page.locator("#inner-portal")).toHaveCount(1);
133+
134+
// Test that events work in nested portals
135+
await expect(page.locator("#nested-portal-count")).toHaveText("0");
136+
await page
137+
.getByRole("button", { name: "Click nested portal button" })
138+
.click();
139+
await expect(page.locator("#nested-portal-count")).toHaveText("1");
140+
141+
// Test DOM patching works in nested portals
142+
await expect(page.locator("#nested-portal-content")).toContainText(
143+
"Tick count: 0",
144+
);
145+
await evalLV(page, "send(self(), :tick)");
146+
await expect(page.locator("#nested-portal-content")).toContainText(
147+
"Tick count: 1",
148+
);
149+
});
150+
151+
test("nested portals cleanup and re-render correctly", async ({ page }) => {
152+
await page.goto("/portal?tick=false");
153+
await syncLV(page);
154+
155+
// Verify initial state
156+
await expect(page.locator("#outer-portal")).toHaveCount(1);
157+
await expect(page.locator("#inner-portal")).toHaveCount(1);
158+
159+
// Toggle off - both portals should be removed
160+
await page.getByRole("button", { name: "Toggle nested portals" }).click();
161+
await expect(
162+
page.locator("[data-phx-teleported-src=nested-portal-source]"),
163+
).toHaveCount(0);
164+
await expect(
165+
page.locator("[data-phx-teleported-src=inner-portal-source]"),
166+
).toHaveCount(0);
167+
await expect(page.locator("#outer-portal")).toHaveCount(0);
168+
await expect(page.locator("#inner-portal")).toHaveCount(0);
169+
170+
// Toggle back on - both portals should reappear with correct nesting
171+
await page.getByRole("button", { name: "Toggle nested portals" }).click();
172+
await expect(
173+
page.locator("[data-phx-teleported-src=nested-portal-source]"),
174+
).toHaveCount(1);
175+
await expect(
176+
page.locator("[data-phx-teleported-src=inner-portal-source]"),
177+
).toHaveCount(1);
178+
await expect(page.locator("#outer-portal")).toHaveCount(1);
179+
await expect(page.locator("#inner-portal")).toHaveCount(1);
180+
});

0 commit comments

Comments
 (0)