@iyulab/components 1.7.2 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.8.0] - 2026-07-22
4
+
5
+ ### Fixed
6
+ - **`u-icon` 아이콘 리졸브가 재렌더·재마운트마다 다시 fetch되던 스톰 수정** — `IconRegistry.resolve()`가 리졸버 결과를 캐시하지 않아, SSE 스트리밍처럼 같은 아이콘이 반복 재마운트되는 UI에서 단일 아이콘에 수백 회 fetch가 발생했다(미존재 아이콘은 404 스톰으로 콘솔 오염 + dev 서버 부하 — SMI.AIMS 실측, ISSUE-components-20260722-iconregistry-resolver-no-cache). 이제 레지스트리가 (lib, name) 단위 캐싱과 동시 요청 in-flight dedupe를 소유해, 커스텀 리졸버를 포함한 모든 라이브러리에서 아이콘당 세션 1회만 리졸브된다. `u-icon`의 `src` 경로·무-lib 기본(baseUrl) 경로도 신설 `IconRegistry.resolveUrl(url)`을 경유해 동일하게 캐시된다.
7
+ - 내장 CDN 리졸버(tabler/heroicons/lucide/bootstrap)가 네트워크 오류를 `undefined`로 삼키던 것을 throw 전파로 교정 — 일시 장애가 세션 내 not-found로 오인·고착되지 않고 다음 조회에서 재시도된다.
8
+
9
+ ### Changed
10
+ - **`IconResolver` 계약 명확화 (동작 변경)** — 리졸버의 `undefined` 반환은 이제 **not-found 확정**을 뜻하며 네거티브 캐시되어 세션 내 재호출되지 않는다(탈출구: `IconCache.clear()`). **일시 오류(네트워크 장애 등)에는 `undefined` 대신 `throw`를 사용할 것** — throw는 캐시되지 않아 다음 조회 시 재시도된다. 일시 실패에 `undefined`를 반환하던 커스텀 리졸버는 throw로 전환해야 재시도 동작을 유지한다.
11
+ - `IconRegistry.unregister(lib)`가 해당 라이브러리의 캐시 항목을 함께 비운다 — 내장 CDN 라이브러리를 로컬 리졸버로 교체(`unregister` → `register`, 폐쇄망 대응)할 때 이전 리졸버의 stale 결과가 남지 않는다.
12
+
13
+ ### Added
14
+ - `IconRegistry.resolveUrl(url)` — URL 직접 리졸브(캐시 + dedupe). 예약 네임스페이스 `url`로 `IconCache`에 저장되며 소비앱의 prewarm 용도로도 사용 가능.
15
+ - `IconCache.clear(lib)` — 특정 라이브러리 항목만 클리어(전체 클리어는 기존대로 인자 없이).
16
+ - `IconCache.set()`이 `undefined`(네거티브 항목) 저장을 허용.
17
+
18
+ ### Documentation
19
+ - `docs/icons.md`·skills 레퍼런스에 리졸버 계약(성공/not-found/일시 오류)·캐싱 동작·내장 라이브러리 오버라이드 절차(`unregister`→`register`) 문서화. CDN 버전 표기 드리프트 교정(lucide 0.577.0, bootstrap 1.13.1).
20
+
3
21
  ## [1.7.2] - 2026-07-19
4
22
 
5
23
  ### Fixed
@@ -13,8 +13,9 @@ var UIcon = class UIcon extends UElement {
13
13
  this.styles = [super.styles, styles];
14
14
  }
