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
14 changes: 14 additions & 0 deletions .changeset/nesting-validation-warnings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@effex/dom": patch
---

Warn on invalid HTML nesting during rendering. Certain parent/child pairs (`<p>` in `<p>`, block-level content in `<p>`, nested anchors, interactive content in `<button>`, nested forms) get silently normalized by the browser's HTML parser — the live DOM ends up different from what SSR emitted, and hydration reports a confusing "Expected `<X>` but not found in `<Y>`" mismatch far from the actual cause.

The renderers (`DOMRenderer`, SSR `StringRenderer`) now check each parent/child pair at `appendChild` time and emit a targeted `console.warn` once per pair per process explaining what the browser will do to the tree. Small runtime cost (a set lookup + string check), catches this class of bug at its source instead of downstream at hydration.

Covered nestings:

- `<p>` inside `<p>` and all block-level tags inside `<p>` (`div`, `section`, `ul`, `form`, `h1`-`h6`, `table`, …)
- `<a>` inside `<a>`
- Interactive elements inside `<button>` (`a`, `input`, `select`, `textarea`, …)
- `<form>` inside `<form>`
5 changes: 5 additions & 0 deletions packages/dom/src/Render/DOMRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { Effect, Layer } from "effect";
import { RendererContext, type Renderer, type Slot } from "@effex/core";

import { toKebabCase } from "../helpers/index.js";
import { warnIfInvalidNesting } from "./validateNesting.js";

const tagNameOf = (node: Node): string | undefined =>
node.nodeType === 1 ? (node as Element).tagName.toLowerCase() : undefined;

/**
* DOM implementation of the Renderer interface.
Expand All @@ -22,6 +26,7 @@ export const DOMRenderer: Renderer<Node> = {

appendChild: (parent: Node, child: Node) =>
Effect.sync(() => {
warnIfInvalidNesting(tagNameOf(parent), tagNameOf(child));
parent.appendChild(child);
}),

Expand Down
4 changes: 4 additions & 0 deletions packages/dom/src/Render/server/StringRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Effect } from "effect";

import type { Renderer, Slot } from "@effex/core";

import { warnIfInvalidNesting } from "../validateNesting.js";
import {
vComment,
vElement,
Expand All @@ -25,6 +26,9 @@ export const StringRenderer: Renderer<VNode> = {
appendChild: (parent: VNode, child: VNode) =>
Effect.sync(() => {
if (parent._tag === "VElement") {
if (child._tag === "VElement") {
warnIfInvalidNesting(parent.type, child.type);
}
parent.children.push(child);
// Track parent reference for slot markers
if (child._tag === "VComment") {
Expand Down
92 changes: 92 additions & 0 deletions packages/dom/src/Render/validateNesting.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { checkNesting, warnIfInvalidNesting } from "./validateNesting.js";

describe("checkNesting", () => {
it("flags a paragraph nested inside a paragraph", () => {
expect(checkNesting("p", "p")).toMatch(/<p> inside <p>/);
});

it("flags block-level content inside a paragraph", () => {
for (const child of ["div", "section", "ul", "form", "h1", "table"]) {
expect(checkNesting("p", child)).toMatch(
new RegExp(`<${child}> inside <p>`),
);
}
});

it("flags nested anchors", () => {
expect(checkNesting("a", "a")).toMatch(/<a> inside <a>/);
});

it("flags interactive content inside a button", () => {
for (const child of ["a", "button", "input", "select"]) {
expect(checkNesting("button", child)).toMatch(
new RegExp(`<${child}> inside <button>`),
);
}
});

it("flags nested forms", () => {
expect(checkNesting("form", "form")).toMatch(/<form> inside <form>/);
});

it("does not flag valid nesting", () => {
expect(checkNesting("div", "p")).toBeNull();
expect(checkNesting("div", "div")).toBeNull();
expect(checkNesting("p", "span")).toBeNull();
expect(checkNesting("p", "a")).toBeNull();
expect(checkNesting("p", "strong")).toBeNull();
expect(checkNesting("a", "span")).toBeNull();
expect(checkNesting("button", "span")).toBeNull();
expect(checkNesting("button", "img")).toBeNull();
expect(checkNesting("ul", "li")).toBeNull();
});
});

describe("warnIfInvalidNesting", () => {
let warnSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
});

afterEach(() => {
warnSpy.mockRestore();
});

it("emits a console.warn on invalid nesting", () => {
warnIfInvalidNesting("p", "div");
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy.mock.calls[0][0]).toMatch(/\[@effex\/dom\]/);
expect(warnSpy.mock.calls[0][0]).toMatch(/<div> inside <p>/);
});

it("is silent on valid nesting", () => {
warnIfInvalidNesting("div", "p");
warnIfInvalidNesting("ul", "li");
expect(warnSpy).not.toHaveBeenCalled();
});

it("is silent when either arg is missing (text nodes, comments, etc.)", () => {
warnIfInvalidNesting(undefined, "div");
warnIfInvalidNesting("p", undefined);
warnIfInvalidNesting(undefined, undefined);
expect(warnSpy).not.toHaveBeenCalled();
});

it("only warns once per parent-child pair (across calls)", () => {
// The module-level Set caches across tests, so use a distinct pair that
// no other test in this file exercises for warning-count assertions.
warnIfInvalidNesting("form", "form");
warnIfInvalidNesting("form", "form");
warnIfInvalidNesting("form", "form");
expect(warnSpy).toHaveBeenCalledTimes(1);
});

it("normalizes tag case", () => {
warnIfInvalidNesting("BUTTON", "INPUT");
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy.mock.calls[0][0]).toMatch(/<input> inside <button>/);
});
});
125 changes: 125 additions & 0 deletions packages/dom/src/Render/validateNesting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/**
* Compile-time HTML nesting validator.
*
* Browsers silently normalize invalid HTML nesting — the classic case being
* `<p>` inside `<p>`, which the parser auto-closes so the inner element ends
* up as a sibling of the outer, not a child. When Effex's SSR emits a tree
* the browser then reshapes on parse, the hydration walker finds a DOM shape
* that doesn't match the virtual tree and reports a confusing "Expected <X>
* but not found in Y" mismatch.
*
* This module checks parent/child pairs against the HTML spec's content-model
* rules for the categories that actually cause silent browser normalization
* (paragraphs, anchors, buttons, forms). Wired into every renderer's
* `appendChild`, it emits a targeted warning the first time each invalid
* pair is seen so the framework can flag the issue at its source instead of
* downstream at hydration.
*/

/** Block-level tags that trigger an implicit `</p>` when they appear inside a paragraph. */
const BLOCK_LEVEL_TAGS = new Set([
"address",
"article",
"aside",
"blockquote",
"details",
"dialog",
"div",
"dl",
"fieldset",
"figcaption",
"figure",
"footer",
"form",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"header",
"hr",
"main",
"menu",
"nav",
"ol",
"p",
"pre",
"section",
"table",
"ul",
]);

/** Interactive tags that can't be nested inside `<button>` (browsers reject or reshape). */
const INTERACTIVE_TAGS = new Set([
"a",
"button",
"embed",
"iframe",
"input",
"label",
"select",
"textarea",
]);

/**
* Return a warning string if `child` is invalid inside `parent`, else null.
* Both arguments are lowercase tag names.
*/
export const checkNesting = (parent: string, child: string): string | null => {
if (parent === "p" && BLOCK_LEVEL_TAGS.has(child)) {
return (
`<${child}> inside <p> is invalid HTML. Browsers auto-close the ` +
`<p> when the parser encounters <${child}>, so the live DOM won't ` +
`match your rendered tree and hydration will fail.`
);
}
if (parent === "a" && child === "a") {
return (
`<a> inside <a> is invalid HTML. Browsers reject nested anchors, so ` +
`the live DOM won't match your rendered tree and hydration will fail.`
);
}
if (parent === "button" && INTERACTIVE_TAGS.has(child)) {
return (
`<${child}> inside <button> is invalid HTML — <button> may not contain ` +
`interactive content. Browsers reshape the DOM and hydration will fail.`
);
}
if (parent === "form" && child === "form") {
return (
`<form> inside <form> is invalid HTML. Browsers reject nested forms, ` +
`so the live DOM won't match your rendered tree and hydration will fail.`
);
}
return null;
};

/**
* Cache of pair-strings we've already warned about, so a repeated bad nesting
* (e.g. `<p><p>...</p></p>` rendered inside a loop) doesn't spam the console.
*/
const warned = new Set<string>();

/**
* Report an invalid nesting via `console.warn`, once per parent-child pair
* per process. Safe to call unconditionally — the check is cheap and the
* warning only fires on real bugs. No-ops in environments without `console`.
*/
export const warnIfInvalidNesting = (
parent: string | undefined,
child: string | undefined,
): void => {
if (!parent || !child) return;
const p = parent.toLowerCase();
const c = child.toLowerCase();
const key = `${p}>${c}`;
if (warned.has(key)) return;
const message = checkNesting(p, c);
if (!message) return;
warned.add(key);
if (typeof console !== "undefined") {
// eslint-disable-next-line no-console
console.warn(`[@effex/dom] ${message}`);
}
};
Loading