Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/lenient-tag-nesting.md
Original file line number Diff line number Diff line change
@@ -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
82 changes: 82 additions & 0 deletions packages/qwik/src/server/ssr-container.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<button');
expect(html).toContain('<div');
expect(nestingWarnings(warn)).toHaveLength(1);
} finally {
warn.mockRestore();
}
});

it('should warn for div inside pre but throw for div inside p', async () => {
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();

Expand Down
70 changes: 70 additions & 0 deletions packages/qwik/src/server/ssr-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,9 @@ import { renderSSRChunks, StringBufferSegmentWriter, StringSSRWriter } from './s
import {
TagNesting,
allowedContent,
closesPTag,
initialTag,
isRetainedWhenInvalid,
isSelfClosingTag,
isTagAllowed,
} from './tag-nesting';
Expand Down Expand Up @@ -286,6 +288,8 @@ class SSRContainer extends _SharedContainer implements ISSRContainer {
private backpatchMap = new Map<number | string, BackpatchEntry[]>();

private currentElementFrame: ElementFrame | null = null;
/** Dev-only: parent>child combos already warned about, so each warns once per render. */
private warnedNestingCombos: Set<string> | null = null;

private renderTimer: ReturnType<typeof createTimer>;
/**
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down
98 changes: 96 additions & 2 deletions packages/qwik/src/server/tag-nesting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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':
Expand All @@ -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;
Comment on lines 208 to 213
Expand Down Expand Up @@ -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':
Expand Down Expand Up @@ -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 `<p>` 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':
Comment on lines +384 to +386
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':
Comment on lines +402 to +405
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 `<p>` in button
* scope, and a `<button>` start tag with an open `<button>` in scope.
*/
export function isRetainedWhenInvalid(parentState: TagNesting, tag: string): boolean {
if (isStructuralTag(tag)) {
return false;
}
switch (parentState) {
case TagNesting.BUTTON:
case TagNesting.PHRASING_ANY:
case TagNesting.PHRASING_INSIDE_INPUT:
case TagNesting.PICTURE:
return true;
default:
return false;
}
}
48 changes: 47 additions & 1 deletion packages/qwik/src/server/tag-nesting.unit.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { TagNesting, isTagAllowed } from './tag-nesting';
import { TagNesting, closesPTag, isRetainedWhenInvalid, isTagAllowed } from './tag-nesting';

describe('tag-nesting', () => {
// Head element tests
Expand Down Expand Up @@ -226,6 +226,52 @@ describe('tag-nesting', () => {
});
});

describe('rawtext elements', () => {
it('should allow text content in textarea elements', () => {
expect(isValidNesting('html>body>textarea>#text')).toBe(true);
});

it('should not allow element children in textarea elements', () => {
expect(isValidNesting('html>body>textarea>span')).toBe('span');
});
});

describe('parser leniency classification', () => {
it('should keep invalid flow content in button/phrasing/picture contexts', () => {
expect(isRetainedWhenInvalid(TagNesting.BUTTON, 'div')).toBe(true);
expect(isRetainedWhenInvalid(TagNesting.BUTTON, 'a')).toBe(true);
expect(isRetainedWhenInvalid(TagNesting.BUTTON, 'input')).toBe(true);
expect(isRetainedWhenInvalid(TagNesting.PHRASING_ANY, 'div')).toBe(true);
expect(isRetainedWhenInvalid(TagNesting.PHRASING_INSIDE_INPUT, 'input')).toBe(true);
expect(isRetainedWhenInvalid(TagNesting.PICTURE, 'div')).toBe(true);
});

it('should not keep children where the parser rewrites the DOM', () => {
expect(isRetainedWhenInvalid(TagNesting.TEXT, 'div')).toBe(false);
expect(isRetainedWhenInvalid(TagNesting.EMPTY, 'div')).toBe(false);
expect(isRetainedWhenInvalid(TagNesting.TABLE, 'div')).toBe(false);
expect(isRetainedWhenInvalid(TagNesting.TABLE_ROW, 'div')).toBe(false);
expect(isRetainedWhenInvalid(TagNesting.HEAD, 'div')).toBe(false);
expect(isRetainedWhenInvalid(TagNesting.DOCUMENT, 'div')).toBe(false);
});

it('should not keep structural tags outside their required context', () => {
expect(isRetainedWhenInvalid(TagNesting.BUTTON, 'td')).toBe(false);
expect(isRetainedWhenInvalid(TagNesting.PHRASING_ANY, 'tr')).toBe(false);
expect(isRetainedWhenInvalid(TagNesting.PICTURE, 'body')).toBe(false);
});

it('should know which start tags auto-close an open p element', () => {
expect(closesPTag('div')).toBe(true);
expect(closesPTag('p')).toBe(true);
expect(closesPTag('table')).toBe(true);
expect(closesPTag('ul')).toBe(true);
expect(closesPTag('h1')).toBe(true);
expect(closesPTag('span')).toBe(false);
expect(closesPTag('marquee')).toBe(false);
});
});

describe('button element placement', () => {
it('should allow button elements in p elements', () => {
expect(isValidNesting('html>body>p>button')).toBe(true);
Expand Down
Loading