Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/tidy-rivers-sing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@naverpay/svg-manager": major
---

[svg-manager] SvgUniqueID를 useId 기반으로 재작성

매 렌더마다 새 난수로 id를 만들어 SSR hydration 불일치를 일으키던 방식을 React `useId`로 교체하고, id 스코핑 로직을 단순화합니다. 생성된 id는 `toSafeId`로 selector/url-safe 토큰으로 정규화되어 서버와 클라이언트에서 동일한 결과를 냅니다.

**Breaking Changes**

- 공개 `toSingleton` export 제거
- `'use client'` 컴포넌트로 전환 (RSC에서 client 경계 생성)

> `useId`는 React 18+ 전용이지만 peerDependencies는 기존과 동일한 `^18 || ^19`로, 이번 변경으로 새로 생긴 요건은 아닙니다.

Issue: [#193](https://github.com/NaverPayDev/pie/issues/193)
26 changes: 14 additions & 12 deletions packages/svg-manager/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,43 +5,45 @@ React utility for making SVG `id` attributes unique across multiple instances of
## Commands

```bash
pnpm build # CJS + ESM (no tests in this package)
pnpm test # vitest, watch=false
pnpm build # CJS + ESM
```

## Structure

```
src/
SvgUniqueID.tsx # main component
index.ts # exports: SvgUniqueID, toSingleton, SVGStyleProps
SvgUniqueID.tsx # main component ('use client')
index.ts # exports: SvgUniqueID, SVGStyleProps
utils/
index.ts # generateRandomString, toSingleton
deepMap.ts # recursive React children traversal
getSecureMathRandom.ts
toSafeId.ts # id → CSS/url-safe token
types/
svg.ts # SVGStyleProps type
utility-types.ts
```

## How It Works

`SvgUniqueID` wraps SVG children and rewrites all `id`, `url(#...)`, and `xlinkHref="#..."` references to unique scoped values using a `prefixId` + random instance `id`.
`SvgUniqueID` wraps SVG children and rewrites all `id`, `url(#...)`, and `xlinkHref="#..."` references to scoped unique values of the form `${prefixId}${instanceId}__${toSafeId(originalId)}__`.

- Uses a module-level singleton `Map<string, number>` (`localIdsMap`) to track original → local ID mapping per render.
- `instanceId` comes from React `useId()` (normalized to a CSS/url-safe token via `toSafeId`), so it is stable across renders and identical on server and client (hydration-safe). An explicit `id` prop overrides it.
- A pure `renameId()` maps each original id to the same scoped value wherever it appears, keeping a definition (`id="x"`) and every reference (`url(#x)`, `xlinkHref="#x"`) in sync. Selector-unsafe characters are normalized to `_`.
- `rewrite()` scopes each prop by name: `id` (whole value), `xlinkHref` (`#id`), and every other prop (`url(#id)`).
- Recursively traverses children with `deepMap` (custom recursive `cloneElement`).
- `toSingleton` is a utility that creates a factory returning the same instance on every call.
- Declares `'use client'` because it uses hooks (`useId`); it runs as a Client Component in RSC/App Router environments.

## Props

```tsx
<SvgUniqueID
prefixId="__SVG_ID__" // default prefix for generated IDs
id={generateRandomString()} // unique instance ID (auto-generated by default)
prefixId="__SVG_ID__" // prefix for generated scoped IDs (default)
id="my-instance" // optional explicit instance id; auto-generated (hydration-safe) when omitted
>
{/* SVG content with id/url(#...)/xlinkHref attributes */}
</SvgUniqueID>
```

## No Tests
## Tests

This package currently has no test suite.
Tests are colocated in `src/` (`SvgUniqueID.test.tsx`, `utils/toSafeId.test.ts`), using vitest in the default node environment — `renderToStaticMarkup` needs no DOM. `vitest.config.mts` exists only to keep vitest from loading `vite.config.mjs` (the pite build config). The component is tested via `renderToStaticMarkup` — asserting scoped-id output, reference/definition integrity (no dangling `url(#...)`), and SSR render determinism (hydration-safety).
1 change: 0 additions & 1 deletion packages/svg-manager/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,3 @@ npm install @naverpay/svg-manager
| :----------------------------------- | :---------------------------------------------------- | :-------- |
| SVGStyleProps | svg 컴포넌트의 Props Type | interface |
| [SvgUniqueID](./docs/SvgUniqueID.md) | svg 컴포넌트에 unique id attribute를 할당하는 Wrapper | component |
| toSingleton | 싱글톤 객체를 생성하는 유틸 함수 | function |
6 changes: 3 additions & 3 deletions packages/svg-manager/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,15 @@ function MyIcon() {
| Prop | Type | Default | Description |
|---|---|---|---|
| `prefixId` | `string` | `'__SVG_ID__'` | Prefix for generated IDs |
| `id` | `string` | `generateRandomString()` | Unique identifier for this instance (auto-generated) |
| `id` | `string` | auto (`useId`) | Optional explicit instance id; auto-generated (hydration-safe) when omitted |
| `children` | `ReactNode` | required | SVG content to scope |

## Exports

```ts
import { SvgUniqueID, toSingleton } from '@naverpay/svg-manager'
import { SvgUniqueID } from '@naverpay/svg-manager'
import type { SVGStyleProps } from '@naverpay/svg-manager'
```

- `toSingleton(factory)` — wraps a factory function so it returns the same instance on every call
- `SvgUniqueID` — wrapper component that scopes SVG ids per instance
- `SVGStyleProps` — TypeScript type for SVG style properties
8 changes: 6 additions & 2 deletions packages/svg-manager/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,19 @@
"author": "@NaverPayDev/frontend",
"scripts": {
"clean": "rm -rf dist",
"build": "npm run clean && vite build"
"build": "npm run clean && vite build",
"test": "vitest --watch=false --reporter=default"
},
"files": [
"dist",
"llms.txt"
],
"devDependencies": {
"@types/react": "catalog:",
"csstype": "^3.1.3"
"csstype": "^3.1.3",
"react": "catalog:",
"react-dom": "catalog:",
"vitest": "^3.1.1"
},
"peerDependencies": {
"react": "catalog:",
Expand Down
125 changes: 125 additions & 0 deletions packages/svg-manager/src/SvgUniqueID.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import {renderToStaticMarkup} from 'react-dom/server'
import {describe, expect, test} from 'vitest'

import SvgUniqueID from './SvgUniqueID'

// 렌더 결과에서 id 정의와 url(#)/xlinkHref 참조를 추출해, 정의가 없는 참조(dangling)를 찾는다.
function analyze(html: string) {
const ids = new Set([...html.matchAll(/\sid="([^"]*)"/g)].map((m) => m[1]))
const refs = [
...[...html.matchAll(/url\(#([^)]*)\)/g)].map((m) => m[1]),
...[...html.matchAll(/(?:xlink:href|href)="#([^"]*)"/g)].map((m) => m[1]),
]
return {ids, refs, dangling: refs.filter((ref) => !ids.has(ref))}
}

describe('SvgUniqueID', () => {
test('정의와 모든 참조가 같은 스코프 id로 매칭된다', () => {
const html = renderToStaticMarkup(
<SvgUniqueID id="X">
<svg>
<defs>
<linearGradient id="grad" />
<clipPath id="clip" />
<filter id="f" />
</defs>
<g clipPath="url(#clip)" filter="url(#f)">
<rect fill="url(#grad)" />
</g>
<use xlinkHref="#grad" />
</svg>
</SvgUniqueID>,
)
const {ids, refs, dangling} = analyze(html)
expect(refs.length).toBeGreaterThan(0)
expect(dangling).toEqual([])
expect(ids.has('__SVG_ID__X__grad__')).toBe(true)
})

test('깊게 중첩된 참조까지 스코핑된다', () => {
const html = renderToStaticMarkup(
<SvgUniqueID id="X">
<svg>
<linearGradient id="deep" />
<g>
<g>
<rect fill="url(#deep)" />
</g>
</g>
</svg>
</SvgUniqueID>,
)
expect(analyze(html).dangling).toEqual([])
expect(html).toContain('__SVG_ID__X__deep__')
})

test('명시 id prop이 스코프에 반영된다', () => {
const html = renderToStaticMarkup(
<SvgUniqueID id="myCustomId">
<svg>
<rect id="a" fill="url(#a)" />
</svg>
</SvgUniqueID>,
)
expect(html).toContain('__SVG_ID__myCustomId__a__')
})

test('prefixId를 커스터마이즈할 수 있다', () => {
const html = renderToStaticMarkup(
<SvgUniqueID id="X" prefixId="__PFX__">
<svg>
<rect id="a" />
</svg>
</SvgUniqueID>,
)
expect(html).toContain('__PFX__X__a__')
})

test('한 트리 안의 여러 인스턴스는 서로 다른 스코프를 갖는다', () => {
// SvgUniqueID는 받은 엘리먼트 트리만 변환하므로 raw <svg>를 직접 감싼다(컴포넌트 내부는 스코핑 불가).
const html = renderToStaticMarkup(
<div>
<SvgUniqueID>
<svg>
<linearGradient id="g" />
<rect fill="url(#g)" />
</svg>
</SvgUniqueID>
<SvgUniqueID>
<svg>
<linearGradient id="g" />
<rect fill="url(#g)" />
</svg>
</SvgUniqueID>
</div>,
)
const ids = [...html.matchAll(/\sid="([^"]*)"/g)].map((m) => m[1])
expect(ids).toHaveLength(2)
expect(ids[0]).not.toBe(ids[1])
expect(analyze(html).dangling).toEqual([])
})

test('같은 트리를 두 번 렌더하면 출력이 동일하다', () => {
const tree = (
<SvgUniqueID>
<svg>
<linearGradient id="grad" />
<rect fill="url(#grad)" />
</svg>
</SvgUniqueID>
)
expect(renderToStaticMarkup(tree)).toBe(renderToStaticMarkup(tree))
})

test('id/url 외의 일반 prop은 변형하지 않는다', () => {
const html = renderToStaticMarkup(
<SvgUniqueID id="X">
<svg>
<rect className="cls" width={10} fill="red" />
</svg>
</SvgUniqueID>,
)
expect(html).toContain('class="cls"')
expect(html).toContain('fill="red"')
})
})
107 changes: 32 additions & 75 deletions packages/svg-manager/src/SvgUniqueID.tsx
Original file line number Diff line number Diff line change
@@ -1,95 +1,52 @@
import {cloneElement} from 'react'
'use client'

import {generateRandomString, toSingleton} from './utils'
import deepMap from './utils/deepMap'

import type {PropsWithChildren, ReactElement} from 'react'

const reactRecursiveChildrenMap = deepMap.bind(deepMap)
import {cloneElement, isValidElement, useId} from 'react'

const generateLocalIdMap = toSingleton(() => new Map<string, number>())
const localIdsMap = generateLocalIdMap()

const SvgUniqueID = ({
children,
prefixId = '__SVG_ID__',
id = generateRandomString(),
}: PropsWithChildren<{prefixId?: string; id?: string}>) => {
let lastLocalId = 0
import deepMap from './utils/deepMap'
import toSafeId from './utils/toSafeId'

const getHookedId = (originalId?: string) => {
if (!originalId) {
return null
}
if (!localIdsMap.has(originalId)) {
localIdsMap.set(originalId, lastLocalId++)
}
import type {PropsWithChildren} from 'react'

const localId = localIdsMap.get(originalId)
return `${prefixId}${id}__${localId}__`
// id/xlinkHref 외 모든 prop은 url(#...) 형태로 처리하며, 패턴이 안 맞으면 원본을 그대로 반환한다.
function rewrite(key: string, value: unknown, rename: (id: string) => string): unknown {
if (typeof value !== 'string') {
return value
}

const fixPropWithUrl = (prop: string) => {
if (typeof prop !== 'string') {
return prop
}

const [, originalId] = prop.match(/^url\(#(.*)\)$/) || [null, null]

if (originalId === null) {
return prop
}

const fixedId = getHookedId(originalId)

if (fixedId === null) {
return prop
}

return `url(#${fixedId})`
if (key === 'id') {
return rename(value)
}
if (key === 'xlinkHref') {
return value.replace(/^#(.*)$/, (_, id) => `#${rename(id)}`)
}
return value.replace(/^url\(#(.*)\)$/, (_, id) => `url(#${rename(id)})`)
}

const getHookedXlinkHref = (prop: string) => {
if (typeof prop !== 'string' || !prop.startsWith('#')) {
return prop
}

const originalId = prop.replace('#', '')
interface SvgUniqueIDProps {
prefixId?: string
id?: string
}

const fixedId = getHookedId(originalId)
if (fixedId === null) {
return prop
}
const SvgUniqueID = ({children, prefixId = '__SVG_ID__', id}: PropsWithChildren<SvgUniqueIDProps>) => {
const autoId = toSafeId(useId())
const instanceId = id ?? autoId

return `#${fixedId}`
}
// 원본 id를 인스턴스별 고유 id로 변환한다.
const renameId = (originalId: string) =>
originalId ? `${prefixId}${instanceId}__${toSafeId(originalId)}__` : originalId

return (
<>
{reactRecursiveChildrenMap(children, (child) => {
if (
!child ||
typeof child === 'string' ||
typeof child === 'number' ||
!('props' in (child as ReactElement))
) {
{deepMap(children, (child) => {
if (!isValidElement(child)) {
return null
}

const ch = child as ReactElement

const fixedId = getHookedId(ch.props.id)

const fixedProps = {
...ch.props,
const rewrittenProps: Record<string, unknown> = {}
for (const [key, value] of Object.entries(child.props)) {
rewrittenProps[key] = rewrite(key, value, renameId)
}

Object.keys(fixedProps).map((key) => (fixedProps[key] = fixPropWithUrl(fixedProps[key])))
return cloneElement(ch, {
...fixedProps,
id: fixedId,
xlinkHref: getHookedXlinkHref(ch.props.xlinkHref),
})
return cloneElement(child, rewrittenProps)
})}
</>
)
Expand Down
2 changes: 0 additions & 2 deletions packages/svg-manager/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
export {default as SvgUniqueID} from './SvgUniqueID'

export {toSingleton} from './utils'

export type {SVGStyleProps} from './types/svg'
Loading
Loading