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
13 changes: 13 additions & 0 deletions src/Parser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,19 @@ describe("API", () => {
expect(reads).toBeLessThan(depth * 10);
});

it.each([
["end tags nobody opened", "<div>", "</span>"],
["repeated form tags", "<div>", "<form>"],
["SVG-cased names in an integration point", "<div>", "<clippath>"],
])("should do linear stack work for %s", (_, open, next) => {
const depth = 500;
const parser = new Parser({});
const prefix = next === "<clippath>" ? "<svg><foreignObject>" : "";
const input = prefix + open.repeat(depth) + next.repeat(depth);
const reads = countArrayReads(parser, "stack", () => parser.end(input));
expect(reads).toBeLessThan(depth * 10);
});

it("should stop closing elements when reset at the end of the input", () => {
const closed: string[] = [];
const parser = new Parser({
Expand Down
28 changes: 24 additions & 4 deletions src/Parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,11 @@ export class Parser implements Callbacks {
private attribvalue = "";
private attribs: null | { [key: string]: string } = null;
private readonly stack: string[] = [];
/**
* How many elements of each name `stack` holds. Asking whether a name is
* open is one lookup instead of a scan of every open element.
*/
private readonly openCounts = new Map<string, number>();
private readonly foreignContext: ForeignContext[];
private readonly cbs: Partial<Handler>;
private readonly lowerCaseTagNames: boolean;
Expand Down Expand Up @@ -349,7 +354,7 @@ export class Parser implements Callbacks {
*/
if (this.foreignContext.length > 1) {
const adjusted = svgTagNameAdjustments.get(name);
if (adjusted !== undefined && this.stack.includes(adjusted)) {
if (adjusted !== undefined && this.openCounts.has(adjusted)) {
return adjusted;
}
}
Expand All @@ -371,7 +376,7 @@ export class Parser implements Callbacks {
* stays null so endOpenTag is a no-op, and closeCurrentTag can't
* match "" on the stack.
*/
if (this.htmlMode && name === "form" && this.stack.includes("form")) {
if (this.htmlMode && name === "form" && this.openCounts.has("form")) {
this.tagname = "";
return;
}
Expand All @@ -388,6 +393,7 @@ export class Parser implements Callbacks {
}
if (!this.isVoidElement(name)) {
this.stack.push(name);
this.openCounts.set(name, (this.openCounts.get(name) ?? 0) + 1);

if (this.htmlMode) {
if (name === "svg") {
Expand Down Expand Up @@ -419,12 +425,18 @@ export class Parser implements Callbacks {

/**
* Pop the top element off the stack, emit a close event, and maintain
* the foreign context stack.
* the open-name counts and the foreign context stack.
* @param isImplied Whether this close is implied (not from an explicit end tag).
*/
private popElement(isImplied: boolean): void {
// biome-ignore lint/style/noNonNullAssertion: The element is guaranteed to exist.
const element = this.stack.pop()!;
const count = this.openCounts.get(element) ?? 0;
if (count > 1) {
this.openCounts.set(element, count - 1);
} else {
this.openCounts.delete(element);
}
if (
this.htmlMode &&
(foreignContextElements.has(element) ||
Expand Down Expand Up @@ -558,7 +570,14 @@ export class Parser implements Callbacks {
const name = this.readTagName(start, endIndex);

if (!this.isVoidElement(name)) {
const pos = this.stack.lastIndexOf(name);
/*
* A name nobody opened is a miss without reading the stack. An
* open one is found from the top, so the scan only passes the
* elements this close tag then pops.
*/
const pos = this.openCounts.has(name)
? this.stack.lastIndexOf(name)
: -1;
if (pos !== -1) {
for (let index = this.stack.length - 1; index > pos; index--) {
this.popElement(true);
Expand Down Expand Up @@ -764,6 +783,7 @@ export class Parser implements Callbacks {
this.attribvalue = "";
this.attribs = null;
this.stack.length = 0;
this.openCounts.clear();
this.startIndex = 0;
this.endIndex = 0;
this.cbs.onparserinit?.(this);
Expand Down