diff --git a/.changeset/tidy-rivers-sing.md b/.changeset/tidy-rivers-sing.md new file mode 100644 index 00000000..34e098bb --- /dev/null +++ b/.changeset/tidy-rivers-sing.md @@ -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) diff --git a/packages/svg-manager/CLAUDE.md b/packages/svg-manager/CLAUDE.md index 6a18b85a..97a34bce 100644 --- a/packages/svg-manager/CLAUDE.md +++ b/packages/svg-manager/CLAUDE.md @@ -5,19 +5,19 @@ 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 @@ -25,23 +25,25 @@ src/ ## 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` (`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 {/* SVG content with id/url(#...)/xlinkHref attributes */} ``` -## 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). diff --git a/packages/svg-manager/README.md b/packages/svg-manager/README.md index 44d59eca..242e9ef9 100644 --- a/packages/svg-manager/README.md +++ b/packages/svg-manager/README.md @@ -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 | diff --git a/packages/svg-manager/llms.txt b/packages/svg-manager/llms.txt index ec0c0b69..79bece97 100644 --- a/packages/svg-manager/llms.txt +++ b/packages/svg-manager/llms.txt @@ -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 diff --git a/packages/svg-manager/package.json b/packages/svg-manager/package.json index 09d89be9..24aaa76c 100644 --- a/packages/svg-manager/package.json +++ b/packages/svg-manager/package.json @@ -35,7 +35,8 @@ "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", @@ -43,7 +44,10 @@ ], "devDependencies": { "@types/react": "catalog:", - "csstype": "^3.1.3" + "csstype": "^3.1.3", + "react": "catalog:", + "react-dom": "catalog:", + "vitest": "^3.1.1" }, "peerDependencies": { "react": "catalog:", diff --git a/packages/svg-manager/src/SvgUniqueID.test.tsx b/packages/svg-manager/src/SvgUniqueID.test.tsx new file mode 100644 index 00000000..576ab8fb --- /dev/null +++ b/packages/svg-manager/src/SvgUniqueID.test.tsx @@ -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( + + + + + + + + + + + + + , + ) + 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( + + + + + + + + + + , + ) + expect(analyze(html).dangling).toEqual([]) + expect(html).toContain('__SVG_ID__X__deep__') + }) + + test('명시 id prop이 스코프에 반영된다', () => { + const html = renderToStaticMarkup( + + + + + , + ) + expect(html).toContain('__SVG_ID__myCustomId__a__') + }) + + test('prefixId를 커스터마이즈할 수 있다', () => { + const html = renderToStaticMarkup( + + + + + , + ) + expect(html).toContain('__PFX__X__a__') + }) + + test('한 트리 안의 여러 인스턴스는 서로 다른 스코프를 갖는다', () => { + // SvgUniqueID는 받은 엘리먼트 트리만 변환하므로 raw 를 직접 감싼다(컴포넌트 내부는 스코핑 불가). + const html = renderToStaticMarkup( +
+ + + + + + + + + + + + +
, + ) + 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 = ( + + + + + + + ) + expect(renderToStaticMarkup(tree)).toBe(renderToStaticMarkup(tree)) + }) + + test('id/url 외의 일반 prop은 변형하지 않는다', () => { + const html = renderToStaticMarkup( + + + + + , + ) + expect(html).toContain('class="cls"') + expect(html).toContain('fill="red"') + }) +}) diff --git a/packages/svg-manager/src/SvgUniqueID.tsx b/packages/svg-manager/src/SvgUniqueID.tsx index 28d76ea4..b756d0d1 100644 --- a/packages/svg-manager/src/SvgUniqueID.tsx +++ b/packages/svg-manager/src/SvgUniqueID.tsx @@ -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()) -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) => { + 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 = {} + 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) })} ) diff --git a/packages/svg-manager/src/index.ts b/packages/svg-manager/src/index.ts index 17e87599..b1eda2c4 100644 --- a/packages/svg-manager/src/index.ts +++ b/packages/svg-manager/src/index.ts @@ -1,5 +1,3 @@ export {default as SvgUniqueID} from './SvgUniqueID' -export {toSingleton} from './utils' - export type {SVGStyleProps} from './types/svg' diff --git a/packages/svg-manager/src/utils/deepMap.ts b/packages/svg-manager/src/utils/deepMap.ts index ab7ffb84..9b51b1dc 100644 --- a/packages/svg-manager/src/utils/deepMap.ts +++ b/packages/svg-manager/src/utils/deepMap.ts @@ -7,14 +7,7 @@ function hasChildren(element: ReactNode): element is ReactElement<{children: Rea } function hasComplexChildren(element: ReactNode): element is ReactElement<{children: ReactNode | ReactNode[]}> { - return ( - isValidElement(element) && - hasChildren(element) && - Children.toArray(element.props.children).reduce( - (response: boolean, child: ReactNode): boolean => response || isValidElement(child), - false, - ) - ) + return hasChildren(element) && Children.toArray(element.props.children).some((child) => isValidElement(child)) } function deepMap( @@ -22,7 +15,7 @@ function deepMap( deepMapFn: (child: ReactNode, index?: number, mapChildren?: ReactNode[]) => ReactNode, ): ReactNode[] { return Children.toArray(children).map((child: ReactNode, index: number, mapChildren: ReactNode[]) => { - if (isValidElement(child) && hasComplexChildren(child)) { + if (hasComplexChildren(child)) { // Clone the child that has children and map them too return deepMapFn( cloneElement(child, { diff --git a/packages/svg-manager/src/utils/getSecureMathRandom.ts b/packages/svg-manager/src/utils/getSecureMathRandom.ts deleted file mode 100644 index b149e8a2..00000000 --- a/packages/svg-manager/src/utils/getSecureMathRandom.ts +++ /dev/null @@ -1,53 +0,0 @@ -// TODO: common-utils 이관 필요 -declare global { - interface Window { - msCrypto: Crypto - } -} - -function getSecureMathRandomBrowser() { - const array = new Uint32Array(1) - const maxNumber = Math.pow(2, 32) - 1 - - if (typeof window.msCrypto !== 'undefined') { - return window.msCrypto.getRandomValues(array)[0] / maxNumber - } - - if (typeof window.crypto !== 'undefined') { - return window.crypto.getRandomValues(array)[0] / maxNumber - } - - // eslint-disable-next-line no-console - console.log(`fail to generate secure random. Neither window.msCrypto nor window.crypto is defined in window`) - return Math.random() -} - -function getSecureMathRandomServer() { - const array = new Uint32Array(1) - const maxNumber = Math.pow(2, 32) - 1 - - if (!crypto || !crypto?.getRandomValues) { - // eslint-disable-next-line no-console - console.log( - "If the node version is less than 20, a require ('crypto') is required. To use the getSecureMathRandomServer properly, update the node version to 20 or higher.", - ) - return Math.random() - } - - return crypto.getRandomValues(array)[0] / maxNumber -} - -export default function getSecureMathRandom() { - try { - if (typeof window === 'undefined') { - return getSecureMathRandomServer() - } else { - return getSecureMathRandomBrowser() - } - } catch (e) { - // eslint-disable-next-line no-console - console.log(`fail to generate secure random reason: ${e}`) - } - - return Math.random() -} diff --git a/packages/svg-manager/src/utils/index.ts b/packages/svg-manager/src/utils/index.ts deleted file mode 100644 index 445a86bd..00000000 --- a/packages/svg-manager/src/utils/index.ts +++ /dev/null @@ -1,33 +0,0 @@ -import getSecureMathRandom from './getSecureMathRandom' - -function createSingleton(fn: () => T): () => T { - const memoize = () => { - const key = 'MEMOIZED_KEY' - const cache = memoize.cache - - if (cache.has(key)) { - return cache.get(key) - } - const result = fn() - - memoize.cache = cache.set(key, result) || cache - - return result - } - - memoize.cache = new Map() - - return memoize -} - -export function toSingleton(fn: () => T): () => T { - return createSingleton(fn) -} - -export function generateRandomString(...prefixes: T[]) { - const MAX_RADIX = 36 - const DECIMAL_POINT_IDX = 2 - const randomString = getSecureMathRandom().toString(MAX_RADIX).slice(DECIMAL_POINT_IDX) - - return [...prefixes, randomString].join('_') -} diff --git a/packages/svg-manager/src/utils/toSafeId.test.ts b/packages/svg-manager/src/utils/toSafeId.test.ts new file mode 100644 index 00000000..1ac58e71 --- /dev/null +++ b/packages/svg-manager/src/utils/toSafeId.test.ts @@ -0,0 +1,14 @@ +import {describe, expect, test} from 'vitest' + +import toSafeId from './toSafeId' + +describe('toSafeId', () => { + test('안전한 문자(영숫자/_/-)는 그대로 둔다', () => { + expect(toSafeId('abc_DEF-123')).toBe('abc_DEF-123') + }) + + test('selector-unsafe 문자를 _로 치환한다', () => { + expect(toSafeId(':r0:')).toBe('_r0_') + expect(toSafeId('a.b c(d)')).toBe('a_b_c_d_') + }) +}) diff --git a/packages/svg-manager/src/utils/toSafeId.ts b/packages/svg-manager/src/utils/toSafeId.ts new file mode 100644 index 00000000..1aeacabc --- /dev/null +++ b/packages/svg-manager/src/utils/toSafeId.ts @@ -0,0 +1,4 @@ +// `url(#...)`/CSS selector 참조에 안전하도록 selector-unsafe 문자를 `_`로 치환한다. +export default function toSafeId(id: string): string { + return id.replace(/[^a-zA-Z0-9_-]/g, '_') +} diff --git a/packages/svg-manager/vitest.config.mts b/packages/svg-manager/vitest.config.mts new file mode 100644 index 00000000..4a1a0ef5 --- /dev/null +++ b/packages/svg-manager/vitest.config.mts @@ -0,0 +1,6 @@ +import {defineConfig} from 'vitest/config' + +// vitest가 vite.config.mjs(pite 빌드 설정)를 불러오지 않도록 분리한다. +export default defineConfig({ + test: {}, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4be9f892..f6874d54 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -264,13 +264,6 @@ importers: version: 18.3.1 packages/svg-manager: - dependencies: - react: - specifier: 'catalog:' - version: 18.3.1 - react-dom: - specifier: 'catalog:' - version: 18.2.0(react@18.3.1) devDependencies: '@types/react': specifier: 'catalog:' @@ -278,6 +271,15 @@ importers: csstype: specifier: ^3.1.3 version: 3.1.3 + react: + specifier: 'catalog:' + version: 18.3.1 + react-dom: + specifier: 'catalog:' + version: 18.2.0(react@18.3.1) + vitest: + specifier: ^3.1.1 + version: 3.1.1(@types/debug@4.1.12)(@types/node@22.14.0)(happy-dom@17.4.4)(jiti@1.21.0)(jsdom@29.1.1)(sass-embedded@1.85.1)(sass@1.75.0)(terser@5.28.1) packages/url-param-compressor: dependencies: