From c1ce97d97baee31dd07fab170871336b058ac66c Mon Sep 17 00:00:00 2001 From: Wout Mertens Date: Wed, 8 Jul 2026 15:09:21 +0200 Subject: [PATCH] fix(core): allow invalid harmless tag nesting --- .changeset/lenient-tag-nesting.md | 5 + .../qwik/src/server/ssr-container.spec.ts | 82 ++++++++++++++++ packages/qwik/src/server/ssr-container.ts | 70 +++++++++++++ packages/qwik/src/server/tag-nesting.ts | 98 ++++++++++++++++++- packages/qwik/src/server/tag-nesting.unit.ts | 48 ++++++++- 5 files changed, 300 insertions(+), 3 deletions(-) create mode 100644 .changeset/lenient-tag-nesting.md diff --git a/.changeset/lenient-tag-nesting.md b/.changeset/lenient-tag-nesting.md new file mode 100644 index 00000000000..c38da7e6070 --- /dev/null +++ b/.changeset/lenient-tag-nesting.md @@ -0,0 +1,5 @@ +--- +'@qwik.dev/core': patch +--- + +fix: ssr does not throw for invalid html nesting that browsers keep as-is, but only warns diff --git a/packages/qwik/src/server/ssr-container.spec.ts b/packages/qwik/src/server/ssr-container.spec.ts index ab521e4090d..12512f57461 100644 --- a/packages/qwik/src/server/ssr-container.spec.ts +++ b/packages/qwik/src/server/ssr-container.spec.ts @@ -165,6 +165,88 @@ describe('SSR Container', () => { ); }); + describe('lenient tag nesting', () => { + const nestingWarnings = (warn: { mock: { calls: unknown[][] } }) => + warn.mock.calls.filter((call) => String(call[0]).includes('invalid HTML')); + + it('should warn once per combination and keep parser-retained invalid nesting', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const { container, writer } = createTestContainer(); + container.openContainer(); + for (let i = 0; i < 2; i++) { + container.openElement('button', null, {}, null, null, null); + container.openElement('div', null, {}, null, null, null); + await container.closeElement(); + await container.closeElement(); + } + await container.closeContainer(); + + const html = writer.toString(); + expect(html).toContain(' { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const { container } = createTestContainer(); + container.openContainer(); + container.openElement('pre', null, {}, null, null, null); + container.openElement('div', null, {}, null, null, null); + await container.closeElement(); + await container.closeElement(); + expect(nestingWarnings(warn)).toHaveLength(1); + + container.openElement('p', null, {}, null, null, null); + expect(() => container.openElement('div', null, {}, null, null, null)).toThrow( + /HTML rules do not allow/ + ); + } finally { + warn.mockRestore(); + } + }); + + it('should throw when a block tag auto-closes an open p through phrasing ancestors', () => { + const { container } = createTestContainer(); + container.openContainer(); + container.openElement('p', null, {}, null, null, null); + container.openElement('b', null, {}, null, null, null); + expect(() => container.openElement('div', null, {}, null, null, null)).toThrow( + /HTML rules do not allow/ + ); + }); + + it('should throw for nested buttons', () => { + const { container } = createTestContainer(); + container.openContainer(); + container.openElement('button', null, {}, null, null, null); + expect(() => container.openElement('button', null, {}, null, null, null)).toThrow( + /HTML rules do not allow/ + ); + }); + + it('should still throw for table fostering and misplaced structural tags', () => { + const { container } = createTestContainer(); + container.openContainer(); + container.openElement('table', null, {}, null, null, null); + expect(() => container.openElement('div', null, {}, null, null, null)).toThrow( + /HTML rules do not allow/ + ); + + const { container: buttonContainer } = createTestContainer(); + buttonContainer.openContainer(); + buttonContainer.openElement('button', null, {}, null, null, null); + expect(() => buttonContainer.openElement('td', null, {}, null, null, null)).toThrow( + /HTML rules do not allow/ + ); + }); + }); + it('should encode default slot projection refs with wrapped values', () => { const writer = new StringSSRWriter(); diff --git a/packages/qwik/src/server/ssr-container.ts b/packages/qwik/src/server/ssr-container.ts index 20dc76d802e..6688ed66830 100644 --- a/packages/qwik/src/server/ssr-container.ts +++ b/packages/qwik/src/server/ssr-container.ts @@ -120,7 +120,9 @@ import { renderSSRChunks, StringBufferSegmentWriter, StringSSRWriter } from './s import { TagNesting, allowedContent, + closesPTag, initialTag, + isRetainedWhenInvalid, isSelfClosingTag, isTagAllowed, } from './tag-nesting'; @@ -286,6 +288,8 @@ class SSRContainer extends _SharedContainer implements ISSRContainer { private backpatchMap = new Map(); private currentElementFrame: ElementFrame | null = null; + /** Dev-only: parent>child combos already warned about, so each warns once per render. */ + private warnedNestingCombos: Set | null = null; private renderTimer: ReturnType; /** @@ -1596,6 +1600,15 @@ class SSRContainer extends _SharedContainer implements ISSRContainer { let frame: ElementFrame | null = this.currentElementFrame; const previousTagNesting = frame!.tagNesting; tagNesting = isTagAllowed(previousTagNesting, elementName); + if ( + tagNesting === TagNesting.NOT_ALLOWED && + this.isNestingRetainedByParser(previousTagNesting, elementName) + ) { + if (isDev) { + this.warnInvalidNesting(elementName, currentFile); + } + tagNesting = isTagAllowed(TagNesting.ANYTHING, elementName); + } if (tagNesting === TagNesting.NOT_ALLOWED) { const frames: ElementFrame[] = []; while (frame) { @@ -1654,6 +1667,63 @@ class SSRContainer extends _SharedContainer implements ISSRContainer { return closingFrame; } + /** True when the HTML parser keeps this invalid nesting in the DOM as-is instead of rewriting it. */ + private isNestingRetainedByParser(parentState: TagNesting, elementName: string): boolean { + if (!isRetainedWhenInvalid(parentState, elementName)) { + return false; + } + if (closesPTag(elementName) && this.hasOpenTagInScope('p')) { + return false; + } + if (elementName === 'button' && this.hasOpenTagInScope('button')) { + return false; + } + return true; + } + + /** Mirrors the parser's "has an element in scope" check over the open element frames. */ + private hasOpenTagInScope(tagName: string): boolean { + let frame = this.currentElementFrame; + while (frame) { + if (frame.elementName === tagName) { + return true; + } + switch (frame.elementName) { + case 'applet': + case 'button': + case 'caption': + case 'html': + case 'marquee': + case 'object': + case 'table': + case 'td': + case 'template': + case 'th': + return false; + } + frame = frame.parent; + } + return false; + } + + private warnInvalidNesting(elementName: string, currentFile: string | null | undefined) { + if (!isDev) { + return; + } + const parentName = this.currentElementFrame!.elementName; + const combo = `${parentName}>${elementName}`; + const warnedCombos = (this.warnedNestingCombos ||= new Set()); + if (warnedCombos.has(combo)) { + return; + } + warnedCombos.add(combo); + console.warn( + `Qwik SSR: '<${elementName}>' inside '<${parentName}>' is invalid HTML. ` + + `Browsers keep it in the DOM so rendering continues, but fix the markup to emit valid HTML.` + + (currentFile ? ` Found in ${currentFile}` : '') + ); + } + //////////////////////////////////// write(text: string) { this.size += text.length; diff --git a/packages/qwik/src/server/tag-nesting.ts b/packages/qwik/src/server/tag-nesting.ts index b1a35036682..10c9e0894e1 100644 --- a/packages/qwik/src/server/tag-nesting.ts +++ b/packages/qwik/src/server/tag-nesting.ts @@ -3,6 +3,10 @@ * * This file contains element tag nesting rules of HTML. * + * The nesting states encode the HTML authoring spec. A violation only breaks Qwik when the parsing + * spec rewrites the DOM (auto-closing, foster parenting, ignored tags); violations the parser + * keeps as-is are classified by `isRetainedWhenInvalid` and only warrant a dev warning. + * * The rules are encoded as switch statements rather than as object literal lookups because: * * 1. Switch statements are faster than object literal lookups. @@ -190,6 +194,7 @@ function isInAnything(text: string): TagNesting { case 'style': case 'noscript': case 'noframes': + case 'textarea': // rawtext element; element children would be parsed as text return TagNesting.TEXT; case 'p': case 'pre': @@ -203,7 +208,6 @@ function isInAnything(text: string): TagNesting { case 'button': return TagNesting.BUTTON; case 'input': - case 'textarea': return TagNesting.PHRASING_INSIDE_INPUT; case 'picture': return TagNesting.PICTURE; @@ -298,8 +302,9 @@ function isInPhrasing(text: string, allowInput: boolean): TagNesting { case 'math': return TagNesting.PHRASING_CONTAINER; case 'input': - case 'textarea': return allowInput ? TagNesting.PHRASING_INSIDE_INPUT : TagNesting.NOT_ALLOWED; + case 'textarea': + return allowInput ? TagNesting.TEXT : TagNesting.NOT_ALLOWED; case 'a': case 'abbr': case 'area': @@ -364,3 +369,92 @@ function isInPhrasing(text: string, allowInput: boolean): TagNesting { return TagNesting.NOT_ALLOWED; } } + +/** Start tags that make the parser auto-close an open `

` element in button scope. */ +export function closesPTag(tag: string): boolean { + switch (tag) { + case 'address': + case 'article': + case 'aside': + case 'blockquote': + case 'center': + case 'details': + case 'dialog': + case 'dir': + case 'div': + case 'dl': + case 'fieldset': + case 'figcaption': + case 'figure': + case 'footer': + case 'form': + case 'h1': + case 'h2': + case 'h3': + case 'h4': + case 'h5': + case 'h6': + case 'header': + case 'hgroup': + case 'hr': + case 'listing': + case 'main': + case 'menu': + case 'nav': + case 'ol': + case 'p': + case 'plaintext': + case 'pre': + case 'section': + case 'summary': + case 'table': + case 'ul': + case 'xmp': + return true; + default: + return false; + } +} + +/** Tags the parser ignores or relocates when misplaced, so the DOM never keeps them as-is. */ +function isStructuralTag(tag: string): boolean { + switch (tag) { + case 'html': + case 'head': + case 'body': + case 'frame': + case 'frameset': + case 'caption': + case 'col': + case 'colgroup': + case 'tbody': + case 'thead': + case 'tfoot': + case 'tr': + case 'td': + case 'th': + return true; + default: + return false; + } +} + +/** + * True when the parsing spec keeps this authoring-invalid child in the DOM as-is. Callers must + * still check recovery that depends on open ancestors: `closesPTag` with an open `

` in button + * scope, and a `