Skip to content

Commit f029171

Browse files
committed
refactor(vanilla): unify gradient and clip-path ID generation via nextSvgId
Two SVG ID generation strategies existed: gradient-utils used a monotonic counter (introduced by 73ef048 after random-hex collisions surfaced in production), while svg-renderer still used `Math.random().toString(36)` for clip-path IDs. That left the clip-path path with the same ~1/16M collision risk the gradient fix eliminated. Unified both under a new `nextSvgId(prefix)` helper in `svg-ids.ts`. The counter is shared across ID types, so gradient IDs may now skip numbers when clip-paths consume counter slots. Still monotonic, still unique. Format changes: clip-path IDs go from `oc-clip-<hex>` to `oc-clip-N`. Gradient IDs stay `oc-grad-N` but the numbering sequence now interleaves with clip-path allocations. Internal SVG IDs aren't part of our public API, but snapshot-based consumer tests matching the specific hex clip-path format would break. Visual regression tests strip all `[id]` attributes before screenshot comparison, so rendering output is unaffected. Extended gradient-ids.test.ts to cover clip-path uniqueness and the core cross-type uniqueness guarantee across two chart mounts.
1 parent 303175a commit f029171

4 files changed

Lines changed: 98 additions & 22 deletions

File tree

packages/vanilla/src/__tests__/gradient-ids.test.ts

Lines changed: 64 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,15 @@
11
/**
2-
* Characterization test for multi-chart gradient ID uniqueness.
2+
* Multi-chart SVG ID uniqueness: gradients AND clip-paths.
33
*
4-
* Part of refactor/v7-cohesion step 1. Pins the behavior of the global
5-
* gradient counter in `packages/vanilla/src/gradient-utils.ts` (commit 73ef048).
4+
* Originally locked gradient-counter behavior from commit 73ef048 (fix for
5+
* random-hex gradient collisions). Step 6 of refactor/v7-cohesion unified
6+
* gradient and clip-path ID generation under `nextSvgId` in `svg-ids.ts`,
7+
* so this test now guards both halves.
68
*
7-
* Before the fix, gradient IDs used random hex suffixes that could collide when
8-
* multiple charts with gradient fills rendered into the same document. Because
9-
* SVG url(#id) resolves against the full document, a collision caused one chart
10-
* to inherit another chart's gradient. The fix replaced the random suffix with a
11-
* module-global monotonic counter so IDs are always unique across charts.
12-
*
13-
* Step 6 of the v7 refactor plans to unify gradient and clipPath ID generation.
14-
* This test guards uniqueness across that consolidation.
9+
* Why it matters: SVG `url(#id)` resolves against the full document. If two
10+
* charts on the same page generate overlapping IDs, one chart silently
11+
* inherits the other's gradient fill or clip region. The shared monotonic
12+
* counter makes uniqueness unconditional.
1513
*/
1614

1715
import type { ChartSpec, LinearGradient } from '@opendata-ai/openchart-core';
@@ -69,7 +67,16 @@ function collectGradientIds(root: HTMLElement): string[] {
6967
return ids;
7068
}
7169

