Skip to content

Commit 88d5f52

Browse files
committed
fix: detach inline slot nodes before the island hydrates
`captureInlineSlot` collected a slot's server-rendered nodes into an array but never removed them from the page, so they were still children of the island root at hydration time. Preact adopted them for whatever the island rendered around the slot — the slot content got overwritten and the island's own element dropped as excess. The fixture now renders `<p>` siblings on both sides of the slot. Same tag as the slot's content matters: hydration matches children by tag name, so a `<span>` sibling skips past the slot's `<p>` and the bug stays hidden.
1 parent f023c7b commit 88d5f52

3 files changed

Lines changed: 35 additions & 3 deletions

File tree

src/client/slot.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ function captureSlotNode(index: number): Node[] {
7474

7575
/**
7676
* Move the nodes between `<!--fresh-slot:N-->` and `<!--/fresh-slot-->` (their
77-
* own contiguous siblings) into a fragment, removing the markers. Returns null
77+
* own contiguous siblings) out of the page, removing the markers. Returns null
7878
* if the start marker isn't in the live DOM (the slot wasn't rendered inline).
7979
*/
8080
function captureInlineSlot(index: number): Node[] | null {
@@ -96,7 +96,11 @@ function captureInlineSlot(index: number): Node[] | null {
9696
n.remove();
9797
break;
9898
}
99-
nodes.push(n); // moves `n` out of the live DOM and into the fragment
99+
// Detach as we go: the island is about to hydrate against this parent, and
100+
// preact would otherwise adopt the slot's still-attached nodes for whatever
101+
// the island renders around the slot.
102+
nodes.push(n);
103+
n.remove();
100104
n = next;
101105
}
102106
start.remove();

tests/e2e/slots.spec.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,26 @@ test.describe("island slots (JSX props)", () => {
1717
await expect(page.getByTestId("slot-children").getByTestId("children-content")).toBeVisible();
1818
});
1919

20+
test("a rendered slot keeps the island's own siblings around it", async ({ page }) => {
21+
await page.goto("/slots");
22+
await expect(page.getByTestId("slot-host")).toHaveAttribute("data-hydrated", "true");
23+
24+
// The island renders elements of its own on both sides of the slot, with
25+
// the same tag name the slot's content uses. Preact must hydrate those
26+
// against its own server-rendered DOM — if the slot's nodes are still
27+
// attached at hydration time it adopts one of them instead, overwriting the
28+
// slot content and dropping the island's own element as excess.
29+
await expect(page.getByTestId("before-slot")).toHaveText("before");
30+
await expect(page.getByTestId("after-slot")).toHaveText("after");
31+
await expect(page.getByTestId("children-content")).toContainText("hello from a slot");
32+
33+
// …and in that order, with the slot between them.
34+
const order = await page
35+
.getByTestId("slot-children")
36+
.evaluate((el) => Array.from(el.children, (c) => c.getAttribute("data-testid")));
37+
expect(order).toEqual(["before-slot", "children-content", "after-slot"]);
38+
});
39+
2040
test("an initially-unrendered slot is grafted from its template when the island renders it", async ({
2141
page,
2242
}) => {

tests/fixture/islands/SlotHost.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,15 @@ export function SlotHost(props: { children?: ComponentChildren; extra?: Componen
1414
}, []);
1515
return (
1616
<div data-testid="slot-host" data-hydrated={hydrated}>
17-
<div data-testid="slot-children">{props.children}</div>
17+
{/* The slot is rendered with siblings on both sides, and they're `<p>`s —
18+
the same tag the slot's own content uses. Hydration matches by tag
19+
name, so these have to be matched against the island's own DOM; if the
20+
slot's nodes are still in the tree, preact adopts one of them here. */}
21+
<div data-testid="slot-children">
22+
<p data-testid="before-slot">before</p>
23+
{props.children}
24+
<p data-testid="after-slot">after</p>
25+
</div>
1826
<button
1927
id="toggle-extra"
2028
type="button"

0 commit comments

Comments
 (0)