@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.
- 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 +4244 -4360
- package/dist/lithentHmrParser.mjs.map +1 -1
- package/dist/lithentHmrParser.umd.js +142 -181
- 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 +110 -142
- 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,164 +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
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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;
|
|
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
|
+
});
|