72-
describe('gradient ID uniqueness across charts', () => {
70+
function collectClipPathIds(root: HTMLElement): string[] {
71+
const ids: string[] = [];
72+
for (const el of root.querySelectorAll('clipPath')) {
73+
const id = el.getAttribute('id');
74+
if (id) ids.push(id);
75+
}
76+
return ids;
77+
}
78+
79+
describe('SVG ID uniqueness across charts', () => {
7380
let a: HTMLDivElement;
7481
let b: HTMLDivElement;
7582

@@ -82,7 +89,7 @@ describe('gradient ID uniqueness across charts', () => {
8289
document.body.innerHTML = '';
8390
});
8491

85-
it('two charts mounted in separate containers produce disjoint gradient IDs', () => {
92+
it('two charts produce disjoint gradient IDs', () => {
8693
const chartA = createChart(a, makeBarSpec(barGradient));
8794
const chartB = createChart(b, makeBarSpec(altGradient));
8895

@@ -105,4 +112,48 @@ describe('gradient ID uniqueness across charts', () => {
105112
chartA.destroy();
106113
chartB.destroy();
107114
});
115+
116+
it('two charts produce disjoint clip-path IDs', () => {
117+
// Every chart render creates a <clipPath> for the plot area, so any spec
118+
// works here. Reuse the gradient specs for consistency.
119+
const chartA = createChart(a, makeBarSpec(barGradient));
120+
const chartB = createChart(b, makeBarSpec(altGradient));
121+
122+
const idsA = collectClipPathIds(a);
123+
const idsB = collectClipPathIds(b);
124+
125+
expect(idsA.length).toBeGreaterThan(0);
126+
expect(idsB.length).toBeGreaterThan(0);
127+
128+
const all = [...idsA, ...idsB];
129+
expect(new Set(all).size).toBe(all.length);
130+
131+
for (const id of all) {
132+
expect(id).toMatch(/^oc-clip-\d+$/);
133+
}
134+
135+
chartA.destroy();
136+
chartB.destroy();
137+
});
138+
139+
it('gradient and clip-path IDs never collide across two charts', () => {
140+
// Core guarantee of the unified nextSvgId counter: even though gradients
141+
// and clip-paths use different prefixes, the counter values are shared.
142+
// Collecting every ID from both <defs> trees should yield a strict set.
143+
const chartA = createChart(a, makeBarSpec(barGradient));
144+
const chartB = createChart(b, makeBarSpec(altGradient));
145+
146+
const all = [
147+
...collectGradientIds(a),
148+
...collectClipPathIds(a),
149+
...collectGradientIds(b),
150+
...collectClipPathIds(b),
151+
];
152+
153+
expect(all.length).toBeGreaterThan(0);
154+
expect(new Set(all).size).toBe(all.length);
155+
156+
chartA.destroy();
157+
chartB.destroy();
158+
});
108159
});

packages/vanilla/src/gradient-utils.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import type { GradientDef, LinearGradient, RadialGradient } from '@opendata-ai/openchart-core';
77
import { isGradientDef } from '@opendata-ai/openchart-core';
8+
import { nextSvgId } from './svg-ids';
89

910
const SVG_NS = 'http://www.w3.org/2000/svg';
1011

@@ -89,18 +90,15 @@ function appendStop(
8990
parent.appendChild(stopEl);
9091
}
9192

92-
/**
93-
* Global counter for gradient IDs. Ensures uniqueness across all charts
94-
* on the same page, since SVG url(#id) resolves globally in the document.
95-
*/
96-
let globalGradientCounter = 0;
97-
9893
/**
9994
* Scan all marks for GradientDef fill values, create SVG gradient elements
10095
* in the provided <defs> node, and return a map from gradient key to element ID.
10196
*
10297
* Identical gradients (by key) share a single SVG element within one chart.
103-
* IDs are globally unique across charts to avoid SVG url(#id) collisions.
98+
* IDs come from `nextSvgId` so they're globally unique across charts and
99+
* share the counter with clip-path IDs (see svg-ids.ts). Gradient ID numbers
100+
* may skip values when clip-paths consume counter slots - still monotonic,
101+
* still unique.
104102
*/
105103
export function buildGradientDefs(
106104
marks: Array<{ fill?: unknown }>,
@@ -113,7 +111,7 @@ export function buildGradientDefs(
113111
if (fill && isGradientDef(fill)) {
114112
const key = gradientKey(fill);
115113
if (!map.has(key)) {
116-
const id = `oc-grad-${globalGradientCounter++}`;
114+
const id = nextSvgId('oc-grad');
117115
const el = createGradientElement(fill, id);
118116
defs.appendChild(el);
119117
map.set(key, id);

packages/vanilla/src/svg-ids.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/**
2+
* Monotonic SVG ID generator shared by gradient and clipPath defs.
3+
*
4+
* Why a single counter: SVG `url(#id)` references resolve against the full
5+
* document, so any ID collision (across charts on the same page) silently
6+
* cross-wires one chart's fill/clip into another. Random-hex suffixes have a
7+
* small-but-real collision probability that surfaced in production; a global
8+
* monotonic counter makes uniqueness unconditional.
9+
*
10+
* Gradient and clip-path IDs share one counter so we can't regress one half of
11+
* the system by accident. Consumers just pick a prefix.
12+
*/
13+
14+
let counter = 0;
15+
16+
export function nextSvgId(prefix: string): string {
17+
return `${prefix}-${counter++}`;
18+
}
19+
20+
/**
21+
* Reset the counter. Intended for isolated test runs only - production code
22+
* should never call this. The underscore prefix marks it as non-public.
23+
*/
24+
export function _resetSvgIdCounter(): void {
25+
counter = 0;
26+
}

packages/vanilla/src/svg-renderer.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
} from '@opendata-ai/openchart-core';
3838
import { clampStaggerDelay } from '@opendata-ai/openchart-engine';
3939
import { buildGradientDefs, resolveMarkFill } from './gradient-utils';
40+
import { nextSvgId } from './svg-ids';
4041

4142
const SVG_NS = 'http://www.w3.org/2000/svg';
4243

@@ -1244,7 +1245,7 @@ export function renderChartSVG(
12441245
// Clip path to prevent marks (especially area fills) from overflowing
12451246
// into the chrome region (title/subtitle). Extends full width so
12461247
// end-of-line labels aren't clipped, but constrains vertically.
1247-
const clipId = `oc-clip-${Math.random().toString(36).slice(2, 8)}`;
1248+
const clipId = nextSvgId('oc-clip');
12481249
const defs = createSVGElement('defs');
12491250
const clipPath = createSVGElement('clipPath');
12501251
clipPath.setAttribute('id', clipId);

0 commit comments

Comments
 (0)