Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion packages/fresh/src/runtime/client/reviver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,11 +164,34 @@ export function boot(
for (let i = 0; i < ctx.roots.length; i++) {
const root = ctx.roots[i];

if (root.end === null) continue;

// Check that the browser didn't reparent our markers due to
// invalid HTML nesting (e.g. <div> inside <p>). When this
// happens the start and end markers end up under different
// parents and hydration would fail with a DOMException.
if (root.start.parentNode !== root.end.parentNode) {
const label = root.kind === RootKind.Island
? `island "${root.name}"`
: `partial "${root.name}"`;
// deno-lint-ignore no-console
console.error(
`Fresh hydration error: the ${label} has invalid HTML nesting. ` +
`Its start and end markers were reparented by the browser into ` +
`different DOM nodes. This is usually caused by placing a ` +
`block-level element (like <div>) inside an inline element ` +
`(like <p> or <span>). Check the HTML around this ${
root.kind === RootKind.Island ? "island" : "partial"
} for invalid nesting.`,
);
continue;
}

const container = createRootFragment(
// deno-lint-ignore no-explicit-any
root.start.parentNode as any,
root.start,
root.end!,
root.end,
);

if (root.kind === RootKind.Island) {
Expand Down
3 changes: 3 additions & 0 deletions packages/fresh/tests/fixtures_islands/BlockIsland.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function BlockIsland() {
return <div class="block-island">hydrated</div>;
}
47 changes: 46 additions & 1 deletion packages/fresh/tests/islands_test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { App, staticFiles } from "fresh";
import { BlockIsland } from "./fixtures_islands/BlockIsland.tsx";
import { Counter } from "./fixtures_islands/Counter.tsx";
import { IslandInIsland } from "./fixtures_islands/IslandInIsland.tsx";
import { JsonIsland } from "./fixtures_islands/JsonIsland.tsx";
Expand All @@ -18,7 +19,7 @@ import {
ISLAND_GROUP_DIR,
withBrowserApp,
} from "./test_utils.tsx";
import { parseHtml, waitForText } from "./test_utils.tsx";
import { parseHtml, waitFor, waitForText } from "./test_utils.tsx";
import { expect } from "@std/expect";
import { JsxConditional } from "./fixtures_islands/JsxConditional.tsx";
import { FnIsland } from "./fixtures_islands/FnIsland.tsx";
Expand Down Expand Up @@ -802,3 +803,47 @@ Deno.test({
);
},
});

Deno.test({
name: "islands - warns on invalid DOM nesting instead of crashing",
fn: async () => {
const app = testApp()
.get("/", (ctx) => {
return ctx.render(
<Doc>
<p>
<BlockIsland />
</p>
<div class="after-island">still here</div>
</Doc>,
);
});

await withBrowserApp(app, async (page, address) => {
const errors: string[] = [];
page.addEventListener(
"console",
(msg) => errors.push(msg.detail.text),
);

await page.goto(address, { waitUntil: "load" });

// Wait for the hydration error to be logged
await waitFor(() =>
errors.find((e) => e.includes("invalid HTML nesting"))
);

// Page should not crash — content after the island should remain
const afterIsland = await page.evaluate(() => {
return document.querySelector(".after-island")?.textContent;
});
expect(afterIsland).toEqual("still here");

// Verify the error message mentions the island name
const nestingError = errors.find((e) =>
e.includes("invalid HTML nesting")
);
expect(nestingError).toContain("BlockIsland");
});
},
});
Loading