diff --git a/packages/fresh/src/runtime/client/reviver.ts b/packages/fresh/src/runtime/client/reviver.ts index e852cd195d6..dda11e8aa4b 100644 --- a/packages/fresh/src/runtime/client/reviver.ts +++ b/packages/fresh/src/runtime/client/reviver.ts @@ -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.
inside

). 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

) inside an inline element ` + + `(like

or ). 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) { diff --git a/packages/fresh/tests/fixtures_islands/BlockIsland.tsx b/packages/fresh/tests/fixtures_islands/BlockIsland.tsx new file mode 100644 index 00000000000..46f0f5315ae --- /dev/null +++ b/packages/fresh/tests/fixtures_islands/BlockIsland.tsx @@ -0,0 +1,3 @@ +export function BlockIsland() { + return

hydrated
; +} diff --git a/packages/fresh/tests/islands_test.tsx b/packages/fresh/tests/islands_test.tsx index 3a364f36b8b..b5031df0d95 100644 --- a/packages/fresh/tests/islands_test.tsx +++ b/packages/fresh/tests/islands_test.tsx @@ -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"; @@ -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"; @@ -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( + +

+ +

+
still here
+
, + ); + }); + + 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"); + }); + }, +});