From 01bd548962093c4ba2e9783b908ce74e2c4eeb4e Mon Sep 17 00:00:00 2001 From: Raymond Iorliam Date: Sat, 17 Oct 2026 09:10:32 +0100 Subject: [PATCH] fix: avoid repeated scans during unclosed-tag repair Assisted-by: OpenAI Codex --- src/nodes/html.ts | 19 +++++-- test/tests/issues/260-unclosed-tags.js | 78 ++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 6 deletions(-) create mode 100644 test/tests/issues/260-unclosed-tags.js diff --git a/src/nodes/html.ts b/src/nodes/html.ts index 04e0c3f..09432b0 100644 --- a/src/nodes/html.ts +++ b/src/nodes/html.ts @@ -1306,9 +1306,7 @@ export function parse(data: string, options = {} as Partial) { // this is wrong, becouse this will put the H3 outside the current right position which should be inside the current Html Element, see issue 152 for more info if (options.parseNoneClosedTags !== true) { oneBefore.removeChild(last); - last.childNodes.forEach((child) => { - oneBefore.parentNode.appendChild(child); - }); + reparentChildren(last, oneBefore.parentNode); stack.pop(); } } else { @@ -1317,9 +1315,7 @@ export function parse(data: string, options = {} as Partial) { // eslint-disable-next-line no-lonely-if if (options.parseNoneClosedTags !== true) { oneBefore.removeChild(last); - last.childNodes.forEach((child) => { - oneBefore.appendChild(child); - }); + reparentChildren(last, oneBefore); } } } else { @@ -1334,6 +1330,17 @@ export function parse(data: string, options = {} as Partial) { return root; } +function reparentChildren(source: HTMLElement, parent: HTMLElement) { + const children = source.childNodes; + source.childNodes = []; + for (const child of children) { + // Detach in bulk: appendChild() would otherwise filter the shrinking source + // array once per child, making repair quadratic in the number of siblings. + child.parentNode = null; + parent.appendChild(child); + } +} + /** * Resolves a list of {@link NodeInsertable} to a list of nodes, * and removes nodes from any potential parent. diff --git a/test/tests/issues/260-unclosed-tags.js b/test/tests/issues/260-unclosed-tags.js new file mode 100644 index 0000000..1588502 --- /dev/null +++ b/test/tests/issues/260-unclosed-tags.js @@ -0,0 +1,78 @@ +const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); +const { parse } = require('@test/test-target'); + +function checkParents(parent) { + parent.childNodes.forEach((child) => { + assert.equal(child.parentNode, parent); + checkParents(child); + }); +} + +describe('unclosed tag repair', function () { + const content = 'alphabetaomega'; + for (const sameTag of [false, true]) { + const opening = sameTag ? '
' : '
'; + const input = `
before${opening}${content}`; + const repaired = `
before${sameTag ? '
' : ''}${content}
`; + + for (const preserve of [undefined, false, true]) { + it(`preserves child order and parent links (${sameTag ? 'same' : 'different'} tags, parseNoneClosedTags=${preserve})`, function () { + const root = parse(input, { comment: true, parseNoneClosedTags: preserve }); + const expected = preserve ? `${input}${sameTag ? '
' : ''}
` : repaired; + assert.equal(root.toString(), expected); + checkParents(root); + const bold = root.querySelector('b'); + assert.equal(bold.previousSibling.toString(), ''); + assert.equal(bold.nextSibling.toString(), 'omega'); + }); + } + + it(`handles empty unclosed ${sameTag ? 'same' : 'different'} tags`, function () { + const root = parse(`
${opening}`); + assert.equal(root.toString(), `
${sameTag ? '
' : ''}
`); + checkParents(root); + }); + + it(`repairs many children of unclosed ${sameTag ? 'same' : 'different'} tags within a bounded process`, function () { + this.timeout(15000); + const args = []; + if (process.env.TEST_TARGET !== 'dist' && !process.env.CI) { + args.push('-r', require.resolve('ts-node/register')); + } + args.push('-e', ` + const assert = require('node:assert/strict'); + const { parse } = require(process.argv[1]); + const sameTag = process.argv[2] === 'true'; + const count = 32000; + const content = 'item'.repeat(count); + const opening = sameTag ? '
' : '
'; + const root = parse('
' + opening + content); + const parent = root.firstChild; + const items = sameTag ? parent.childNodes.slice(1) : parent.childNodes; + assert.equal(items.length, count); + for (const item of items) { + assert.equal(item.rawTagName, 'b'); + assert.equal(item.text, 'item'); + assert.equal(item.parentNode, parent); + assert.equal(item.firstChild.parentNode, item); + } + assert.equal(root.toString(), '
' + (sameTag ? '
' : '') + content + '
'); + process.stdout.write('ok'); + `, require.resolve('@test/test-target'), String(sameTag)); + // Isolate synchronous parsing so a regression cannot hang the test runner. + // The linear path has ample headroom; repeated sibling filtering times out. + const result = spawnSync(process.execPath, args, { timeout: 8000, encoding: 'utf8', windowsHide: true }); + assert.ifError(result.error); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, 'ok'); + }); + } + + it('keeps properly closed parents and their mixed children', function () { + const html = `
before
${content}
after
`; + const root = parse(html, { comment: true }); + assert.equal(root.toString(), html); + checkParents(root); + }); +});