@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.
- package/dist/helper/calculator.d.cts +8 -0
- package/dist/helper/calculator.d.ts +1 -1
- package/dist/index.d.cts +8 -0
- package/dist/index.d.ts +5 -5
- package/dist/lithentHmrParser.mjs +4214 -4370
- package/dist/lithentHmrParser.mjs.map +1 -1
- package/dist/lithentHmrParser.umd.js +137 -216
- package/dist/lithentHmrParser.umd.js.map +1 -1
- package/dist/markerParse.d.cts +2 -0
- package/dist/markerParse.d.ts +1 -1
- package/dist/noMarkerParse.d.cts +2 -0
- package/dist/noMarkerParse.d.ts +1 -1
- package/dist/shared.d.cts +4 -0
- package/dist/shared.d.ts +1 -1
- package/dist/strings.d.cts +1 -0
- package/dist/strings.d.ts +1 -1
- package/dist/tests/bootstrap.test.d.cts +1 -0
- package/dist/tests/bootstrap.test.d.ts +1 -0
- package/dist/tests/mark.test.d.cts +1 -0
- package/dist/tests/nomark.test.d.cts +1 -0
- package/dist/transform/componentRegister.d.cts +3 -0
- package/dist/transform/componentRegister.d.ts +2 -2
- package/dist/transform/exportSnapshot.d.cts +3 -0
- package/dist/transform/exportSnapshot.d.ts +3 -0
- package/dist/transform/renderGuard.d.cts +3 -0
- package/dist/transform/renderGuard.d.ts +1 -1
- package/dist/types.d.cts +28 -0
- package/dist/utils/ast/componentCollector.d.cts +9 -0
- package/dist/utils/ast/componentCollector.d.ts +5 -4
- package/package.json +2 -2
- package/src/helper/calculator.ts +27 -7
- package/src/markerParse.ts +30 -14
- package/src/noMarkerParse.ts +16 -4
- package/src/shared.ts +8 -2
- package/src/strings.ts +105 -177
- package/src/tests/bootstrap.test.ts +312 -0
- package/src/tests/mark.test.ts +61 -181
- package/src/tests/nomark.test.tsx +110 -89
- package/src/transform/componentRegister.ts +13 -304
- package/src/transform/exportSnapshot.ts +77 -0
- package/src/transform/renderGuard.ts +59 -49
- package/src/utils/ast/componentCollector.ts +195 -96
package/src/strings.ts
CHANGED
|
@@ -1,204 +1,132 @@
|
|
|
1
1
|
export const createHmrBootstrapBlock = (
|
|
2
|
-
|
|
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
|
|
13
|
-
|
|
14
|
-
const
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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;
|
|
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;
|
|
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;
|
|
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;
|
|
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;
|
|
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;
|
|
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;
|
|
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
|
+
});
|