Skip to content

Commit af3f30d

Browse files
bartlomiejuclaude
andauthored
fix: warn instead of crash on invalid HTML nesting around islands (#3762)
## Summary - When a block-level element (e.g. `<div>`) is placed inside an inline element (e.g. `<p>`) that wraps an island, the browser fixes the invalid nesting by splitting the parent, moving Fresh's comment markers into different DOM nodes - Previously this caused a fatal `DOMException` during hydration, crashing all client-side JS on the page - Now Fresh detects that `start.parentNode !== end.parentNode` before calling `createRootFragment`, logs a descriptive error message explaining the invalid nesting, and skips the broken island instead of crashing Example of the error message: ``` Fresh hydration error: the island "BugIsland" 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 island for invalid nesting. ``` Closes #2121 --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 07878fc commit af3f30d

3 files changed

Lines changed: 73 additions & 2 deletions

File tree

packages/fresh/src/runtime/client/reviver.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,11 +164,34 @@ export function boot(
164164
for (let i = 0; i < ctx.roots.length; i++) {
165165
const root = ctx.roots[i];
166166

167+
if (root.end === null) continue;
168+
169+
// Check that the browser didn't reparent our markers due to
170+
// invalid HTML nesting (e.g. <div> inside <p>). When this
171+
// happens the start and end markers end up under different
172+
// parents and hydration would fail with a DOMException.
173+
if (root.start.parentNode !== root.end.parentNode) {
174+
const label = root.kind === RootKind.Island
175+
? `island "${root.name}"`
176+
: `partial "${root.name}"`;
177+
// deno-lint-ignore no-console
178+
console.error(
179+
`Fresh hydration error: the ${label} has invalid HTML nesting. ` +
180+
`Its start and end markers were reparented by the browser into ` +
181+
`different DOM nodes. This is usually caused by placing a ` +
182+
`block-level element (like <div>) inside an inline element ` +
183+
`(like <p> or <span>). Check the HTML around this ${
184+
root.kind === RootKind.Island ? "island" : "partial"
185+
} for invalid nesting.`,
186+
);
187+
continue;
188+
}
189+
167190
const container = createRootFragment(
168191
// deno-lint-ignore no-explicit-any
169192
root.start.parentNode as any,
170193
root.start,
171-
root.end!,
194+
root.end,
172195
);
173196

174197
if (root.kind === RootKind.Island) {
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function BlockIsland() {
2+
return <div class="block-island">hydrated</div>;
3+
}

packages/fresh/tests/islands_test.tsx

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { App, staticFiles } from "fresh";
2+
import { BlockIsland } from "./fixtures_islands/BlockIsland.tsx";
23
import { Counter } from "./fixtures_islands/Counter.tsx";
34
import { IslandInIsland } from "./fixtures_islands/IslandInIsland.tsx";
45
import { JsonIsland } from "./fixtures_islands/JsonIsland.tsx";
@@ -18,7 +19,7 @@ import {
1819
ISLAND_GROUP_DIR,
1920
withBrowserApp,
2021
} from "./test_utils.tsx";
21-
import { parseHtml, waitForText } from "./test_utils.tsx";
22+
import { parseHtml, waitFor, waitForText } from "./test_utils.tsx";
2223
import { expect } from "@std/expect";
2324
import { JsxConditional } from "./fixtures_islands/JsxConditional.tsx";
2425
import { FnIsland } from "./fixtures_islands/FnIsland.tsx";
@@ -802,3 +803,47 @@ Deno.test({
802803
);
803804
},
804805
});
806+
807+
Deno.test({
808+
name: "islands - warns on invalid DOM nesting instead of crashing",
809+
fn: async () => {
810+
const app = testApp()
811+
.get("/", (ctx) => {
812+
return ctx.render(
813+
<Doc>
814+
<p>
815+
<BlockIsland />
816+
</p>
817+
<div class="after-island">still here</div>
818+
</Doc>,
819+
);
820+
});
821+
822+
await withBrowserApp(app, async (page, address) => {
823+
const errors: string[] = [];
824+
page.addEventListener(
825+
"console",
826+
(msg) => errors.push(msg.detail.text),
827+
);
828+
829+
await page.goto(address, { waitUntil: "load" });
830+
831+
// Wait for the hydration error to be logged
832+
await waitFor(() =>
833+
errors.find((e) => e.includes("invalid HTML nesting"))
834+
);
835+
836+
// Page should not crash — content after the island should remain
837+
const afterIsland = await page.evaluate(() => {
838+
return document.querySelector(".after-island")?.textContent;
839+
});
840+
expect(afterIsland).toEqual("still here");
841+
842+
// Verify the error message mentions the island name
843+
const nestingError = errors.find((e) =>
844+
e.includes("invalid HTML nesting")
845+
);
846+
expect(nestingError).toContain("BlockIsland");
847+
});
848+
},
849+
});

0 commit comments

Comments
 (0)