Skip to content

htmlValidate Nitro plugin crashes SSR responses (HTTP 500) on SVG <foreignObject> containing a <picture> #360

Description

@JonathanXDR

Environment

Operating System Darwin
Node Version v24.18.0
Nuxt Version 4.4.6
CLI Version 3.36.0
Nitro Version 2.13.4
Package Manager npm@11.17.0
Builder vite
User Config compatibilityDate, modules, hints
Runtime Modules @nuxt/hints@1.1.2
Build Modules -

Reproducible in WebContainer (StackBlitz) and on plain Node.

Describe the bug

The htmlValidate feature registers a Nitro render:response hook that pipes every SSR HTML response through prettier.format(response.body, { plugins: [html], parser: 'html' }) before handing the markup to html-validate. Prettier's HTML parser cannot follow the SVG to HTML namespace transition when an element whose tag name collides with an SVG-2 element (here <picture>) is nested inside <foreignObject>. Prettier throws, the format(...) call is not wrapped in a try/catch, so the render:response hook rejects and the page is served as HTTP 500 with no html-validate report.

This <svg><foreignObject><picture><source><img></picture></foreignObject></svg> shape is exactly what <NuxtImg> expands to when responsive sources are configured, a common pattern for SVG-clipped portraits.

The proof artifact (output of npm run reproduce, which calls prettier.format the same way the plugin does):

Unexpected closing tag ":svg:picture". It may happen when the tag has already been closed by another tag. For more info see https://www.w3.org/TR/html5/syntax.html#closing-elements-that-have-implied-end-tags (7:5)
     5 |       <source type="image/avif" srcset="/img/portrait_275.avif 1x">
     6 |       <img src="/img/portrait_275.jpg" alt="portrait" width="275" height="275">
  >  7 |     </picture>
       |     ^^^^^^^^^^
     8 |   </foreignObject>
     9 | </svg>

The same error surfaces end to end via npm run dev: the page request returns HTTP 500 and the response never contains the <script id="hints-html-validate"> block the plugin inlines on success.

Reproduction

https://stackblitz.com/github/JonathanXDR/repro-nuxt-hints-prettier-foreignobject

Expected behavior

A page containing <svg><foreignObject><picture>...</picture></foreignObject></svg> returns HTTP 200, the html-validate report is stored, and the inline report tag is present in the response body. A formatter failure on one page should not take down the SSR response, since the formatting is purely cosmetic for the inline report. html-validate accepts the original unformatted HTML directly.

Additional context

Root cause is in dist/runtime/html-validate/nitro.plugin.js:

  • line 6 to 7 import format from prettier/standalone and the prettier/parser-html plugin.
  • line 35 registers nitro.hooks.hook('render:response', ...).
  • line 37 const formattedBody = await format(response.body, { plugins: [html], parser: 'html' }) runs with no try/catch. This is the call that throws on the namespace transition.
  • line 38 await validator.validateString(formattedBody) never runs because line 37 already rejected.

Because the throw is unguarded, the entire render:response hook fails for any page matching this shape, and the only user-side workaround is disabling the whole feature with hints.features.htmlValidate: false (there is no per-page or per-rule opt-out).

Two possible fixes: wrap the format(...) call in a try/catch and fall back to response.body (html-validate accepts unformatted HTML), or validate response.body directly and only run prettier for the human-readable copy stored in the report.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions