@lithent/hmr-parser 0.2.2 → 0.2.3

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.
Files changed (42) hide show
  1. package/dist/helper/calculator.d.cts +8 -0
  2. package/dist/helper/calculator.d.ts +1 -1
  3. package/dist/index.d.cts +8 -0
  4. package/dist/index.d.ts +5 -5
  5. package/dist/lithentHmrParser.mjs +4214 -4370
  6. package/dist/lithentHmrParser.mjs.map +1 -1
  7. package/dist/lithentHmrParser.umd.js +137 -216
  8. package/dist/lithentHmrParser.umd.js.map +1 -1
  9. package/dist/markerParse.d.cts +2 -0
  10. package/dist/markerParse.d.ts +1 -1
  11. package/dist/noMarkerParse.d.cts +2 -0
  12. package/dist/noMarkerParse.d.ts +1 -1
  13. package/dist/shared.d.cts +4 -0
  14. package/dist/shared.d.ts +1 -1
  15. package/dist/strings.d.cts +1 -0
  16. package/dist/strings.d.ts +1 -1
  17. package/dist/tests/bootstrap.test.d.cts +1 -0
  18. package/dist/tests/bootstrap.test.d.ts +1 -0
  19. package/dist/tests/mark.test.d.cts +1 -0
  20. package/dist/tests/nomark.test.d.cts +1 -0
  21. package/dist/transform/componentRegister.d.cts +3 -0
  22. package/dist/transform/componentRegister.d.ts +2 -2
  23. package/dist/transform/exportSnapshot.d.cts +3 -0
  24. package/dist/transform/exportSnapshot.d.ts +3 -0
  25. package/dist/transform/renderGuard.d.cts +3 -0
  26. package/dist/transform/renderGuard.d.ts +1 -1
  27. package/dist/types.d.cts +28 -0
  28. package/dist/utils/ast/componentCollector.d.cts +9 -0
  29. package/dist/utils/ast/componentCollector.d.ts +5 -4
  30. package/package.json +2 -2
  31. package/src/helper/calculator.ts +27 -7
  32. package/src/markerParse.ts +30 -14
  33. package/src/noMarkerParse.ts +16 -4
  34. package/src/shared.ts +8 -2
  35. package/src/strings.ts +105 -177
  36. package/src/tests/bootstrap.test.ts +312 -0
  37. package/src/tests/mark.test.ts +61 -181
  38. package/src/tests/nomark.test.tsx +110 -89
  39. package/src/transform/componentRegister.ts +13 -304
  40. package/src/transform/exportSnapshot.ts +77 -0
  41. package/src/transform/renderGuard.ts +59 -49
  42. package/src/utils/ast/componentCollector.ts +195 -96
package/src/strings.ts CHANGED
@@ -1,204 +1,132 @@
1
1
  export const createHmrBootstrapBlock = (
2
- targetExports: string[],
2
+ _targetExports: string[],
3
3
  componentNames: string[]
4
4
  ) => {
5
- const normalizedTargets = new Set(
6
- componentNames.length ? componentNames : targetExports
7
- );
8
-
9
- const serializedTargets = JSON.stringify(Array.from(normalizedTargets));
10
-
11
5
  return `
12
- const __lithentModuleId = new URL(import.meta.url).pathname;
13
- const __lithentBoundaryStoreKey = \`__lithent_hmr_boundary__\${__lithentModuleId}\`;
14
- const __lithentDisposeStoreKey = \`__lithent_hmr_dispose__\${__lithentModuleId}\`;
6
+ const __lithentModuleUrl = new URL(import.meta.url);
7
+ __lithentModuleUrl.searchParams.delete('t');
8
+ const __lithentModuleId = __lithentModuleUrl.pathname + __lithentModuleUrl.search;
15
9
  const __lithentInvalidateCountKey = \`__lithent_hmr_invalidate_count__\${__lithentModuleId}\`;
16
- const __lithentGlobalStore =
17
- typeof globalThis === 'object'
18
- ? globalThis
19
- : undefined;
20
-
21
- const __lithentEnsureHotData = () => {
22
- const hot = import.meta.hot ?? undefined;
23
- if (!hot) return undefined;
24
-
25
- const existing = hot.data;
26
- if (existing && typeof existing === 'object') {
27
- return existing;
28
- }
29
-
30
- try {
31
- const target = {};
32
- hot.data = target;
33
- return target;
34
- } catch {
35
- hot.__lithentData = hot.__lithentData || {};
36
- return hot.__lithentData;
37
- }
38
- };
39
-
40
- const __lithentHotData = __lithentEnsureHotData();
41
-
42
- const counterBoundary =
43
- __lithentHotData?.counterBoundary ||
44
- __lithentGlobalStore?.[__lithentBoundaryStoreKey] ||
45
- createBoundary(__lithentModuleId);
46
-
47
- if (__lithentHotData) {
48
- __lithentHotData.counterBoundary = counterBoundary;
49
- }
50
-
51
- if (__lithentGlobalStore) {
52
- __lithentGlobalStore[__lithentBoundaryStoreKey] = counterBoundary;
53
- }
54
-
55
- let disposeApp =
56
- __lithentHotData?.disposeApp ||
57
- __lithentGlobalStore?.[__lithentDisposeStoreKey];
58
-
59
- const __lithentRenderOnce = (factory) => {
60
- if (disposeApp) {
61
- return disposeApp;
62
- }
63
-
10
+ const __lithentGlobalStore = globalThis;
11
+ const __lithentHotData = import.meta.hot?.data;
12
+ const __lithentModuleHotStore = __lithentHotData
13
+ ? (__lithentHotData.components ??= Object.create(null))
14
+ : Object.create(null);
15
+ const __lithentCurrentComponents = Object.create(null);
16
+ if (__lithentHotData) __lithentHotData.currentComponents = __lithentCurrentComponents;
17
+ let __lithentExportSnapshot;
18
+ const __lithentRenderDisposers = __lithentHotData
19
+ ? (__lithentHotData.renderDisposers ??= new Map()) : new Map();
20
+ const __lithentRenderOnce = (id, factory) => {
21
+ if (!import.meta.hot) return factory();
22
+ if (__lithentRenderDisposers.has(id)) return __lithentRenderDisposers.get(id);
64
23
  const result = factory();
65
-
66
24
  if (typeof result === 'function') {
67
- disposeApp = result;
68
-
69
- if (__lithentHotData) {
70
- __lithentHotData.disposeApp = disposeApp;
71
- }
72
-
73
- if (__lithentGlobalStore && disposeApp) {
74
- __lithentGlobalStore[__lithentDisposeStoreKey] = disposeApp;
75
- }
25
+ __lithentRenderDisposers.set(id, result);
76
26
  }
77
-
78
27
  return result;
79
28
  };
80
-
81
- const __lithentModuleHotStore =
82
- __lithentHotData
83
- ? ((__lithentHotData.components =
84
- __lithentHotData.components ?? {}))
85
- : undefined;
86
-
87
- const __lithentHmrTargets = ${serializedTargets};
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;
29
+ const __lithentResetInvalidateCount = (name = '') => {
30
+ __lithentGlobalStore[__lithentInvalidateCountKey + '#' + name] = 0;
100
31
  };
101
-
102
- const __lithentResetInvalidateCount = () => {
103
- if (__lithentGlobalStore) {
104
- __lithentGlobalStore[__lithentInvalidateCountKey] = 0;
32
+ const __lithentSafeInvalidate = (reason, name = '') => {
33
+ const key = __lithentInvalidateCountKey + '#' + name;
34
+ const count = (__lithentGlobalStore[key] || 0) + 1;
35
+ __lithentGlobalStore[key] = count;
36
+ if (count >= 3) {
37
+ console.warn('[Lithent HMR] boundary update repeatedly failed; reload manually:', reason);
38
+ return;
105
39
  }
40
+ console.warn('[Lithent HMR] invalidating boundary:', reason);
41
+ import.meta.hot?.invalidate(reason);
106
42
  };
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;
43
+ const __lithentWrapComponent = (name, implementation) => {
44
+ // Production/SSR builds retain the original component and never register hooks.
45
+ if (!import.meta.hot) return implementation;
46
+ let entry = __lithentModuleHotStore[name];
47
+ if (!entry) {
48
+ entry = {
49
+ implementation,
50
+ registrations: new WeakMap(),
51
+ boundary: __lithentCreateBoundary(
52
+ __lithentModuleId + '#' + name,
53
+ reason => __lithentSafeInvalidate(reason, name),
54
+ () => __lithentResetInvalidateCount(name)
55
+ ),
56
+ };
57
+ entry.component = (props, children) => {
58
+ const compKey = __lithentGetComponentKey();
59
+ const instance = compKey && __lithentComponentMap.get(compKey);
60
+ if (instance && entry.registrations.get(compKey) !== instance) {
61
+ entry.registrations.set(compKey, instance);
62
+ // Direct calls rerender the caller's existing closure; they have no
63
+ // independent VDOM that can be replaced with this component's tag.
64
+ const callerCtor = instance.ctor && instance.ctor !== entry.component
65
+ ? instance.ctor : undefined;
66
+ const unregister = entry.boundary.register(compKey, callerCtor);
67
+ __lithentMountCallback(() => () => {
68
+ if (entry.registrations.get(compKey) === instance) {
69
+ entry.registrations.delete(compKey);
70
+ }
71
+ unregister();
72
+ });
73
+ }
74
+ const implementation = entry.implementation;
75
+ return implementation(props, children);
76
+ };
77
+ __lithentModuleHotStore[name] = entry;
118
78
  }
119
-
120
- console.warn(
121
- \`[Lithent HMR] 변경된 경계를 적용하지 못해 전체 리프레시합니다. (시도 \${count}/\${MAX_INVALIDATE_ATTEMPTS})\`,
122
- reason
123
- );
124
- import.meta.hot?.invalidate?.();
79
+ entry.implementation = implementation;
80
+ __lithentCurrentComponents[name] = entry;
81
+ return entry.component;
125
82
  };
126
-
127
83
  const __lithentSetupHmrHooks = () => {
128
- if (!import.meta.hot) {
129
- return;
130
- }
131
-
132
- import.meta.hot.accept(mod => {
133
- let applied = false;
134
- const nextModule = mod ?? {};
135
- const missing = [];
136
- const knownNames = new Set([
137
- ...${JSON.stringify(Array.from(new Set(componentNames)))},
138
- ...__lithentHmrTargets,
139
- ]);
140
-
141
- for (const name of knownNames) {
142
- const nextCtor =
143
- name === 'default'
144
- ? nextModule.default
145
- : nextModule[name] ||
146
- __lithentModuleHotStore?.[name];
147
-
148
- if (!nextCtor) {
149
- missing.push(name);
150
- continue;
151
- }
152
-
153
- if (counterBoundary.update(nextCtor)) {
154
- applied = true;
84
+ if (!import.meta.hot) return;
85
+ // Entries without components must propagate to importers, not accept an update
86
+ // that cannot render anything (e.g. the render-only application entry).
87
+ if (${JSON.stringify(componentNames)}.length) {
88
+ import.meta.hot.accept(mod => {
89
+ const current = __lithentHotData.currentComponents || {};
90
+ const missing = ${JSON.stringify(componentNames)}.filter(name => !current[name]);
91
+ if (!mod || missing.length) {
92
+ import.meta.hot.invalidate('removed or unavailable components: ' + missing.join(', '));
93
+ return;
155
94
  }
156
- }
157
-
158
- if (applied) {
159
- __lithentResetInvalidateCount();
160
- } else {
161
- if (missing.length && __lithentModuleHotStore) {
162
- queueMicrotask(() => {
163
- let retried = false;
164
- for (const name of missing) {
165
- const retryCtor = __lithentModuleHotStore?.[name];
166
- if (retryCtor && counterBoundary.update(retryCtor)) {
167
- retried = true;
168
- }
169
- }
170
- if (retried) {
171
- __lithentResetInvalidateCount();
172
- } else {
173
- __lithentSafeInvalidate(\`missing components: \${missing.join(', ')}\`);
174
- }
175
- });
95
+ const previousExports = __lithentExportSnapshot;
96
+ if (!previousExports || Object.keys(previousExports).length !== Object.keys(mod).length
97
+ || Object.keys(previousExports).some(name => !Object.prototype.hasOwnProperty.call(mod, name)
98
+ || !Object.is(previousExports[name], mod[name]))) {
99
+ import.meta.hot.invalidate('module exports changed');
176
100
  return;
177
101
  }
178
- __lithentSafeInvalidate('no components applied');
179
- }
180
- });
181
-
182
- import.meta.hot.dispose(data => {
183
- data.counterBoundary = counterBoundary;
184
-
185
- if (disposeApp) {
186
- data.disposeApp = disposeApp;
187
- }
188
-
189
- if (__lithentGlobalStore) {
190
- __lithentGlobalStore[__lithentBoundaryStoreKey] = counterBoundary;
191
-
192
- if (disposeApp) {
193
- __lithentGlobalStore[__lithentDisposeStoreKey] = disposeApp;
102
+ for (const name of Object.keys(current)) {
103
+ const entry = current[name];
104
+ // Existing imports and future parent redraws keep the stable proxy.
105
+ if (!entry.boundary.update(entry.component)) {
106
+ __lithentResetInvalidateCount(name);
107
+ }
194
108
  }
195
-
196
- // Reset invalidate count on dispose to allow fresh attempts on next load
109
+ // An unmounted component is a valid update: its proxy is already refreshed.
197
110
  __lithentResetInvalidateCount();
111
+ });
112
+ }
113
+ import.meta.hot.dispose(data => {
114
+ data.components = __lithentModuleHotStore;
115
+ data.currentComponents = undefined;
116
+ data.renderDisposers = __lithentRenderDisposers;
117
+ // Keep the failure count across invalidation-driven module replacements.
118
+ });
119
+ import.meta.hot.prune(() => {
120
+ // App disposers need the live component map to locate the current root.
121
+ for (const dispose of __lithentRenderDisposers.values()) dispose();
122
+ __lithentRenderDisposers.clear();
123
+ for (const [name, entry] of Object.entries(__lithentModuleHotStore)) {
124
+ entry.boundary.dispose();
125
+ __lithentResetInvalidateCount(name);
198
126
  }
127
+ __lithentResetInvalidateCount();
199
128
  });
200
129
  };
201
-
202
130
  __lithentSetupHmrHooks();
203
131
  `;
204
132
  };
@@ -0,0 +1,312 @@
1
+ // @vitest-environment node
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import { transformWithEsbuild } from 'vite';
4
+ import { transformWithoutMarker } from '../noMarkerParse';
5
+
6
+ const compile = async (source: string) => {
7
+ const transformed = transformWithoutMarker({
8
+ code: source,
9
+ boundaryImportSpecifier: 'lithent/devHelper',
10
+ tagFunctionImportSpecifier: 'lithent',
11
+ });
12
+ return (
13
+ await transformWithEsbuild(
14
+ transformed.code.replaceAll('import.meta', '__meta'),
15
+ 'Component.tsx',
16
+ { format: 'cjs', jsx: 'automatic', jsxImportSource: 'lithent' }
17
+ )
18
+ ).code;
19
+ };
20
+
21
+ const runtime = () => {
22
+ const data: Record<string, any> = {};
23
+ const boundaries = new Map<string, any>();
24
+ const hot = {
25
+ data,
26
+ accept: vi.fn(),
27
+ dispose: vi.fn(),
28
+ prune: vi.fn(),
29
+ invalidate: vi.fn(),
30
+ };
31
+ const createBoundary = vi.fn(
32
+ (id: string, failure: (reason: string) => void, applied: () => void) => {
33
+ const boundary = {
34
+ register: vi.fn(() => vi.fn()),
35
+ update: vi.fn(() => true),
36
+ dispose: vi.fn(),
37
+ failure,
38
+ applied,
39
+ };
40
+ boundaries.set(id, boundary);
41
+ return boundary;
42
+ }
43
+ );
44
+ const libraries: Record<string, any> = {
45
+ lithent: {
46
+ getComponentKey: () => null,
47
+ mountCallback: vi.fn(),
48
+ componentMap: new WeakMap(),
49
+ render: vi.fn(() => vi.fn()),
50
+ },
51
+ 'lithent/devHelper': { createBoundary },
52
+ 'lithent/jsx-runtime': {
53
+ jsx: (tag: string, props: any) => ({ tag, ...props }),
54
+ },
55
+ };
56
+ const load = async (
57
+ source: string,
58
+ url = 'http://localhost/Component.tsx',
59
+ hmr = true
60
+ ) => {
61
+ const exports = {};
62
+ const module = { exports };
63
+ new Function(
64
+ 'require',
65
+ 'module',
66
+ 'exports',
67
+ '__meta',
68
+ await compile(source)
69
+ )((name: string) => libraries[name], module, exports, {
70
+ url,
71
+ hot: hmr ? hot : undefined,
72
+ });
73
+ return module.exports as Record<string, any>;
74
+ };
75
+ return { hot, load, boundaries, createBoundary, libraries };
76
+ };
77
+
78
+ const badge = (label: string) =>
79
+ `export const Badge = () => <span>${label}</span>;`;
80
+
81
+ describe('HMR component proxies and invalidation budget', () => {
82
+ it('refreshes existing imports and keeps per-component identities', async () => {
83
+ const r = runtime();
84
+ const first = await r.load(
85
+ badge('first') + '\nexport const Card = () => <div/>;'
86
+ );
87
+ const accept = r.hot.accept.mock.calls[0][0];
88
+ r.hot.dispose.mock.calls[0][0](r.hot.data);
89
+ const next = await r.load(
90
+ badge('next') + '\nexport const Card = () => <article/>;',
91
+ 'http://localhost/Component.tsx?t=1'
92
+ );
93
+ accept(next);
94
+ expect(next.Badge).toBe(first.Badge);
95
+ expect(next.Card).toBe(first.Card);
96
+ expect(first.Badge({}, [])).toEqual({ tag: 'span', children: 'next' });
97
+ expect(first.Card({}, [])).toEqual({ tag: 'article' });
98
+ expect(r.createBoundary).toHaveBeenCalledTimes(2);
99
+ expect(
100
+ r.boundaries.get('/Component.tsx#Badge').update
101
+ ).toHaveBeenCalledTimes(1);
102
+ expect(
103
+ r.boundaries.get('/Component.tsx#Card').update
104
+ ).toHaveBeenCalledTimes(1);
105
+ expect(r.hot.invalidate).not.toHaveBeenCalled();
106
+ });
107
+
108
+ it('does not reset failed replacement counts on dispose or sibling success', async () => {
109
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
110
+ const r = runtime();
111
+ const url = 'http://localhost/failure-budget.tsx';
112
+ let exports = await r.load(
113
+ badge('first') + '\nexport const Card = () => <div/>;',
114
+ url
115
+ );
116
+ const boundary = r.boundaries.get('/failure-budget.tsx#Badge');
117
+ for (let i = 0; i < 6; i++) {
118
+ const accept = r.hot.accept.mock.lastCall![0];
119
+ r.hot.dispose.mock.lastCall![0](r.hot.data);
120
+ exports = await r.load(
121
+ badge('next') + '\nexport const Card = () => <div/>;',
122
+ url + '?t=' + i
123
+ );
124
+ accept(exports);
125
+ r.boundaries.get('/failure-budget.tsx#Card').applied();
126
+ boundary.failure('failed DOM');
127
+ }
128
+ expect(r.hot.invalidate).toHaveBeenCalledTimes(2);
129
+ boundary.applied();
130
+ boundary.failure('after recovery');
131
+ expect(r.hot.invalidate).toHaveBeenCalledTimes(3);
132
+ r.hot.prune.mock.lastCall![0]();
133
+ warn.mockRestore();
134
+ });
135
+
136
+ it('invalidates removed components instead of accepting stale registrations', async () => {
137
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
138
+ const r = runtime();
139
+ await r.load(badge('first'), 'http://localhost/removed.tsx');
140
+ const accept = r.hot.accept.mock.lastCall![0];
141
+ r.hot.dispose.mock.lastCall![0](r.hot.data);
142
+ const next = await r.load(
143
+ 'export const value = 1;',
144
+ 'http://localhost/removed.tsx?t=1'
145
+ );
146
+ accept(next);
147
+ expect(r.hot.invalidate).toHaveBeenCalledTimes(1);
148
+ expect(
149
+ r.boundaries.get('/removed.tsx#Badge').update
150
+ ).not.toHaveBeenCalled();
151
+ r.hot.prune.mock.lastCall![0]();
152
+ warn.mockRestore();
153
+ });
154
+
155
+ it('keeps meaningful URL queries distinct while discarding HMR timestamps', async () => {
156
+ const first = runtime();
157
+ const second = runtime();
158
+ await first.load(
159
+ badge('first'),
160
+ 'http://localhost/Component.tsx?variant=one&t=1'
161
+ );
162
+ await second.load(
163
+ badge('second'),
164
+ 'http://localhost/Component.tsx?variant=two&t=2'
165
+ );
166
+ expect([...first.boundaries.keys()]).toEqual([
167
+ '/Component.tsx?variant=one#Badge',
168
+ ]);
169
+ expect([...second.boundaries.keys()]).toEqual([
170
+ '/Component.tsx?variant=two#Badge',
171
+ ]);
172
+ });
173
+
174
+ it('propagates changed non-component exports and export aliases', async () => {
175
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
176
+ for (const nextSource of [
177
+ badge('next') + '\nexport const value = 2;',
178
+ badge('next') + '\nexport const value = 1; export {Badge as NewName};',
179
+ badge('next') + '\nexport const value = 1;',
180
+ ]) {
181
+ const r = runtime();
182
+ await r.load(
183
+ badge('first') + '\nexport const value = 1; export {Badge as OldName};'
184
+ );
185
+ const accept = r.hot.accept.mock.lastCall![0];
186
+ r.hot.dispose.mock.lastCall![0](r.hot.data);
187
+ accept(await r.load(nextSource));
188
+ expect(r.hot.invalidate).toHaveBeenCalledTimes(1);
189
+ expect(
190
+ r.boundaries.get('/Component.tsx#Badge').update
191
+ ).not.toHaveBeenCalled();
192
+ r.hot.prune.mock.lastCall![0]();
193
+ }
194
+ warn.mockRestore();
195
+ });
196
+
197
+ it('accepts component edits with unchanged primitive exports', async () => {
198
+ const r = runtime();
199
+ await r.load(badge('first') + '\nexport const value = 1;');
200
+ const accept = r.hot.accept.mock.lastCall![0];
201
+ r.hot.dispose.mock.lastCall![0](r.hot.data);
202
+ accept(await r.load(badge('next') + '\nexport const value = 1;'));
203
+ expect(r.hot.invalidate).not.toHaveBeenCalled();
204
+ expect(
205
+ r.boundaries.get('/Component.tsx#Badge').update
206
+ ).toHaveBeenCalledTimes(1);
207
+ });
208
+
209
+ it('keeps propagating intentional export changes beyond the failure retry limit', async () => {
210
+ const r = runtime();
211
+ await r.load(badge('first') + '\nexport const value = 0;');
212
+ for (let value = 1; value <= 5; value++) {
213
+ const accept = r.hot.accept.mock.lastCall![0];
214
+ r.hot.dispose.mock.lastCall![0](r.hot.data);
215
+ accept(await r.load(badge('next') + `\nexport const value = ${value};`));
216
+ }
217
+ expect(r.hot.invalidate).toHaveBeenCalledTimes(5);
218
+ r.hot.prune.mock.lastCall![0]();
219
+ });
220
+
221
+ it('wraps hoisted functions before their first top-level use', async () => {
222
+ const r = runtime();
223
+ const first = await r.load(`
224
+ export const reference = Badge;
225
+ export function Badge() {return <span>first</span>;}
226
+ `);
227
+ const accept = r.hot.accept.mock.lastCall![0];
228
+ r.hot.dispose.mock.lastCall![0](r.hot.data);
229
+ const next = await r.load(`
230
+ export const reference = Badge;
231
+ export function Badge() {return <span>next</span>;}
232
+ `);
233
+ accept(next);
234
+ expect(first.reference).toBe(first.Badge);
235
+ expect(first.reference({}, []).children).toBe('next');
236
+ });
237
+
238
+ it('keeps every top-level app mount, guards aliases, and leaves function mounts repeatable', async () => {
239
+ const r = runtime();
240
+ const source = `
241
+ import {render as attach} from 'lithent';
242
+ export const Badge = () => <span/>;
243
+ attach(Badge, 'first');
244
+ attach(Badge, 'second');
245
+ export function attachMore(target) {return attach(Badge, target);}
246
+ `;
247
+ const first = await r.load(source);
248
+ const renderer = r.libraries.lithent.render;
249
+ expect(renderer).toHaveBeenCalledTimes(2);
250
+ const firstDispose = renderer.mock.results[0].value;
251
+ const secondDispose = renderer.mock.results[1].value;
252
+ first.attachMore('third');
253
+ first.attachMore('fourth');
254
+ expect(renderer).toHaveBeenCalledTimes(4);
255
+ r.hot.dispose.mock.lastCall![0](r.hot.data);
256
+ await r.load(source);
257
+ expect(renderer).toHaveBeenCalledTimes(4);
258
+ r.hot.prune.mock.lastCall![0]();
259
+ expect(firstDispose).toHaveBeenCalledTimes(1);
260
+ expect(secondDispose).toHaveBeenCalledTimes(1);
261
+ expect(firstDispose.mock.invocationCallOrder[0]).toBeLessThan(
262
+ r.boundaries.get('/Component.tsx#Badge').dispose.mock
263
+ .invocationCallOrder[0]
264
+ );
265
+ });
266
+
267
+ it('preserves ordinary render helpers, local boundary helpers and SSR mounts', async () => {
268
+ const r = runtime();
269
+ const source = `
270
+ import {render as attach} from 'lithent';
271
+ const createBoundary = () => 'local';
272
+ function render(value) {return value;}
273
+ export const Badge = () => <span/>;
274
+ export const result = render(createBoundary());
275
+ attach(Badge, 'first');
276
+ attach(Badge, 'second');
277
+ `;
278
+ const first = await r.load(source, undefined, false);
279
+ expect(first.result).toBe('local');
280
+ expect(r.libraries.lithent.render).toHaveBeenCalledTimes(2);
281
+ expect(r.hot.accept).not.toHaveBeenCalled();
282
+ expect(r.createBoundary).not.toHaveBeenCalled();
283
+ });
284
+
285
+ it('registers direct calls to rerender their caller and rendered tags to replace themselves', async () => {
286
+ const r = runtime();
287
+ const exports = await r.load(`
288
+ export const Badge = ({label}) => <span>{label}</span>;
289
+ export const Parent = props => <div>{Badge(props)}</div>;
290
+ `);
291
+ const parentKey = { label: 'direct' };
292
+ let key = parentKey;
293
+ r.libraries.lithent.getComponentKey = () => key;
294
+ r.libraries.lithent.componentMap.set(key, { ctor: exports.Parent });
295
+ expect(exports.Parent(key, []).children.children).toBe('direct');
296
+ expect(
297
+ r.boundaries.get('/Component.tsx#Parent').register
298
+ ).toHaveBeenCalledTimes(1);
299
+ expect(
300
+ r.boundaries.get('/Component.tsx#Badge').register
301
+ ).toHaveBeenCalledWith(parentKey, exports.Parent);
302
+ key = { label: 'rendered' };
303
+ r.libraries.lithent.componentMap.set(key, { ctor: exports.Badge });
304
+ expect(exports.Badge(key, []).children).toBe('rendered');
305
+ expect(
306
+ r.boundaries.get('/Component.tsx#Badge').register
307
+ ).toHaveBeenCalledTimes(2);
308
+ expect(
309
+ r.boundaries.get('/Component.tsx#Badge').register
310
+ ).toHaveBeenLastCalledWith(key, undefined);
311
+ });
312
+ });