Skip to content

Build canvas lucide SVG without react-dom/server #2107

Description

@kmcginnes

Remove react-dom/server from the client bundle by building the cytoscape lucide SVG string from Lucide geometry data instead of rendering a React element to markup.

Context

utils/lucideIcons.ts produces the canvas SVG string with:

const { default: Icon } = await dynamicIconImports[iconName]();
return renderToStaticMarkup(createElement(Icon));

renderToStaticMarkup is the only reason react-dom/server (~238 KB raw, ~60–80 KB gzipped) is in the client bundle — verified as the sole import across packages/graph-explorer/src. The DOM surfaces no longer need it at all: VertexSymbolIcon renders lucide through DynamicIcon, so only the cytoscape background-image still needs a string.

Every lucide icon module also exports its geometry as plain data:

// dist/esm/icons/plane.mjs
const __iconNode = [["path", { d: "M17.8 19.2 …", key: "1v9wt8" }]];
export { __iconNode, Plane as default };

DynamicIcon itself reads __iconNode, so this is the same data lucide-react renders from.

Why this was deferred

PR #1777 had a hand-rolled buildSvgString + escapeXmlAttr and its review removed it — finding R7, "buildSvgString unvalidated tag interpolation", resolved as "no longer constructing SVG manually". Reintroducing string concatenation reopens a closed finding, which is a conversation this branch did not need to have.

Build via document.createElementNS + XMLSerializer, not string concatenation. With no interpolation there is no injection surface to validate, so R7 does not apply by construction, and XMLSerializer is already used in core/icons/iconImageUrl.ts.

Scope

  • Pin lucide-react to exact 1.20.0 (currently ^1.20.0) — __iconNode is exported and typed but underscore-prefixed, so treat it as semi-public.
  • Add a shape-assertion test so a future bump that changes __iconNode fails CI loudly rather than silently rendering blank icons.
  • Build the canvas SVG from __iconNode + lucide’s defaultAttributes via createElementNS.
  • Drop renderToStaticMarkup and confirm react-dom/server leaves the bundle.

Notes from investigation

Across all 1985 icon modules in 1.20.0:

  • Only 7 element types: circle, ellipse, line, path, polygon, polyline, rect. No g.
  • Zero icons use id, defs, gradients, masks, clipPath, or style, so there is no collision surface.
  • All icon-level attribute keys are already lowercase. React’s key must be stripped rather than emitted.
  • camelCase conversion is needed for exactly three wrapper attributes, all from the fixed defaultAttributes: strokeWidth, strokeLinecap, strokeLinejoin. A camelCase attribute emitted into raw SVG is silently ignored by the browser, so this is the main correctness risk.
  • 10 icons set their own fill="currentColor" (vault, tag, tags, chart-scatter, …), so per-element fill must be emitted when present.

Out of scope

Recoloring custom SVG icons — tracked in #2105.

Related

Follows the icon pipeline rework in #2102. Background in docs/adr/20260813-icon-registry-not-react-query.md (see the final consequence).


Important

Internal only — this issue is maintained by the core team and is not accepting external contributions.

Metadata

Metadata

Assignees

No one assigned

    Labels

    internalSignals that the team will work on this issue internally.performanceIssues relating to performancetech debtIssues, typically tasks, that are mainly about cleaning up code that is problematic in some way

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions