From 59b91a0919f108b0f7a566204e3f37265618c742 Mon Sep 17 00:00:00 2001
From: Lee Calcote cannot appear as a descendant of "
and "Expected server HTML to contain a matching in ".
MDX parses the body of a multi-line JSX element as Markdown, so
Some text.
Some text.
before the nested one and the parsed DOM no longer matches the tree React renders on the client. The same happened wherever a heading, list, table,
, and for every Markdown image, because the MDX image component wrapped each one in a
, which the + // browser's parser re-shapes and React then fails to hydrate. + rehypePlugins: [rehypeFixParagraphNesting], + }, }, }, { diff --git a/rehype-fix-paragraph-nesting.js b/rehype-fix-paragraph-nesting.js new file mode 100644 index 00000000000000..57e358e90a79da --- /dev/null +++ b/rehype-fix-paragraph-nesting.js @@ -0,0 +1,233 @@ +/* eslint-env node */ + +/** + * rehype plugin: keep block-level content out of
elements. + * + * MDX parses the body of a multi-line JSX element as Markdown, so authoring + * + *
+ * Some text. + *
+ * + * compiles to `Some text.
`. The same happens whenever a heading, + * list, table,. + * + * That markup is invalid HTML. When the browser parses the server-rendered + * page it silently closes the outer
before the block-level child, so the + * parsed DOM no longer matches the tree React renders on the client. React then + * fails hydration ("Minified React error #418") and, having no Suspense + * boundary to recover into, discards the server markup and re-renders the whole + * root on the client ("Minified React error #423"). + * + * This plugin rewrites the tree so the emitted HTML is valid: nested paragraphs + * are collapsed into their parent, and any other block-level child is hoisted + * out of the paragraph the way the HTML parser would place it. The parsed DOM + * then matches React's tree and hydration succeeds. + */ + +// Elements whose start tag implicitly closes an open
(HTML Standard, +// "a p element's end tag can be omitted" plus the in-body insertion rules). +const BLOCK_LEVEL_TAGS = new Set([ + "address", + "article", + "aside", + "blockquote", + "center", + "dd", + "details", + "dialog", + "dir", + "div", + "dl", + "dt", + "fieldset", + "figcaption", + "figure", + "footer", + "form", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "header", + "hgroup", + "hr", + "li", + "main", + "menu", + "nav", + "ol", + "p", + "pre", + "section", + "summary", + "table", + "ul", +]); + +// Components that render a single text element (
by default). MDX turns +// their multi-line body into a Markdown paragraph, which lands inside whatever +// element the component renders — invalid there for every variant it supports. +const PARAGRAPH_COMPONENTS = new Set(["Typography"]); + +const tagNameOf = (node) => { + if (!node || typeof node !== "object") return null; + if (node.type === "element") return node.tagName; + if (node.type === "mdxJsxFlowElement" || node.type === "mdxJsxTextElement") { + return node.name; + } + return null; +}; + +const isBlockLevel = (node) => { + const tagName = tagNameOf(node); + return ( + typeof tagName === "string" && BLOCK_LEVEL_TAGS.has(tagName.toLowerCase()) + ); +}; + +const isParagraph = (node) => tagNameOf(node) === "p"; + +const isBlankText = (node) => + node && + node.type === "text" && + typeof node.value === "string" && + node.value.trim() === ""; + +// Whether a paragraph node carries markup worth keeping (a className, an id, …) +// when a redundant wrapper has to be dropped. +const hasAttributes = (node) => { + if (node.type === "element") { + return !!node.properties && Object.keys(node.properties).length > 0; + } + return Array.isArray(node.attributes) && node.attributes.length > 0; +}; + +// `
text
` keeps whichever of the two wrappers carries +// the attributes, so styling written by the author survives. +const collapseSoleNestedParagraph = (paragraph) => { + let current = paragraph; + for (;;) { + const content = current.children.filter((child) => !isBlankText(child)); + if (content.length !== 1 || !isParagraph(content[0])) return current; + + const inner = content[0]; + if (hasAttributes(current) || !hasAttributes(inner)) { + current.children = inner.children; + } else { + current = inner; + } + } +}; + +// `text
, and a block-level wrapper there is invalid HTML that the browser's +// parser re-shapes, which breaks hydration. const OptimizedImage = (props) => { return ( -
There is an alternate relationship between the layer hierarchy in both light and dark themes: -
This means, in effect, that while there is a fine blend of surfaces
on any given UI theme, there is also strict adherence to
accessibility guidelines and recommendations.
From 1b4e356e1981ba853e98d978ea51eaf2367717c2 Mon Sep 17 00:00:00 2001
From: Lee Calcote , which the
// browser's parser re-shapes and React then fails to hydrate.
- rehypePlugins: [rehypeFixParagraphNesting],
+ rehypePlugins: [rehypeFixDomNesting],
},
},
},
diff --git a/rehype-fix-paragraph-nesting.js b/rehype-fix-dom-nesting.js
similarity index 62%
rename from rehype-fix-paragraph-nesting.js
rename to rehype-fix-dom-nesting.js
index 57e358e90a79da..a1e904950eabe0 100644
--- a/rehype-fix-paragraph-nesting.js
+++ b/rehype-fix-dom-nesting.js
@@ -1,28 +1,36 @@
/* eslint-env node */
/**
- * rehype plugin: keep block-level content out of elements.
+ * rehype plugin: emit markup the HTML parser will not rearrange.
*
- * MDX parses the body of a multi-line JSX element as Markdown, so authoring
+ * React hydration compares the tree it renders against the DOM the browser
+ * parsed from the server-rendered HTML. Where those disagree, hydration fails
+ * ("Minified React error #418") and, with no Suspense boundary to recover into,
+ * React discards the server markup and re-renders the whole root on the client
+ * ("Minified React error #423"). Markup the parser silently restructures is
+ * therefore a hydration bug, even when it renders the way the author intended.
*
- *
- * Some text.
- * Some text. .
+ * 1. Block-level content inside a . MDX parses the body of a multi-line JSX
+ * element as Markdown, so authoring
*
- * That markup is invalid HTML. When the browser parses the server-rendered
- * page it silently closes the outer before the block-level child, so the
- * parsed DOM no longer matches the tree React renders on the client. React then
- * fails hydration ("Minified React error #418") and, having no Suspense
- * boundary to recover into, discards the server markup and re-renders the whole
- * root on the client ("Minified React error #423").
+ *
+ * Some text.
+ * Some text.
+ * before the nested one. The same happens for a heading, list, table, .
+ *
+ * 2. Table rows and cells written without their section. ` (HTML Standard,
@@ -72,6 +80,12 @@ const BLOCK_LEVEL_TAGS = new Set([
// element the component renders — invalid there for every variant it supports.
const PARAGRAPH_COMPONENTS = new Set(["Typography"]);
+// Table structure the parser fills in when rows or cells are written bare.
+const TABLE_SECTION_TAGS = new Set(["thead", "tbody", "tfoot"]);
+const TABLE_CELL_TAGS = new Set(["td", "th"]);
+// Stray text in any of these is "foster parented" out of the table entirely.
+const TABLE_INTERNAL_TAGS = new Set(["table", "thead", "tbody", "tfoot", "tr"]);
+
const tagNameOf = (node) => {
if (!node || typeof node !== "object") return null;
if (node.type === "element") return node.tagName;
@@ -81,6 +95,8 @@ const tagNameOf = (node) => {
return null;
};
+const lowerTagNameOf = (node) => (tagNameOf(node) || "").toLowerCase();
+
const isBlockLevel = (node) => {
const tagName = tagNameOf(node);
return (
@@ -198,6 +214,55 @@ const splitParagraph = (paragraph) => {
return siblings;
};
+// Group each run of children the parser would gather under an implied element.
+const wrapRunsOfChildren = (node, belongsInWrapper, wrapperTagName) => {
+ if (!node.children.some(belongsInWrapper)) return;
+
+ const children = [];
+ let run = [];
+ const flushRun = () => {
+ if (run.length) {
+ children.push({
+ type: "element",
+ tagName: wrapperTagName,
+ properties: {},
+ children: run,
+ });
+ run = [];
+ }
+ };
+
+ for (const child of node.children) {
+ if (belongsInWrapper(child)) run.push(child);
+ else {
+ flushRun();
+ children.push(child);
+ }
+ }
+ flushRun();
+ node.children = children;
+};
+
+// Insert the a a c `. Each
paragraph now becomes its own copy of the component, which keeps the
author's paragraph breaks and their styling rather than running the text
together. A paragraph carrying its own markup is kept in place of the
component wrapper, since the more specific attributes are the ones worth
preserving - that case was previously skipped too.
Non-whitespace text between a table's rows and cells was left inside the
table, where the parser instead "foster parents" it out to just before
the table. It is now moved there at build time. Whitespace is still
simply dropped, since it renders as nothing once outside the table.
No change to the built output of the current content: pages the parser
restructures stays at 11, none of them a blog page, and all ten probed
pages remain at zero console errors in both color schemes. These were
latent gaps rather than live failures - which is how the table case
started out too, until a post landed that hit it.
Signed-off-by: Lee Calcote a, and the parser always inserts
the implied , so every row sits one level deeper in the DOM than
React put it. /blog/meshery/deploying-linkerd-with-meshery hit the same
thing through , where the parser inserts a .
Rather than keep guessing which shapes the parser rewrites, the markup is
now checked against the parser itself: every built page is parsed in
Chromium and the resulting element sequence compared with the sequence
the server HTML was written in. Any divergence is a hydration bug. That
found these two table cases, and confirms no other blog page diverges.
So the plugin now also applies the parser's table rules - wrapping bare
rows in a , bare cells in a , and dropping the whitespace the
parser would foster-parent out of a table. Renamed to
rehype-fix-dom-nesting.js, since it is no longer only about paragraphs.
Verified on the merged head, in both light and dark color schemes:
/blog/ai/ai-system-1-versus-system-2-tasks 36 -> 0
/blog/meshery/deploying-linkerd-with-meshery 0 -> 0
5 other blog pages, /blog, / (controls) 0 -> 0
Pages whose markup the parser restructures: 14 -> 11, with none of the
remaining 11 on a blog page. They are all hand-written JSX in components
rather than MDX content, so the build-time transform cannot reach them.
Signed-off-by: Lee Calcote ` and
+ * ` and ` are how people write tables by hand, but the parser inserts
+ * the implied , so every row lands one level deeper than
+ * React put it.
+ *
+ * This plugin applies the parser's own rules at build time - hoisting block
+ * children out of paragraphs, and inserting the table sections and rows the
+ * parser would - so the emitted HTML survives a round trip through it.
*/
// Elements whose start tag implicitly closes an open and the parser would, and drop the whitespace it
+// would hoist out of the table, so the markup round-trips through the parser.
+const fixTableStructure = (node) => {
+ const tagName = lowerTagNameOf(node);
+ if (!TABLE_INTERNAL_TAGS.has(tagName)) return;
+
+ node.children = node.children.filter((child) => !isBlankText(child));
+
+ const isCell = (child) => TABLE_CELL_TAGS.has(lowerTagNameOf(child));
+ const isRow = (child) => lowerTagNameOf(child) === "tr";
+
+ if (TABLE_SECTION_TAGS.has(tagName)) {
+ wrapRunsOfChildren(node, isCell, "tr");
+ } else if (tagName === "table") {
+ // A bare cell implies a row as well as a section, so wrap cells first.
+ wrapRunsOfChildren(node, isCell, "tr");
+ wrapRunsOfChildren(node, isRow, "tbody");
+ }
+};
+
// Depth-first, so a paragraph that only becomes invalid after its own children
// are normalised is still handled by its parent on the way back up.
const fixParagraphNesting = (node) => {
@@ -205,6 +270,7 @@ const fixParagraphNesting = (node) => {
node.children.forEach(fixParagraphNesting);
liftSoleParagraphFromComponent(node);
+ fixTableStructure(node);
const children = [];
for (const child of node.children) {
@@ -223,11 +289,11 @@ const fixParagraphNesting = (node) => {
node.children = children;
};
-const rehypeFixParagraphNesting = () => (tree) => {
+const rehypeFixDomNesting = () => (tree) => {
fixParagraphNesting(tree);
return tree;
};
-module.exports = rehypeFixParagraphNesting;
+module.exports = rehypeFixDomNesting;
module.exports.BLOCK_LEVEL_TAGS = BLOCK_LEVEL_TAGS;
module.exports.PARAGRAPH_COMPONENTS = PARAGRAPH_COMPONENTS;
From 18ba1afbad6bc931356e20476cca77cff23bffe8 Mon Sep 17 00:00:00 2001
From: Lee Calcote the parser would, and drop the whitespace it
-// would hoist out of the table, so the markup round-trips through the parser.
+// Insert the and the parser would, so the markup round-trips.
const fixTableStructure = (node) => {
const tagName = lowerTagNameOf(node);
if (!TABLE_INTERNAL_TAGS.has(tagName)) return;
- node.children = node.children.filter((child) => !isBlankText(child));
-
const isCell = (child) => TABLE_CELL_TAGS.has(lowerTagNameOf(child));
const isRow = (child) => lowerTagNameOf(child) === "tr";
@@ -263,18 +308,55 @@ const fixTableStructure = (node) => {
}
};
+// Text between a table's rows and cells belongs to no cell, so the parser
+// "foster parents" it: the text is moved out to just before the table. Doing
+// the same here keeps the emitted markup and the parsed DOM in step. Whitespace
+// is simply dropped, since it renders as nothing once outside the table.
+const fosterParentStrayText = (table) => {
+ const strayText = [];
+
+ const collect = (node) => {
+ if (!TABLE_INTERNAL_TAGS.has(lowerTagNameOf(node))) return;
+ node.children = node.children.filter((child) => {
+ if (child.type !== "text") {
+ collect(child);
+ return true;
+ }
+ if (!isBlankText(child)) strayText.push(child);
+ return false;
+ });
+ };
+ collect(table);
+
+ return strayText;
+};
+
// Depth-first, so a paragraph that only becomes invalid after its own children
// are normalised is still handled by its parent on the way back up.
const fixParagraphNesting = (node) => {
if (!node || !Array.isArray(node.children)) return;
node.children.forEach(fixParagraphNesting);
- liftSoleParagraphFromComponent(node);
fixTableStructure(node);
const children = [];
for (const child of node.children) {
- if (!isParagraph(child) || !Array.isArray(child.children)) {
+ if (!Array.isArray(child.children)) {
+ children.push(child);
+ continue;
+ }
+
+ if (lowerTagNameOf(child) === "table") {
+ children.push(...fosterParentStrayText(child), child);
+ continue;
+ }
+
+ if (isParagraphComponent(child)) {
+ children.push(...liftParagraphsFromComponent(child));
+ continue;
+ }
+
+ if (!isParagraph(child)) {
children.push(child);
continue;
}
@@ -282,7 +364,7 @@ const fixParagraphNesting = (node) => {
const paragraph = collapseSoleNestedParagraph(child);
unwrapInlineParagraphs(paragraph);
- if (paragraph.children.some(isBlockLevel))
+ if (containsBlockLevel(paragraph))
children.push(...splitParagraph(paragraph));
else children.push(paragraph);
}
From f1bb80192c7db426c0f52abb5943df272242c8d1 Mon Sep 17 00:00:00 2001
From: Lee Calcote