DEV Community

Vladimir Elchinov for Session Replay

Posted on

The Browser Closed Your Paragraph Early, and Your CSS Stopped Applying

Write this, then read back what the browser actually built:

<p>before <figure>FIG</figure> after</p>
Enter fullscreen mode Exit fullscreen mode

I parsed it in Chrome this morning. What comes back is not that:

<p>before </p><figure>FIG</figure> after<p></p>
Enter fullscreen mode Exit fullscreen mode

Four things happened that nobody asked for. The paragraph closed before the figure. The figure became a sibling rather than a child. The word after is now a bare text node hanging directly off <body>, in no paragraph at all. And the </p> you wrote arrived too late to close anything, so it opened and closed a new empty one.

Here is the measurement, which you can run in any console:

const parse = (html) => {
  const doc = new DOMParser().parseFromString('<!doctype html><body>' + html, 'text/html');
  return { html: doc.body.innerHTML, paragraphs: doc.body.querySelectorAll('p').length };
};

parse('<p>before <figure>FIG</figure> after</p>');
// { html: '<p>before </p><figure>FIG</figure> after<p></p>', paragraphs: 2 }
parse('<p>before <span>SPAN</span> after</p>');
// { html: '<p>before <span>SPAN</span> after</p>', paragraphs: 1 }
Enter fullscreen mode Exit fullscreen mode

div and ul behave exactly like figure. span does not, and the difference is the whole mechanism.

This is specified behaviour, not a quirk

The HTML Standard says a p element's end tag can be omitted

if the p element is immediately followed by an address, article, aside, blockquote, details, dialog, div, dl, fieldset, figcaption, figure, footer, form, h1, h2, h3, h4, h5, h6, header, hgroup, hr, main, menu, nav, ol, p, pre, search, section, table, or ul element

"Can be omitted" is the author-facing half of that sentence, and it reads like a convenience. The parser-facing half is that the tag is omitted whether you wrote it or not. Because the standard requires it, this is not a rendering difference you can test your way around in another browser. I measured Chrome; any conformant parser is obliged to build the same tree.

What it actually costs

The orphaned text is the part that bites. Every rule you wrote for p stops applying to it: line-height, max-width, margin, font-size, the lot. Half your sentence inherits from body and the half above it does not, so the spacing changes partway through what the reader sees as one paragraph. The empty <p> adds a margin on top of that, and it is not in your source to find.

That is a strange defect to receive. It is visible, it is clearly a layout fault, and it is confined to one page and one place on it.

Where it comes from, since nobody types this

Three routes, and the author is not wrong in any of them:

  • A template or component wrapping a body in <p>, where that body can contain a figure, a table or a code block.
  • A markdown or CMS pipeline that renders an image, an embed or an attachment as a <figure> and lets it land mid-paragraph. I hit this last week in a publishing pipeline, which refused the file rather than render it, and that refusal is the only reason I looked.
  • Rich text pasted into a field whose output gets wrapped in <p> on the way out.

The part you cannot see from the source

Here is the uncomfortable bit, and the reason this is worth knowing rather than just fixing. The defect does not exist in your template, your markdown, or your CMS record. All three are exactly what you intended, and re-reading them proves nothing. It exists only in the tree the parser built.

So the report arrives as "the spacing looks wrong on that page", from somebody who cannot tell you which half of which paragraph, and you cannot reproduce it by reading the file that produced it. Opening the page yourself may not help either, because the condition is in the content: it needs the paragraph that happened to contain a figure, which is one article out of two hundred.

The only artefact that shows the fault is the DOM as it was built at the time, on the page the reader had open. That is never what gets sent.

A cheap assertion

Both of these should be zero on a content page, and neither is on a page with this defect:

// Point this at whatever wraps your rendered content, not at body:
// on most pages the prose lives inside <article> or <main>.
const root = document.querySelector('article, main') || document.body;

const orphans = [...root.childNodes]
  .filter(n => n.nodeType === Node.TEXT_NODE && n.textContent.trim());

const empties = [...root.querySelectorAll('p')]
  .filter(p => !p.textContent.trim() && !p.children.length);

console.log({ orphans: orphans.length, empties: empties.length });
Enter fullscreen mode Exit fullscreen mode

Cheap enough to assert in whatever test already renders your templates, which turns a layout report nobody can reproduce into a failing build.

If you take one thing from this: a correct source file and a correct DOM are two different claims. The parser sits between them, and it is allowed to disagree with you.

Top comments (0)