@lithent/hmr-parser 0.2.1 → 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 +4244 -4360
  6. package/dist/lithentHmrParser.mjs.map +1 -1
  7. package/dist/lithentHmrParser.umd.js +142 -181
  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 +110 -142
  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,164 +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}\`;
15
- const __lithentGlobalStore =
16
- typeof globalThis === 'object'
17
- ? globalThis
18
- : undefined;
19
-
20
- const __lithentEnsureHotData = () => {
21
- const hot = import.meta.hot ?? undefined;
22
- if (!hot) return undefined;
23
-
24
- const existing = hot.data;
25
- if (existing && typeof existing === 'object') {
26
- return existing;
27
- }
28
-
29
- try {
30
- const target = {};
31
- hot.data = target;
32
- return target;
33
- } catch {
34
- hot.__lithentData = hot.__lithentData || {};
35
- return hot.__lithentData;
36
- }
37
- };
38
-
39
- const __lithentHotData = __lithentEnsureHotData();
40
-
41
- const counterBoundary =
42
- __lithentHotData?.counterBoundary ||
43
- __lithentGlobalStore?.[__lithentBoundaryStoreKey] ||
44
- createBoundary(__lithentModuleId);
45
-
46
- if (__lithentHotData) {
47
- __lithentHotData.counterBoundary = counterBoundary;
48
- }
49
-
50
- if (__lithentGlobalStore) {
51
- __lithentGlobalStore[__lithentBoundaryStoreKey] = counterBoundary;
52
- }
53
-
54
- let disposeApp =
55
- __lithentHotData?.disposeApp ||
56
- __lithentGlobalStore?.[__lithentDisposeStoreKey];
57
-
58
- const __lithentRenderOnce = (factory) => {
59
- if (disposeApp) {
60
- return disposeApp;
61
- }
62
-
6
+ const __lithentModuleUrl = new URL(import.meta.url);
7
+ __lithentModuleUrl.searchParams.delete('t');
8
+ const __lithentModuleId = __lithentModuleUrl.pathname + __lithentModuleUrl.search;
9
+ const __lithentInvalidateCountKey = \`__lithent_hmr_invalidate_count__\${__lithentModuleId}\`;
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);
63
23
  const result = factory();
64
-
65
24
  if (typeof result === 'function') {
66
- disposeApp = result;
67
-
68
- if (__lithentHotData) {
69
- __lithentHotData.disposeApp = disposeApp;
70
- }
71
-
72
- if (__lithentGlobalStore && disposeApp) {
73
- __lithentGlobalStore[__lithentDisposeStoreKey] = disposeApp;
74
- }
25
+ __lithentRenderDisposers.set(id, result);
75
26
  }
76
-
77
27
  return result;
78
28
  };
79
-
80
- const __lithentModuleHotStore =
81
- __lithentHotData
82
- ? ((__lithentHotData.components =
83
- __lithentHotData.components ?? {}))
84
- : undefined;
85
-
86
- const __lithentHmrTargets = ${serializedTargets};
87
-
88
- const __lithentSetupHmrHooks = () => {
89
- if (!import.meta.hot) {
29
+ const __lithentResetInvalidateCount = (name = '') => {
30
+ __lithentGlobalStore[__lithentInvalidateCountKey + '#' + name] = 0;
31
+ };
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);
90
38
  return;
91
39
  }
92
-
93
- import.meta.hot.accept(mod => {
94
- let applied = false;
95
- const nextModule = mod ?? {};
96
- const missing = [];
97
- const knownNames = new Set([
98
- ...${JSON.stringify(Array.from(new Set(componentNames)))},
99
- ...__lithentHmrTargets,
100
- ]);
101
-
102
- for (const name of knownNames) {
103
- const nextCtor =
104
- name === 'default'
105
- ? nextModule.default
106
- : nextModule[name] ||
107
- __lithentModuleHotStore?.[name];
108
-
109
- if (!nextCtor) {
110
- missing.push(name);
111
- continue;
112
- }
113
-
114
- if (counterBoundary.update(nextCtor)) {
115
- applied = true;
116
- }
117
- }
118
-
119
- if (!applied) {
120
- if (missing.length && __lithentModuleHotStore) {
121
- queueMicrotask(() => {
122
- let retried = false;
123
- for (const name of missing) {
124
- const retryCtor = __lithentModuleHotStore?.[name];
125
- if (retryCtor && counterBoundary.update(retryCtor)) {
126
- retried = true;
127
- }
128
- }
129
- if (!retried) {
130
- console.warn(
131
- '[Lithent HMR] 변경된 경계를 적용하지 못해 전체 리프레시합니다.'
132
- );
133
- import.meta.hot?.invalidate?.();
40
+ console.warn('[Lithent HMR] invalidating boundary:', reason);
41
+ import.meta.hot?.invalidate(reason);
42
+ };
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);
134
70
  }
71
+ unregister();
135
72
  });
73
+ }
74
+ const implementation = entry.implementation;
75
+ return implementation(props, children);
76
+ };
77
+ __lithentModuleHotStore[name] = entry;
78
+ }
79
+ entry.implementation = implementation;
80
+ __lithentCurrentComponents[name] = entry;
81
+ return entry.component;
82
+ };
83
+ const __lithentSetupHmrHooks = () => {
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(', '));
136
93
  return;
137
94
  }
138
- console.warn(
139
- '[Lithent HMR] 변경된 경계를 적용하지 못해 전체 리프레시합니다.'
140
- );
141
- import.meta.hot?.invalidate?.();
142
- }
143
- });
144
-
145
- import.meta.hot.dispose(data => {
146
- data.counterBoundary = counterBoundary;
147
-
148
- if (disposeApp) {
149
- data.disposeApp = disposeApp;
150
- }
151
-
152
- if (__lithentGlobalStore) {
153
- __lithentGlobalStore[__lithentBoundaryStoreKey] = counterBoundary;
154
-
155
- if (disposeApp) {
156
- __lithentGlobalStore[__lithentDisposeStoreKey] = disposeApp;
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');
100
+ return;
101
+ }
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
+ }
157
108
  }
109
+ // An unmounted component is a valid update: its proxy is already refreshed.
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);
158
126
  }
127
+ __lithentResetInvalidateCount();
159
128
  });
160
129
  };
161
-
162
130
  __lithentSetupHmrHooks();
163
131
  `;
164
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
+ });