15
15
  render() {
16
- if (this.src) return until(fetch(this.src).then((r) => r.text()).then((html) => {
17
- return html ? unsafeHTML(this.sanitize(html)) : nothing;
16
+ if (this.src) return until(IconRegistry.resolveUrl(this.src).then((html) => {
17
+ const sanitized = this.sanitize(html);
18
+ return sanitized ? unsafeHTML(sanitized) : nothing;
18
19
  }), nothing);
19
20
  if (this.name) return until(this.resolve(this.name).then((html) => {
20
21
  return html ? unsafeHTML(html) : nothing;
@@ -24,12 +25,9 @@ var UIcon = class UIcon extends UElement {
24
25
  async resolve(name) {
25
26
  let html = void 0;
26
27
  if (this.lib) html = await IconRegistry.resolve(this.lib, name);
27
- else try {
28
+ else {
28
29
  const url = `${getDefaultBaseUrl().replace(/\/$/, "")}/${name}.svg`;
29
- const response = await fetch(url);
30
- if (response.ok) html = await response.text();
31
- } catch (error) {
32
- console.error(error);
30
+ html = await IconRegistry.resolveUrl(url);
33
31
  }
34
32
  return this.sanitize(html);
35
33
  }
@@ -10,11 +10,18 @@ declare function getDefaultBaseUrl(): string;
10
10
  declare function setDefaultBaseUrl(url: string): void;
11
11
  /**
12
12
  * `name`으로 아이콘 리소스를 가져오는 함수입니다.
13
+ *
14
+ * 반환 계약 — 레지스트리가 결과를 (lib, name) 단위로 캐시하므로 리졸버는 순수 조회만 담당합니다:
15
+ * - `string` 반환: 성공 — 세션 동안 캐시되어 재호출되지 않습니다.
16
+ * - `undefined` 반환: **not-found 확정** — 네거티브 캐시되어 재호출되지 않습니다.
17
+ * (탈출구: `IconCache.clear()`)
18
+ * - `throw`: **일시 오류**(네트워크 장애 등) — 캐시되지 않아 다음 조회 시 재시도됩니다.
13
19
  */
14
20
  type IconResolver = (name: string) => string | undefined | Promise<string | undefined>;
15
21
  /**
16
22
  * 아이콘 리소스 캐시입니다.
17
23
  * lib + name 조합으로 SVG 리소스를 저장/조회합니다.
24
+ * `undefined` 값은 "not-found 확정"의 네거티브 캐시 항목을 의미합니다.
18
25
  */
19
26
  declare class IconCache {
20
27
  private static cache;
@@ -22,7 +29,7 @@ declare class IconCache {
22
29
  private constructor();
23
30
  private static makeKey;
24
31
  /**
25
- * 지정된 lib, name 조합의 리소스가 존재하는지 확인합니다.
32
+ * 지정된 lib, name 조합의 리소스(네거티브 항목 포함)가 존재하는지 확인합니다.
26
33
  */
27
34
  static has(lib: string, name: string): boolean;
28
35
  /**
@@ -31,12 +38,15 @@ declare class IconCache {
31
38
  static get(lib: string, name: string): string | undefined;
32
39
  /**
33
40
  * 지정된 lib, name 조합의 리소스를 저장합니다.
41
+ * `undefined`를 저장하면 not-found 확정(네거티브 캐시)으로 기록됩니다.
34
42
  */
35
- static set(lib: string, name: string, resource: string): void;
43
+ static set(lib: string, name: string, resource: string | undefined): void;
36
44
  /**
37
- * 전체 캐시를 비웁니다.
45
+ * 캐시를 비웁니다.
46
+ *
47
+ * @param lib - 지정하면 해당 라이브러리의 항목만, 생략하면 전체를 비웁니다.
38
48
  */
39
- static clear(): void;
49
+ static clear(lib?: string): void;
40
50
  }
41
51
  /**
42
52
  * 아이콘 레지스트리입니다.
@@ -44,6 +54,8 @@ declare class IconCache {
44
54
  */
45
55
  declare class IconRegistry {
46
56
  private static libs;
57
+ /** 동일 (lib, name)의 동시 리졸브를 하나의 요청으로 합치는 in-flight 맵입니다. */
58
+ private static pending;
47
59
  /** 클래스 생성 방지 */
48
60
  private constructor();
49
61
  /**
@@ -57,11 +69,33 @@ declare class IconRegistry {
57
69
  static register(lib: string, resolver: IconResolver): void;
58
70
  /**
59
71
  * 지정된 아이콘 라이브러리를 등록 해제합니다.
72
+ * 해당 라이브러리의 캐시 항목도 함께 비웁니다 —
73
+ * `unregister` 후 다른 리졸버를 재등록할 때 이전 결과가 남지 않도록 하기 위함입니다.
60
74
  */
61
75
  static unregister(lib: string): void;
62
76
  /**
63
77
  * 지정된 아이콘 라이브러리의 svg 아이콘 소스를 가져옵니다.
78
+ *
79
+ * 리졸브 결과는 (lib, name) 단위로 캐시됩니다 — 스트리밍 재렌더처럼 같은 아이콘이
80
+ * 반복 재마운트되는 시나리오에서도 리졸버는 이름당 한 번만 호출됩니다.
81
+ * 동시 요청은 in-flight Promise를 공유하고, `IconResolver` 계약에 따라
82
+ * `undefined`(not-found 확정)는 네거티브 캐시, `throw`(일시 오류)는 캐시하지 않습니다.
64
83
  */
65
84
  static resolve(lib: string, name: string): Promise<string | undefined>;
85
+ /**
86
+ * URL에서 직접 svg 아이콘 소스를 가져옵니다.
87
+ * `u-icon`의 `src` 경로와 무-lib 기본(baseUrl) 경로가 사용하는 저수준 진입점입니다.
88
+ *
89
+ * 결과는 예약 네임스페이스 `url`로 `IconCache`에 캐시되어 같은 URL은 세션당
90
+ * 한 번만 fetch됩니다. HTTP 실패(404 등)는 not-found로 네거티브 캐시되고,
91
+ * 네트워크 오류는 캐시하지 않아 다음 조회 시 재시도됩니다.
92
+ */
93
+ static resolveUrl(url: string): Promise<string | undefined>;
94
+ /**
95
+ * 캐시 기록 + in-flight dedupe + 실패 시멘틱을 공유하는 리졸브 코어입니다.
96
+ * catch를 task 내부에 두어, in-flight를 공유한 모든 호출자가 rejection 대신
97
+ * undefined를 받도록 한다 (렌더 경로로 rejection이 전파되면 안 됨).
98
+ */
99
+ private static resolveCached;
66
100
  }
67
101
  export { getDefaultBaseUrl, setDefaultBaseUrl, IconCache, IconRegistry, };
@@ -63,6 +63,7 @@ var InternalIconBundle = new Map(Object.entries(/* #__PURE__ */ Object.assign({
63
63
  /**
64
64
  * 아이콘 리소스 캐시입니다.
65
65
  * lib + name 조합으로 SVG 리소스를 저장/조회합니다.
66
+ * `undefined` 값은 "not-found 확정"의 네거티브 캐시 항목을 의미합니다.
66
67
  */
67
68
  var IconCache = class {
68
69
  static {
@@ -74,7 +75,7 @@ var IconCache = class {
74
75
  return `${lib}:${name}`;
75
76
  }
76
77
  /**
77
- * 지정된 lib, name 조합의 리소스가 존재하는지 확인합니다.
78
+ * 지정된 lib, name 조합의 리소스(네거티브 항목 포함)가 존재하는지 확인합니다.
78
79
  */
79
80
  static has(lib, name) {
80
81
  return this.cache.has(this.makeKey(lib, name));
@@ -87,15 +88,23 @@ var IconCache = class {
87
88
  }
88
89
  /**
89
90
  * 지정된 lib, name 조합의 리소스를 저장합니다.
91
+ * `undefined`를 저장하면 not-found 확정(네거티브 캐시)으로 기록됩니다.
90
92
  */
91
93
  static set(lib, name, resource) {
92
94
  this.cache.set(this.makeKey(lib, name), resource);
93
95
  }
94
96
  /**
95
- * 전체 캐시를 비웁니다.
97
+ * 캐시를 비웁니다.
98
+ *
99
+ * @param lib - 지정하면 해당 라이브러리의 항목만, 생략하면 전체를 비웁니다.
96
100
  */
97
- static clear() {
98
- this.cache.clear();
101
+ static clear(lib) {
102
+ if (lib === void 0) {
103
+ this.cache.clear();
104
+ return;
105
+ }
106
+ const prefix = `${lib}:`;
107
+ for (const key of this.cache.keys()) if (key.startsWith(prefix)) this.cache.delete(key);
99
108
  }
100
109
  };
101
110
  /**
@@ -106,6 +115,9 @@ var IconRegistry = class {
106
115
  static {
107
116
  this.libs = /* @__PURE__ */ new Map();
108
117
  }
118
+ static {
119
+ this.pending = /* @__PURE__ */ new Map();
120
+ }
109
121
  /** 클래스 생성 방지 */
110
122
  constructor() {}
111
123
  /**
@@ -123,75 +135,95 @@ var IconRegistry = class {
123
135
  }
124
136
  /**
125
137
  * 지정된 아이콘 라이브러리를 등록 해제합니다.
138
+ * 해당 라이브러리의 캐시 항목도 함께 비웁니다 —
139
+ * `unregister` 후 다른 리졸버를 재등록할 때 이전 결과가 남지 않도록 하기 위함입니다.
126
140
  */
127
141
  static unregister(lib) {
128
142
  this.libs.delete(lib);
143
+ IconCache.clear(lib);
129
144
  }
130
145
  /**
131
146
  * 지정된 아이콘 라이브러리의 svg 아이콘 소스를 가져옵니다.
147
+ *
148
+ * 리졸브 결과는 (lib, name) 단위로 캐시됩니다 — 스트리밍 재렌더처럼 같은 아이콘이
149
+ * 반복 재마운트되는 시나리오에서도 리졸버는 이름당 한 번만 호출됩니다.
150
+ * 동시 요청은 in-flight Promise를 공유하고, `IconResolver` 계약에 따라
151
+ * `undefined`(not-found 확정)는 네거티브 캐시, `throw`(일시 오류)는 캐시하지 않습니다.
132
152
  */
133
153
  static async resolve(lib, name) {
154
+ if (IconCache.has(lib, name)) return IconCache.get(lib, name);
134
155
  const resolver = this.libs.get(lib);
135
156
  if (!resolver) return void 0;
136
- return (await resolver(name))?.trim();
157
+ return this.resolveCached(lib, name, () => resolver(name));
158
+ }
159
+ /**
160
+ * URL에서 직접 svg 아이콘 소스를 가져옵니다.
161
+ * `u-icon`의 `src` 경로와 무-lib 기본(baseUrl) 경로가 사용하는 저수준 진입점입니다.
162
+ *
163
+ * 결과는 예약 네임스페이스 `url`로 `IconCache`에 캐시되어 같은 URL은 세션당
164
+ * 한 번만 fetch됩니다. HTTP 실패(404 등)는 not-found로 네거티브 캐시되고,
165
+ * 네트워크 오류는 캐시하지 않아 다음 조회 시 재시도됩니다.
166
+ */
167
+ static async resolveUrl(url) {
168
+ if (IconCache.has("url", url)) return IconCache.get("url", url);
169
+ return this.resolveCached("url", url, async () => {
170
+ const response = await fetch(url);
171
+ if (!response.ok) return void 0;
172
+ return await response.text();
173
+ });
174
+ }
175
+ /**
176
+ * 캐시 기록 + in-flight dedupe + 실패 시멘틱을 공유하는 리졸브 코어입니다.
177
+ * catch를 task 내부에 두어, in-flight를 공유한 모든 호출자가 rejection 대신
178
+ * undefined를 받도록 한다 (렌더 경로로 rejection이 전파되면 안 됨).
179
+ */
180
+ static resolveCached(lib, name, fn) {
181
+ const key = `${lib}:${name}`;
182
+ const inflight = this.pending.get(key);
183
+ if (inflight) return inflight;
184
+ const task = (async () => {
185
+ try {
186
+ const svg = (await fn())?.trim();
187
+ IconCache.set(lib, name, svg);
188
+ return svg;
189
+ } catch (error) {
190
+ console.error(`[IconRegistry] '${key}' 아이콘 리졸브 실패 (재시도 가능):`, error);
191
+ return;
192
+ }
193
+ })();
194
+ this.pending.set(key, task);
195
+ task.finally(() => this.pending.delete(key));
196
+ return task;
137
197
  }
138
198
  };
139
199
  IconRegistry.register("internal", (name) => {
140
200
  return InternalIconBundle.get(name);
141
201
  });
142
202
  IconRegistry.register("tabler", async (name) => {
143
- if (IconCache.has("tabler", name)) return IconCache.get("tabler", name);
144
- try {
145
- const [iconName, style = "outline"] = name.split(":");
146
- const url = `https://cdn.jsdelivr.net/npm/@tabler/icons@3.40.0/icons/${style}/${iconName}.svg`;
147
- const response = await fetch(url);
148
- if (!response.ok) return void 0;
149
- const svg = await response.text();
150
- IconCache.set("tabler", name, svg);
151
- return svg;
152
- } catch {
153
- return;
154
- }
203
+ const [iconName, style = "outline"] = name.split(":");
204
+ const url = `https://cdn.jsdelivr.net/npm/@tabler/icons@3.40.0/icons/${style}/${iconName}.svg`;
205
+ const response = await fetch(url);
206
+ if (!response.ok) return void 0;
207
+ return await response.text();
155
208
  });
156
209
  IconRegistry.register("heroicons", async (name) => {
157
- if (IconCache.has("heroicons", name)) return IconCache.get("heroicons", name);
158
- try {
159
- const [iconName, style = "outline", size = "24"] = name.split(":");
160
- const url = `https://cdn.jsdelivr.net/npm/heroicons@2.2.0/${size}/${style}/${iconName}.svg`;
161
- const response = await fetch(url);
162
- if (!response.ok) return void 0;
163
- const svg = await response.text();
164
- IconCache.set("heroicons", name, svg);
165
- return svg;
166
- } catch {
167
- return;
168
- }
210
+ const [iconName, style = "outline", size = "24"] = name.split(":");
211
+ const url = `https://cdn.jsdelivr.net/npm/heroicons@2.2.0/${size}/${style}/${iconName}.svg`;
212
+ const response = await fetch(url);
213
+ if (!response.ok) return void 0;
214
+ return await response.text();
169
215
  });
170
216
  IconRegistry.register("lucide", async (name) => {
171
- if (IconCache.has("lucide", name)) return IconCache.get("lucide", name);
172
- try {
173
- const url = `https://cdn.jsdelivr.net/npm/lucide-static@0.577.0/icons/${name}.svg`;
174
- const response = await fetch(url);
175
- if (!response.ok) return void 0;
176
- const svg = await response.text();
177
- IconCache.set("lucide", name, svg);
178
- return svg;
179
- } catch {
180
- return;
181
- }
217
+ const url = `https://cdn.jsdelivr.net/npm/lucide-static@0.577.0/icons/${name}.svg`;
218
+ const response = await fetch(url);
219
+ if (!response.ok) return void 0;
220
+ return await response.text();
182
221
  });
183
222
  IconRegistry.register("bootstrap", async (name) => {
184
- if (IconCache.has("bootstrap", name)) return IconCache.get("bootstrap", name);
185
- try {
186
- const url = `https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/icons/${name}.svg`;
187
- const response = await fetch(url);
188
- if (!response.ok) return void 0;
189
- const svg = await response.text();
190
- IconCache.set("bootstrap", name, svg);
191
- return svg;
192
- } catch {
193
- return;
194
- }
223
+ const url = `https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/icons/${name}.svg`;
224
+ const response = await fetch(url);
225
+ if (!response.ok) return void 0;
226
+ return await response.text();
195
227
  });
196
228
  //#endregion
197
229
  export { IconCache, IconRegistry, getDefaultBaseUrl, setDefaultBaseUrl };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@iyulab/components",
3
3
  "description": "web-components library based on lit-element made by iyulab",
4
- "version": "1.7.2",
4
+ "version": "1.8.0",
5
5
  "keywords": [
6
6
  "iyulab",
7
7
  "components",
@@ -24,14 +24,20 @@ IconRegistry.register('my-icons', async (name: string) => {
24
24
  });
25
25
  ```
26
26
 
27
+ Resolver contract — the registry owns caching, so a resolver is a pure lookup:
28
+ return `string` = success (cached) · return `undefined` = definitive not-found (negative-cached, not retried) · `throw` = transient error (not cached, retried on next lookup).
29
+
30
+ `register()` ignores an already-registered `lib` name. To override a built-in library (e.g. air-gapped local bundle), call `unregister(lib)` first — this also evicts that library's cache.
31
+
27
32
  ## API
28
33
 
29
34
  | Method | Description |
30
35
  |--------|-------------|
31
- | `IconRegistry.register(lib, resolver)` | Register an icon library |
32
- | `IconRegistry.unregister(lib)` | Remove a library |
36
+ | `IconRegistry.register(lib, resolver)` | Register an icon library (ignored if `lib` already registered) |
37
+ | `IconRegistry.unregister(lib)` | Remove a library + evict its cached entries |
33
38
  | `IconRegistry.has(lib)` | Check if a library is registered |
34
- | `IconRegistry.resolve(lib, name)` | `Promise<string \| undefined>` — resolve SVG source |
39
+ | `IconRegistry.resolve(lib, name)` | `Promise<string \| undefined>` — resolve SVG source (cached + in-flight dedupe) |
40
+ | `IconRegistry.resolveUrl(url)` | `Promise<string \| undefined>` — fetch SVG by URL, cached under the reserved `url` namespace (used by `u-icon` `src`/base-URL paths) |
35
41
 
36
42
  ## Built-in Libraries
37
43
 
@@ -40,14 +46,15 @@ IconRegistry.register('my-icons', async (name: string) => {
40
46
  | `internal` | (bundled) | — |
41
47
  | `tabler` | `name` / `name:filled` | 3.40.0 |
42
48
  | `heroicons` | `name` / `name:solid` | 2.2.0 |
43
- | `lucide` | `name` | 0.503.0 |
44
- | `bootstrap` | `name` / `name:filled` | 1.11.3 |
49
+ | `lucide` | `name` | 0.577.0 |
50
+ | `bootstrap` | `name` / `name:filled` | 1.13.1 |
45
51
 
46
52
  ## Caching
47
53
 
48
- Resolved SVGs are cached by `lib:name` key. The cache persists for the lifetime of the page.
54
+ Resolved SVGs are cached by `lib:name` key (owned by `IconRegistry.resolve()` — resolvers are called once per name per session, concurrent lookups share one in-flight request). Not-found results are negative-cached; transient errors (`throw`) are retried. The cache persists for the lifetime of the page.
49
55
 
50
56
  ```ts
51
57
  // Internal cache is managed automatically; manual access is rarely needed.
52
- // IconCache.clear(); // clears all cached entries
58
+ // IconCache.clear(); // clears all cached entries
59
+ // IconCache.clear('tabler'); // clears one library's entries
53
60
  ```