Skip to content

cleanupIds: minified IDs are not idempotent and can collide #2237

Description

@victorvianna

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:

  1. IDs come out non-consecutive and change again on a second pass.
  2. 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

  1. 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.

  1. 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

  1. added theissue type on Aug 7, 2026
  2. added 3 commits that reference this issue on Aug 7, 2026
    d5a3372
    7ee2de7
    e78ab82
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions