Repository navigation
cleanupIds: minified IDs are not idempotent and can collide #2237
Description
Describe the bug
When minifying, cleanupIds skips a generated ID if that ID belongs to an element it has already renamed. This has two symptoms:
- IDs come out non-consecutive and change again on a second pass.
- With
remove: false, a generated ID can collide with a kept unreferenced ID, producing duplicate IDs and silently repointing references at the wrong element.
To Reproduce
- Optimize with default params:
<svg xmlns="http://www.w3.org/2000/svg">
<defs>
<path id="b" d="M0 0"/>
<path id="a" d="M1 1"/>
</defs>
<use href="#b"/>
<use href="#a"/>
</svg>Pass 1 gives a and c; pass 2 turns c into b.
- Optimize with
{ remove: false }:
<svg xmlns="http://www.w3.org/2000/svg">
<defs>
<path id="x" d="M0 0"/>
<path id="a" d="M1 1"/>
</defs>
<use href="#x"/>
</svg>Both paths come out as id="a", and <use href="#a"> now resolves to whichever the UA picks.
Expected behavior
Pass 1 should produce consecutive IDs (a, b) that survive a second pass, and should never emit duplicate IDs.
Desktop
- SVGO Version: 4.0.2
- Node.js Version: 22+
- OS: Linux / macOS / Windows
Additional context
Root Cause
In plugins/cleanupIds.js, the ID generator avoids capturing dangling external references (#1815) with:
while (
isIdPreserved(currentIdString) ||
(referencesById.has(currentIdString) && nodeById.get(currentIdString) == null)
)Renaming an element runs nodeById.delete(id), so any ID already reassigned during the pass satisfies that test as well and is skipped. The check also doesn't cover unreferenced IDs, which are kept when remove is disabled.
Suggested Fix
Collect external references before any renaming, and guard separately against IDs that will be kept:
--- a/plugins/cleanupIds.js
+++ b/plugins/cleanupIds.js
@@ -214,6 +214,13 @@ export const fn = (_root, params) => {
*/
const isIdPreserved = (id) =>
preserveIds.has(id) || hasStringPrefix(id, preserveIdPrefixes);
+ /** @type {Set<string>} */
+ const externalIds = new Set();
+ for (const id of referencesById.keys()) {
+ if (!nodeById.has(id)) {
+ externalIds.add(id);
+ }
+ }
/** @type {?number[]} */
let currentId = null;
for (const [id, refs] of referencesById) {
@@ -228,8 +235,10 @@ export const fn = (_root, params) => {
currentIdString = getIdString(currentId);
} while (
isIdPreserved(currentIdString) ||
- (referencesById.has(currentIdString) &&
- nodeById.get(currentIdString) == null)
+ externalIds.has(currentIdString) ||
+ (!remove &&
+ nodeById.has(currentIdString) &&
+ !referencesById.has(currentIdString))
);
node.attributes.id = currentIdString;
Activity
fix(cleanupIds): don't skip IDs that were already reassigned
fix(cleanupIds): don't skip IDs that were already reassigned
fix(cleanupIds): don't skip IDs that were already reassigned