Conversation
…t nodes Merging each text node with `node.data + child.data` copies the growing string once per absorbed node. Collect the pieces and join each run once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
aisk
reviewed
Oct 5, 2026
| , "testNormalizeDeleteAndCombine -- result") | ||
| doc.unlink() | ||
|
|
||
| def testNormalizeManyTextNodes(self): |
Member
There was a problem hiding this comment.
This test doesn't assert anything, so it doesn't really verify the change. I think it's hard to write a test for this, so it may be fine to just remove it.
I've run the benchmark locally (debug build, one element with n text nodes of 48 chars each):
| n | before | after |
|---|---|---|
| 8,000 | 2.8 s | 18 ms |
| 16,000 | 11.3 s | 37 ms |
| 32,000 | 45.6 s | 75 ms |
| 64,000 | 181 s | 148 ms |
aisk
reviewed
Oct 5, 2026
| L = [] | ||
| # (text node, [data, ...]) for each text node that absorbs others. | ||
| # Join each run once at the end; concatenating as we go is quadratic. | ||
| runs = None |
Member
There was a problem hiding this comment.
Maybe we can initialize it here, then the None guards at L214 and L230 can be removed:
Suggested change
| runs = None | |
| runs = [] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Node.normalize()merges adjacent text nodes withnode.data = node.data + child.data, once per absorbed node. A run of n text nodes copies the growing string n times, so it's quadratic.This collects the pieces for each run and joins them once at the end:
for child in self.childNodes: ... elif L and L[-1].nodeType == child.nodeType: - node.data = node.data + child.data + runs[-1][1].append(child.data) # or start a new run for node ... +for node, data in runs: + node.data = ''.join(data) self.childNodes[:] = LSibling links are still fixed up inline, as before.
runsis only created when something actually merges.Parsing doesn't hit this, because expatbuilder already merges character data into the previous text node. You only get long runs from documents built through the DOM API.
Evidence
One element with n text-node children, 3.16 dev build, macOS arm64:
New test
testNormalizeManyTextNodes(100k nodes, with an empty one every third node) takes 1.35 s before and 0.18 s after. Like the other complexity tests, it's slow-before / fast-after, not a hard timing assertion.Differential check against the old
normalize(): 20,000 random DOMs (text, empty text, elements, comments, nested).toxml()output and everyparentNode/previousSibling/nextSiblinglink are identical.test_minidom,test_pulldom,test_xml_dom_minicompat: 179 run, all pass.Merge Danger
Door: two-way
Same result and same links. Only how the merged string gets built changes.
Blast Radius: a small slowdown when nothing merges
Normalizing an already-normal parsed document (200 items, 601
normalize()calls) went from about 102 µs to about 106 µs, measured over 10 alternating runs in both orders. That's the cost of the extraruns is Nonecheck, roughly 4 ns per call.AI disclosure: the profiling and the patch were done with AI tools (Codex, Claude).
🤖 Generated with Claude Code