@lithent/hmr-parser 0.2.0 → 0.2.2

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@lithent/hmr-parser",
4
- "version": "0.2.0",
4
+ "version": "0.2.2",
5
5
  "description": "Lithent HMR parser utilities for build tools",
6
6
  "main": "./dist/lithentHmrParser.umd.js",
7
7
  "module": "./dist/lithentHmrParser.mjs",
@@ -40,7 +40,7 @@
40
40
  "vite-plugin-checker": "^0.8.0",
41
41
  "vite-plugin-dts": "^4.2.1",
42
42
  "vitest": "^2.1.5",
43
- "lithent": "1.20.0"
43
+ "lithent": "1.20.2"
44
44
  },
45
45
  "volta": {
46
46
  "node": "20.3.0",
package/src/strings.ts CHANGED
@@ -12,6 +12,7 @@ export const createHmrBootstrapBlock = (
12
12
  const __lithentModuleId = new URL(import.meta.url).pathname;
13
13
  const __lithentBoundaryStoreKey = \`__lithent_hmr_boundary__\${__lithentModuleId}\`;
14
14
  const __lithentDisposeStoreKey = \`__lithent_hmr_dispose__\${__lithentModuleId}\`;
15
+ const __lithentInvalidateCountKey = \`__lithent_hmr_invalidate_count__\${__lithentModuleId}\`;
15
16
  const __lithentGlobalStore =
16
17
  typeof globalThis === 'object'
17
18
  ? globalThis
@@ -85,6 +86,44 @@ const __lithentModuleHotStore =
85
86
 
86
87
  const __lithentHmrTargets = ${serializedTargets};
87
88
 
89
+ const __lithentGetInvalidateCount = () => {
90
+ return (__lithentGlobalStore && __lithentGlobalStore[__lithentInvalidateCountKey]) || 0;
91
+ };
92
+
93
+ const __lithentIncrementInvalidateCount = () => {
94
+ if (__lithentGlobalStore) {
95
+ const current = __lithentGetInvalidateCount();
96
+ __lithentGlobalStore[__lithentInvalidateCountKey] = current + 1;
97
+ return current + 1;
98
+ }
99
+ return 0;
100
+ };
101
+
102
+ const __lithentResetInvalidateCount = () => {
103
+ if (__lithentGlobalStore) {
104
+ __lithentGlobalStore[__lithentInvalidateCountKey] = 0;
105
+ }
106
+ };
107
+
108
+ const __lithentSafeInvalidate = (reason) => {
109
+ const MAX_INVALIDATE_ATTEMPTS = 3;
110
+ const count = __lithentIncrementInvalidateCount();
111
+
112
+ if (count >= MAX_INVALIDATE_ATTEMPTS) {
113
+ console.warn(
114
+ \`[Lithent HMR] 전체 리프레시를 \${count}번 시도했지만 계속 실패합니다. 컴포넌트가 마운트되지 않았거나 다른 문제가 있을 수 있습니다. 페이지를 수동으로 새로고침해주세요.\`,
115
+ reason
116
+ );
117
+ return;
118
+ }
119
+
120
+ console.warn(
121
+ \`[Lithent HMR] 변경된 경계를 적용하지 못해 전체 리프레시합니다. (시도 \${count}/\${MAX_INVALIDATE_ATTEMPTS})\`,
122
+ reason
123
+ );
124
+ import.meta.hot?.invalidate?.();
125
+ };
126
+
88
127
  const __lithentSetupHmrHooks = () => {
89
128
  if (!import.meta.hot) {
90
129
  return;
@@ -116,7 +155,9 @@ const __lithentSetupHmrHooks = () => {
116
155
  }
117
156
  }
118
157
 
119
- if (!applied) {
158
+ if (applied) {
159
+ __lithentResetInvalidateCount();
160
+ } else {
120
161
  if (missing.length && __lithentModuleHotStore) {
121
162
  queueMicrotask(() => {
122
163
  let retried = false;
@@ -126,19 +167,15 @@ const __lithentSetupHmrHooks = () => {
126
167
  retried = true;
127
168
  }
128
169
  }
129
- if (!retried) {
130
- console.warn(
131
- '[Lithent HMR] 변경된 경계를 적용하지 못해 전체 리프레시합니다.'
132
- );
133
- import.meta.hot?.invalidate?.();
170
+ if (retried) {
171
+ __lithentResetInvalidateCount();
172
+ } else {
173
+ __lithentSafeInvalidate(\`missing components: \${missing.join(', ')}\`);
134
174
  }
135
175
  });
136
176
  return;
137
177
  }
138
- console.warn(
139
- '[Lithent HMR] 변경된 경계를 적용하지 못해 전체 리프레시합니다.'
140
- );
141
- import.meta.hot?.invalidate?.();
178
+ __lithentSafeInvalidate('no components applied');
142
179
  }
143
180
  });
144
181
 
@@ -155,6 +192,9 @@ const __lithentSetupHmrHooks = () => {
155
192
  if (disposeApp) {
156
193
  __lithentGlobalStore[__lithentDisposeStoreKey] = disposeApp;
157
194
  }
195
+
196
+ // Reset invalidate count on dispose to allow fresh attempts on next load
197
+ __lithentResetInvalidateCount();
158
198
  }
159
199
  });
160
200
  };
@@ -157,5 +157,35 @@ if (import.meta.vitest) {
157
157
  expect(namedRegisters).toHaveLength(2);
158
158
  expect(namedUnregisters).toHaveLength(2);
159
159
  });
160
+
161
+ it('still injects helpers when code includes snippet text mentioning mountCallback', () => {
162
+ const sourceWithSnippet = [
163
+ "'use client';",
164
+ "import { mount } from 'lithent';",
165
+ '',
166
+ 'const doc = "import { h, mountCallback } from \'lithent\';";',
167
+ '',
168
+ '/* lithent:hmr-boundary default */',
169
+ '',
170
+ 'const Demo = mount(() => {',
171
+ ' return () => <div>demo</div>;',
172
+ '});',
173
+ '',
174
+ 'export default Demo;',
175
+ '',
176
+ ].join('\n');
177
+
178
+ const snippetResult = transformWithMarker({
179
+ code: sourceWithSnippet,
180
+ markerRegex: createDefaultMarkerRegex(),
181
+ boundaryImportSpecifier: 'lithent/devHelper',
182
+ tagFunctionImportSpecifier: 'lithent',
183
+ });
184
+
185
+ expect(snippetResult.transformed).toBe(true);
186
+ expect(snippetResult.code).toContain(
187
+ "import { mountCallback, getComponentKey } from 'lithent';"
188
+ );
189
+ });
160
190
  });
161
191
  }
@@ -9,8 +9,12 @@ export const stitchComponentRegistration = (
9
9
  ) => {
10
10
  if (!mounts.length) return;
11
11
 
12
- const needsMountCallback = !/import\s+{[^}]*mountCallback/.test(code);
13
- const needsGetComponentKey = !/import\s+{[^}]*getComponentKey/.test(code);
12
+ const importStatements = extractImportStatements(code);
13
+ const needsMountCallback = !hasNamedImport(importStatements, 'mountCallback');
14
+ const needsGetComponentKey = !hasNamedImport(
15
+ importStatements,
16
+ 'getComponentKey'
17
+ );
14
18
 
15
19
  if (needsMountCallback || needsGetComponentKey) {
16
20
  const pieces: string[] = [];
@@ -48,3 +52,263 @@ if (__lithentModuleHotStore) {
48
52
  }
49
53
  }
50
54
  };
55
+
56
+ type ScannerState =
57
+ | 'code'
58
+ | 'single'
59
+ | 'double'
60
+ | 'template'
61
+ | 'lineComment'
62
+ | 'blockComment';
63
+
64
+ const extractImportStatements = (source: string): string[] => {
65
+ const imports: string[] = [];
66
+ let i = 0;
67
+ let state: ScannerState = 'code';
68
+ let templateDepth = 0;
69
+
70
+ while (i < source.length) {
71
+ const ch = source[i];
72
+ const next = source[i + 1];
73
+
74
+ switch (state) {
75
+ case 'code':
76
+ if (ch === '/' && next === '/') {
77
+ state = 'lineComment';
78
+ i += 2;
79
+ continue;
80
+ }
81
+ if (ch === '/' && next === '*') {
82
+ state = 'blockComment';
83
+ i += 2;
84
+ continue;
85
+ }
86
+ if (ch === "'") {
87
+ state = 'single';
88
+ i++;
89
+ continue;
90
+ }
91
+ if (ch === '"') {
92
+ state = 'double';
93
+ i++;
94
+ continue;
95
+ }
96
+ if (ch === '`') {
97
+ state = 'template';
98
+ templateDepth = 0;
99
+ i++;
100
+ continue;
101
+ }
102
+ if (
103
+ ch === 'i' &&
104
+ isImportKeyword(source, i) &&
105
+ !isDynamicImport(source, i)
106
+ ) {
107
+ const end = findImportStatementEnd(source, i);
108
+ imports.push(source.slice(i, end));
109
+ i = end;
110
+ continue;
111
+ }
112
+ i++;
113
+ break;
114
+
115
+ case 'single':
116
+ if (ch === '\\') {
117
+ i += 2;
118
+ continue;
119
+ }
120
+ if (ch === "'") {
121
+ state = 'code';
122
+ }
123
+ i++;
124
+ break;
125
+
126
+ case 'double':
127
+ if (ch === '\\') {
128
+ i += 2;
129
+ continue;
130
+ }
131
+ if (ch === '"') {
132
+ state = 'code';
133
+ }
134
+ i++;
135
+ break;
136
+
137
+ case 'template':
138
+ if (ch === '\\') {
139
+ i += 2;
140
+ continue;
141
+ }
142
+ if (ch === '`') {
143
+ if (templateDepth === 0) {
144
+ state = 'code';
145
+ i++;
146
+ continue;
147
+ }
148
+ templateDepth--;
149
+ i++;
150
+ continue;
151
+ }
152
+ if (ch === '$' && next === '{') {
153
+ templateDepth++;
154
+ i += 2;
155
+ continue;
156
+ }
157
+ if (ch === '}' && templateDepth > 0) {
158
+ templateDepth--;
159
+ }
160
+ i++;
161
+ break;
162
+
163
+ case 'lineComment':
164
+ if (ch === '\n') {
165
+ state = 'code';
166
+ }
167
+ i++;
168
+ break;
169
+
170
+ case 'blockComment':
171
+ if (ch === '*' && next === '/') {
172
+ state = 'code';
173
+ i += 2;
174
+ continue;
175
+ }
176
+ i++;
177
+ break;
178
+ }
179
+ }
180
+
181
+ return imports;
182
+ };
183
+
184
+ const hasNamedImport = (imports: string[], name: string): boolean => {
185
+ const pattern = new RegExp(`\\b${escapeRegExp(name)}\\b`);
186
+ return imports.some(statement => {
187
+ const [clause] = statement.split(/from\s+/i);
188
+ return pattern.test(clause);
189
+ });
190
+ };
191
+
192
+ const escapeRegExp = (value: string): string =>
193
+ value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
194
+
195
+ const isImportKeyword = (source: string, index: number): boolean => {
196
+ if (!source.startsWith('import', index)) {
197
+ return false;
198
+ }
199
+
200
+ const prev = source[index - 1];
201
+ if (prev && /\w/.test(prev)) {
202
+ return false;
203
+ }
204
+
205
+ const next = source[index + 'import'.length];
206
+ if (next && /\w/.test(next)) {
207
+ return false;
208
+ }
209
+
210
+ return true;
211
+ };
212
+
213
+ const isDynamicImport = (source: string, index: number): boolean => {
214
+ const nextChar = source[index + 'import'.length];
215
+ return nextChar === '(' || nextChar === '.';
216
+ };
217
+
218
+ const findImportStatementEnd = (source: string, start: number): number => {
219
+ let i = start;
220
+ let state: ScannerState = 'code';
221
+
222
+ while (i < source.length) {
223
+ const ch = source[i];
224
+ const next = source[i + 1];
225
+
226
+ switch (state) {
227
+ case 'code':
228
+ if (ch === ';') {
229
+ return i + 1;
230
+ }
231
+ if (ch === '/' && next === '/') {
232
+ state = 'lineComment';
233
+ i += 2;
234
+ continue;
235
+ }
236
+ if (ch === '/' && next === '*') {
237
+ state = 'blockComment';
238
+ i += 2;
239
+ continue;
240
+ }
241
+ if (ch === "'") {
242
+ state = 'single';
243
+ i++;
244
+ continue;
245
+ }
246
+ if (ch === '"') {
247
+ state = 'double';
248
+ i++;
249
+ continue;
250
+ }
251
+ if (ch === '`') {
252
+ state = 'template';
253
+ i++;
254
+ continue;
255
+ }
256
+ if (ch === '\n' && source[i + 1] === '\n') {
257
+ return i + 1;
258
+ }
259
+ i++;
260
+ break;
261
+
262
+ case 'single':
263
+ if (ch === '\\') {
264
+ i += 2;
265
+ continue;
266
+ }
267
+ if (ch === "'") {
268
+ state = 'code';
269
+ }
270
+ i++;
271
+ break;
272
+
273
+ case 'double':
274
+ if (ch === '\\') {
275
+ i += 2;
276
+ continue;
277
+ }
278
+ if (ch === '"') {
279
+ state = 'code';
280
+ }
281
+ i++;
282
+ break;
283
+
284
+ case 'template':
285
+ if (ch === '\\') {
286
+ i += 2;
287
+ continue;
288
+ }
289
+ if (ch === '`') {
290
+ state = 'code';
291
+ }
292
+ i++;
293
+ break;
294
+
295
+ case 'lineComment':
296
+ if (ch === '\n') {
297
+ state = 'code';
298
+ }
299
+ i++;
300
+ break;
301
+
302
+ case 'blockComment':
303
+ if (ch === '*' && next === '/') {
304
+ state = 'code';
305
+ i += 2;
306
+ continue;
307
+ }
308
+ i++;
309
+ break;
310
+ }
311
+ }
312
+
313
+ return source.length;
314
+ };