@lithent/hmr-parser 0.1.0
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/README.md +146 -0
- package/dist/helper/calculator.d.ts +8 -0
- package/dist/index.d.ts +8 -0
- package/dist/lithentHmrParser.mjs +21509 -0
- package/dist/lithentHmrParser.mjs.map +1 -0
- package/dist/lithentHmrParser.umd.js +216 -0
- package/dist/lithentHmrParser.umd.js.map +1 -0
- package/dist/markerParse.d.ts +2 -0
- package/dist/noMarkerParse.d.ts +2 -0
- package/dist/shared.d.ts +4 -0
- package/dist/strings.d.ts +1 -0
- package/dist/tests/mark.test.d.ts +1 -0
- package/dist/tests/nomark.test.d.ts +1 -0
- package/dist/transform/componentRegister.d.ts +3 -0
- package/dist/transform/renderGuard.d.ts +3 -0
- package/dist/types.d.ts +28 -0
- package/dist/utils/ast/componentCollector.d.ts +8 -0
- package/package.json +52 -0
- package/src/helper/calculator.ts +169 -0
- package/src/index.ts +33 -0
- package/src/markerParse.ts +61 -0
- package/src/noMarkerParse.ts +70 -0
- package/src/shared.ts +109 -0
- package/src/strings.ts +176 -0
- package/src/tests/mark.test.ts +162 -0
- package/src/tests/nomark.test.tsx +100 -0
- package/src/transform/componentRegister.ts +50 -0
- package/src/transform/renderGuard.ts +59 -0
- package/src/types.ts +33 -0
- package/src/utils/ast/componentCollector.ts +119 -0
package/src/strings.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
export const createHmrBootstrapBlock = (
|
|
2
|
+
targetExports: string[],
|
|
3
|
+
componentNames: string[]
|
|
4
|
+
) => {
|
|
5
|
+
const normalizedTargets = new Set(
|
|
6
|
+
componentNames.length ? componentNames : targetExports
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
const serializedTargets = JSON.stringify(Array.from(normalizedTargets));
|
|
10
|
+
|
|
11
|
+
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 as Record<string, unknown>)
|
|
18
|
+
: undefined;
|
|
19
|
+
|
|
20
|
+
const __lithentEnsureHotData = (): Record<string, unknown> | undefined => {
|
|
21
|
+
const hot =
|
|
22
|
+
(import.meta.hot as
|
|
23
|
+
| {
|
|
24
|
+
data?: Record<string, unknown>;
|
|
25
|
+
__lithentData?: Record<string, unknown>;
|
|
26
|
+
}
|
|
27
|
+
| undefined) ?? undefined;
|
|
28
|
+
if (!hot) return undefined;
|
|
29
|
+
|
|
30
|
+
const existing = hot.data;
|
|
31
|
+
if (existing && typeof existing === 'object') {
|
|
32
|
+
return existing;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const target: Record<string, unknown> = {};
|
|
37
|
+
hot.data = target;
|
|
38
|
+
return target;
|
|
39
|
+
} catch {
|
|
40
|
+
hot.__lithentData = hot.__lithentData || {};
|
|
41
|
+
return hot.__lithentData;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const __lithentHotData = __lithentEnsureHotData();
|
|
46
|
+
type __LithentBoundaryController = ReturnType<typeof createBoundary>;
|
|
47
|
+
|
|
48
|
+
const counterBoundary: __LithentBoundaryController =
|
|
49
|
+
(__lithentHotData?.counterBoundary as __LithentBoundaryController | undefined) ||
|
|
50
|
+
(__lithentGlobalStore?.[__lithentBoundaryStoreKey] as __LithentBoundaryController | undefined) ||
|
|
51
|
+
createBoundary(__lithentModuleId);
|
|
52
|
+
|
|
53
|
+
if (__lithentHotData) {
|
|
54
|
+
__lithentHotData.counterBoundary = counterBoundary;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (__lithentGlobalStore) {
|
|
58
|
+
__lithentGlobalStore[__lithentBoundaryStoreKey] = counterBoundary;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let disposeApp =
|
|
62
|
+
(__lithentHotData?.disposeApp as (() => void) | undefined) ||
|
|
63
|
+
(__lithentGlobalStore?.[__lithentDisposeStoreKey] as (() => void) | undefined);
|
|
64
|
+
|
|
65
|
+
const __lithentRenderOnce = <T,>(factory: () => T) => {
|
|
66
|
+
if (disposeApp) {
|
|
67
|
+
return disposeApp as unknown as T;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const result = factory();
|
|
71
|
+
|
|
72
|
+
if (typeof result === 'function') {
|
|
73
|
+
disposeApp = result as unknown as () => void;
|
|
74
|
+
|
|
75
|
+
if (__lithentHotData) {
|
|
76
|
+
__lithentHotData.disposeApp = disposeApp;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (__lithentGlobalStore && disposeApp) {
|
|
80
|
+
__lithentGlobalStore[__lithentDisposeStoreKey] = disposeApp;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return result;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const __lithentModuleHotStore =
|
|
88
|
+
__lithentHotData
|
|
89
|
+
? ((__lithentHotData.components =
|
|
90
|
+
(__lithentHotData.components as Record<
|
|
91
|
+
string,
|
|
92
|
+
TagFunction | undefined
|
|
93
|
+
> | undefined) ?? {}) as Record<string, TagFunction | undefined>)
|
|
94
|
+
: undefined;
|
|
95
|
+
|
|
96
|
+
const __lithentHmrTargets = ${serializedTargets};
|
|
97
|
+
|
|
98
|
+
const __lithentSetupHmrHooks = () => {
|
|
99
|
+
if (!import.meta.hot) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
import.meta.hot.accept(mod => {
|
|
104
|
+
let applied = false;
|
|
105
|
+
const nextModule = (mod ?? {}) as Record<string, unknown>;
|
|
106
|
+
const missing: string[] = [];
|
|
107
|
+
const knownNames = new Set([
|
|
108
|
+
...${JSON.stringify(Array.from(new Set(componentNames)))},
|
|
109
|
+
...__lithentHmrTargets,
|
|
110
|
+
]);
|
|
111
|
+
|
|
112
|
+
for (const name of knownNames) {
|
|
113
|
+
const nextCtor =
|
|
114
|
+
name === 'default'
|
|
115
|
+
? (nextModule.default as TagFunction | undefined)
|
|
116
|
+
: (nextModule[name] as TagFunction | undefined) ||
|
|
117
|
+
(__lithentModuleHotStore?.[name] as TagFunction | undefined);
|
|
118
|
+
|
|
119
|
+
if (!nextCtor) {
|
|
120
|
+
missing.push(name);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (counterBoundary.update(nextCtor)) {
|
|
125
|
+
applied = true;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (!applied) {
|
|
130
|
+
if (missing.length && __lithentModuleHotStore) {
|
|
131
|
+
queueMicrotask(() => {
|
|
132
|
+
let retried = false;
|
|
133
|
+
for (const name of missing) {
|
|
134
|
+
const retryCtor = __lithentModuleHotStore?.[name] as
|
|
135
|
+
| TagFunction
|
|
136
|
+
| undefined;
|
|
137
|
+
if (retryCtor && counterBoundary.update(retryCtor)) {
|
|
138
|
+
retried = true;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (!retried) {
|
|
142
|
+
console.warn(
|
|
143
|
+
'[Lithent HMR] 변경된 경계를 적용하지 못해 전체 리프레시합니다.'
|
|
144
|
+
);
|
|
145
|
+
import.meta.hot?.invalidate?.();
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
console.warn(
|
|
151
|
+
'[Lithent HMR] 변경된 경계를 적용하지 못해 전체 리프레시합니다.'
|
|
152
|
+
);
|
|
153
|
+
import.meta.hot?.invalidate?.();
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
import.meta.hot.dispose(data => {
|
|
158
|
+
data.counterBoundary = counterBoundary;
|
|
159
|
+
|
|
160
|
+
if (disposeApp) {
|
|
161
|
+
data.disposeApp = disposeApp;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (__lithentGlobalStore) {
|
|
165
|
+
__lithentGlobalStore[__lithentBoundaryStoreKey] = counterBoundary;
|
|
166
|
+
|
|
167
|
+
if (disposeApp) {
|
|
168
|
+
__lithentGlobalStore[__lithentDisposeStoreKey] = disposeApp;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
__lithentSetupHmrHooks();
|
|
175
|
+
`;
|
|
176
|
+
};
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { transformWithMarker } from '../markerParse';
|
|
2
|
+
|
|
3
|
+
const createDefaultMarkerRegex = () =>
|
|
4
|
+
new RegExp(
|
|
5
|
+
'/\\*\\s*lithent:hmr-boundary(?:\\s+([A-Za-z0-9_,\\s]+))?\\s*\\*/'
|
|
6
|
+
);
|
|
7
|
+
|
|
8
|
+
const markerSourceDefault = `
|
|
9
|
+
'use client';
|
|
10
|
+
import { render, mount } from 'lithent';
|
|
11
|
+
|
|
12
|
+
/* lithent:hmr-boundary default */
|
|
13
|
+
|
|
14
|
+
const App = mount((renew, props: { title: string }) => {
|
|
15
|
+
return () => <div>{props.title}</div>;
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const Secondary = mount((renew, props: { subtitle: string }) => {
|
|
19
|
+
return () => <p>{props.subtitle}</p>;
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export default App;
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
const markerSourceNamed = `
|
|
26
|
+
"use client";
|
|
27
|
+
import type { TagFunction } from 'lithent';
|
|
28
|
+
import { createBoundary } from 'lithent/devHelper';
|
|
29
|
+
import { render, mount } from 'lithent';
|
|
30
|
+
|
|
31
|
+
/* lithent:hmr-boundary Counter, Another */
|
|
32
|
+
|
|
33
|
+
export const Counter = mount((renew, props) => {
|
|
34
|
+
return () => <span>{props.value}</span>;
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export const Another = mount((renew, props) => {
|
|
38
|
+
return () => <span>{props.value}</span>;
|
|
39
|
+
});
|
|
40
|
+
`;
|
|
41
|
+
|
|
42
|
+
const defaultResult = transformWithMarker({
|
|
43
|
+
code: markerSourceDefault,
|
|
44
|
+
markerRegex: createDefaultMarkerRegex(),
|
|
45
|
+
boundaryImportSpecifier: 'lithent/devHelper',
|
|
46
|
+
tagFunctionImportSpecifier: 'lithent',
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const namedResult = transformWithMarker({
|
|
50
|
+
code: markerSourceNamed,
|
|
51
|
+
markerRegex: createDefaultMarkerRegex(),
|
|
52
|
+
boundaryImportSpecifier: 'lithent/devHelper',
|
|
53
|
+
tagFunctionImportSpecifier: 'lithent',
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
console.log('[marker] transformed default:', defaultResult.transformed);
|
|
57
|
+
console.log('[marker] transformed named:', namedResult.transformed);
|
|
58
|
+
console.log('[marker] code default:', defaultResult.code);
|
|
59
|
+
console.log('[marker] code named:', namedResult.code);
|
|
60
|
+
|
|
61
|
+
if (!import.meta.vitest) {
|
|
62
|
+
const defaultPre = document?.getElementById('marker-default');
|
|
63
|
+
if (defaultPre) {
|
|
64
|
+
defaultPre.textContent = defaultResult.transformed
|
|
65
|
+
? defaultResult.code
|
|
66
|
+
: 'transform skipped';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const namedPre = document?.getElementById('marker-named');
|
|
70
|
+
if (namedPre) {
|
|
71
|
+
namedPre.textContent = namedResult.transformed
|
|
72
|
+
? namedResult.code
|
|
73
|
+
: 'transform skipped';
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (import.meta.vitest) {
|
|
78
|
+
const { describe, it, expect } = import.meta.vitest;
|
|
79
|
+
|
|
80
|
+
describe('transformWithMarker', () => {
|
|
81
|
+
it('replaces marker comment with HMR bootstrap and injects helpers', () => {
|
|
82
|
+
expect(defaultResult.transformed).toBe(true);
|
|
83
|
+
expect(defaultResult.code).not.toMatch(/lithent:hmr-boundary/);
|
|
84
|
+
expect(defaultResult.code).toContain(
|
|
85
|
+
"import { createBoundary } from 'lithent/devHelper';"
|
|
86
|
+
);
|
|
87
|
+
expect(defaultResult.code).toContain(
|
|
88
|
+
"import type { TagFunction } from 'lithent';"
|
|
89
|
+
);
|
|
90
|
+
expect(defaultResult.code).toContain('import.meta.hot as');
|
|
91
|
+
expect(defaultResult.code).toContain(
|
|
92
|
+
"import { mountCallback, getComponentKey } from 'lithent';"
|
|
93
|
+
);
|
|
94
|
+
expect(defaultResult.code).toContain(
|
|
95
|
+
'const __lithentModuleId = new URL(import.meta.url).pathname;'
|
|
96
|
+
);
|
|
97
|
+
expect(defaultResult.code).toContain(
|
|
98
|
+
'const __lithentHmrTargets = ["App","Secondary"];'
|
|
99
|
+
);
|
|
100
|
+
expect(defaultResult.code).toContain('const knownNames = new Set([');
|
|
101
|
+
expect(defaultResult.code).toContain('...["App","Secondary"]');
|
|
102
|
+
expect(defaultResult.code).toContain('...__lithentHmrTargets');
|
|
103
|
+
expect(defaultResult.code).toContain(
|
|
104
|
+
'const __lithentHotComponent_App = App as unknown as TagFunction;'
|
|
105
|
+
);
|
|
106
|
+
expect(defaultResult.code).toContain(
|
|
107
|
+
'__lithentModuleHotStore["App"] = __lithentHotComponent_App;'
|
|
108
|
+
);
|
|
109
|
+
expect(defaultResult.code).toContain(
|
|
110
|
+
'const __lithentHotComponent_Secondary = Secondary as unknown as TagFunction;'
|
|
111
|
+
);
|
|
112
|
+
expect(defaultResult.code).toContain(
|
|
113
|
+
'__lithentModuleHotStore["Secondary"] = __lithentHotComponent_Secondary;'
|
|
114
|
+
);
|
|
115
|
+
expect(defaultResult.code).toContain('const __lithentRenderOnce = <');
|
|
116
|
+
const registerMatches =
|
|
117
|
+
defaultResult.code.match(/counterBoundary\.register\(compKey\)/g) ?? [];
|
|
118
|
+
const unregisterMatches =
|
|
119
|
+
defaultResult.code.match(
|
|
120
|
+
/mountCallback\(\(\) => \(\) => unregister\(\)\)/g
|
|
121
|
+
) ?? [];
|
|
122
|
+
expect(registerMatches).toHaveLength(2);
|
|
123
|
+
expect(unregisterMatches).toHaveLength(2);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('respects explicit export names declared in the marker', () => {
|
|
127
|
+
expect(namedResult.transformed).toBe(true);
|
|
128
|
+
expect(
|
|
129
|
+
namedResult.code.match(
|
|
130
|
+
/import { createBoundary } from 'lithent\/devHelper';/g
|
|
131
|
+
)
|
|
132
|
+
).toHaveLength(1);
|
|
133
|
+
expect(namedResult.code).toContain(
|
|
134
|
+
'const __lithentHmrTargets = ["Counter","Another"];'
|
|
135
|
+
);
|
|
136
|
+
expect(namedResult.code).toContain('import.meta.hot as');
|
|
137
|
+
expect(namedResult.code).toContain('const knownNames = new Set([');
|
|
138
|
+
expect(namedResult.code).toContain('...["Counter","Another"]');
|
|
139
|
+
expect(namedResult.code).toContain('...__lithentHmrTargets');
|
|
140
|
+
expect(namedResult.code).toContain(
|
|
141
|
+
'const __lithentHotComponent_Counter = Counter as unknown as TagFunction;'
|
|
142
|
+
);
|
|
143
|
+
expect(namedResult.code).toContain(
|
|
144
|
+
'__lithentModuleHotStore["Counter"] = __lithentHotComponent_Counter;'
|
|
145
|
+
);
|
|
146
|
+
expect(namedResult.code).toContain(
|
|
147
|
+
'const __lithentHotComponent_Another = Another as unknown as TagFunction;'
|
|
148
|
+
);
|
|
149
|
+
expect(namedResult.code).toContain(
|
|
150
|
+
'__lithentModuleHotStore["Another"] = __lithentHotComponent_Another;'
|
|
151
|
+
);
|
|
152
|
+
const namedRegisters =
|
|
153
|
+
namedResult.code.match(/counterBoundary\.register\(compKey\)/g) ?? [];
|
|
154
|
+
const namedUnregisters =
|
|
155
|
+
namedResult.code.match(
|
|
156
|
+
/mountCallback\(\(\) => \(\) => unregister\(\)\)/g
|
|
157
|
+
) ?? [];
|
|
158
|
+
expect(namedRegisters).toHaveLength(2);
|
|
159
|
+
expect(namedUnregisters).toHaveLength(2);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { transformWithoutMarker } from '../noMarkerParse';
|
|
2
|
+
|
|
3
|
+
const source = `
|
|
4
|
+
'use client';
|
|
5
|
+
import { render, mount } from 'lithent';
|
|
6
|
+
|
|
7
|
+
const Demo = mount((_renew, props: { message: string }) => {
|
|
8
|
+
return () => ({ type: 'div', props: {}, children: [props.message] } as any);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const Another = mount((_renew, props: { id: number }) => {
|
|
12
|
+
return () => ({ type: 'span', props: {}, children: [String(props.id)] } as any);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const root = document.getElementById('app');
|
|
16
|
+
if (root) {
|
|
17
|
+
render(Demo({ message: 'hello' }), root as any);
|
|
18
|
+
render(Another({ id: 1 }), root as any);
|
|
19
|
+
}
|
|
20
|
+
`;
|
|
21
|
+
|
|
22
|
+
const result = transformWithoutMarker({
|
|
23
|
+
code: source,
|
|
24
|
+
boundaryImportSpecifier: 'lithent/devHelper',
|
|
25
|
+
tagFunctionImportSpecifier: 'lithent',
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
console.log('[no-marker] transformed:', result.transformed);
|
|
29
|
+
console.log('[no-marker] code:', result.code);
|
|
30
|
+
|
|
31
|
+
if (!import.meta.vitest) {
|
|
32
|
+
const pre = document?.getElementById('output');
|
|
33
|
+
if (pre) {
|
|
34
|
+
pre.textContent = result.transformed ? result.code : 'transform skipped';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (import.meta.vitest) {
|
|
39
|
+
const { describe, it, expect } = import.meta.vitest;
|
|
40
|
+
|
|
41
|
+
describe('transformWithoutMarker (integration)', () => {
|
|
42
|
+
it('should mark transform as successful and inject HMR bootstrap + boundary registration', () => {
|
|
43
|
+
const expectedSnippet =
|
|
44
|
+
'const __lithentModuleId = new URL(import.meta.url).pathname;';
|
|
45
|
+
|
|
46
|
+
expect(result.transformed).toBe(true);
|
|
47
|
+
expect(result.code.includes(expectedSnippet)).toBe(true);
|
|
48
|
+
expect(
|
|
49
|
+
result.code.includes(
|
|
50
|
+
"import { createBoundary } from 'lithent/devHelper'"
|
|
51
|
+
)
|
|
52
|
+
).toBe(true);
|
|
53
|
+
expect(
|
|
54
|
+
result.code.includes(
|
|
55
|
+
"import { mountCallback, getComponentKey } from 'lithent'"
|
|
56
|
+
)
|
|
57
|
+
).toBe(true);
|
|
58
|
+
expect(result.code.includes('const compKey = getComponentKey();')).toBe(
|
|
59
|
+
true
|
|
60
|
+
);
|
|
61
|
+
expect(
|
|
62
|
+
result.code.includes('const __lithentHmrTargets = ["Demo","Another"];')
|
|
63
|
+
).toBe(true);
|
|
64
|
+
expect(result.code.includes('const knownNames = new Set([')).toBe(true);
|
|
65
|
+
expect(result.code.includes('...["Demo","Another"]')).toBe(true);
|
|
66
|
+
expect(result.code.includes('...__lithentHmrTargets')).toBe(true);
|
|
67
|
+
expect(
|
|
68
|
+
result.code.includes(
|
|
69
|
+
'const __lithentHotComponent_Demo = Demo as unknown as TagFunction;'
|
|
70
|
+
)
|
|
71
|
+
).toBe(true);
|
|
72
|
+
expect(
|
|
73
|
+
result.code.includes(
|
|
74
|
+
'__lithentModuleHotStore["Demo"] = __lithentHotComponent_Demo;'
|
|
75
|
+
)
|
|
76
|
+
).toBe(true);
|
|
77
|
+
expect(
|
|
78
|
+
result.code.includes(
|
|
79
|
+
'const __lithentHotComponent_Another = Another as unknown as TagFunction;'
|
|
80
|
+
)
|
|
81
|
+
).toBe(true);
|
|
82
|
+
expect(
|
|
83
|
+
result.code.includes(
|
|
84
|
+
'__lithentModuleHotStore["Another"] = __lithentHotComponent_Another;'
|
|
85
|
+
)
|
|
86
|
+
).toBe(true);
|
|
87
|
+
expect(result.code.includes('const __lithentRenderOnce = <')).toBe(true);
|
|
88
|
+
expect(/__lithentRenderOnce\(\(\) =>\s*render\(/.test(result.code)).toBe(
|
|
89
|
+
true
|
|
90
|
+
);
|
|
91
|
+
const registerMatches =
|
|
92
|
+
result.code.match(/counterBoundary\.register\(compKey\)/g) ?? [];
|
|
93
|
+
const unregisterMatches =
|
|
94
|
+
result.code.match(/mountCallback\(\(\) => \(\) => unregister\(\)\)/g) ??
|
|
95
|
+
[];
|
|
96
|
+
expect(registerMatches).toHaveLength(2);
|
|
97
|
+
expect(unregisterMatches).toHaveLength(2);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import MagicString from 'magic-string';
|
|
2
|
+
import type { MountInfo } from '../utils/ast/componentCollector';
|
|
3
|
+
|
|
4
|
+
export const stitchComponentRegistration = (
|
|
5
|
+
ms: MagicString,
|
|
6
|
+
mounts: MountInfo[],
|
|
7
|
+
code: string,
|
|
8
|
+
importInsertionPos: number
|
|
9
|
+
) => {
|
|
10
|
+
if (!mounts.length) return;
|
|
11
|
+
|
|
12
|
+
const needsMountCallback = !/import\s+{[^}]*mountCallback/.test(code);
|
|
13
|
+
const needsGetComponentKey = !/import\s+{[^}]*getComponentKey/.test(code);
|
|
14
|
+
|
|
15
|
+
if (needsMountCallback || needsGetComponentKey) {
|
|
16
|
+
const pieces: string[] = [];
|
|
17
|
+
if (needsMountCallback) {
|
|
18
|
+
pieces.push('mountCallback');
|
|
19
|
+
}
|
|
20
|
+
if (needsGetComponentKey) {
|
|
21
|
+
pieces.push('getComponentKey');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const joined = pieces.join(', ');
|
|
25
|
+
ms.appendLeft(importInsertionPos, `import { ${joined} } from 'lithent';\n`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
for (const mount of mounts) {
|
|
29
|
+
const { insertPos, componentName, declarationEnd } = mount;
|
|
30
|
+
const unregisterBlock = `
|
|
31
|
+
const compKey = getComponentKey();
|
|
32
|
+
const unregister = compKey ? counterBoundary.register(compKey) : null;
|
|
33
|
+
if (unregister) {
|
|
34
|
+
mountCallback(() => () => unregister());
|
|
35
|
+
}
|
|
36
|
+
`;
|
|
37
|
+
ms.appendLeft(insertPos, unregisterBlock);
|
|
38
|
+
|
|
39
|
+
if (componentName) {
|
|
40
|
+
const hotStoreVar = `__lithentHotComponent_${componentName}`;
|
|
41
|
+
const storeSnippet = `
|
|
42
|
+
const ${hotStoreVar} = ${componentName} as unknown as TagFunction;
|
|
43
|
+
if (__lithentModuleHotStore) {
|
|
44
|
+
__lithentModuleHotStore["${componentName}"] = ${hotStoreVar};
|
|
45
|
+
}
|
|
46
|
+
`;
|
|
47
|
+
ms.appendRight(declarationEnd, storeSnippet);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import MagicString from 'magic-string';
|
|
2
|
+
import type { File, Node, CallExpression } from '@babel/types';
|
|
3
|
+
import { VISITOR_KEYS } from '@babel/types';
|
|
4
|
+
|
|
5
|
+
const collectRenderCalls = (ast: Node, calls: CallExpression[]): void => {
|
|
6
|
+
const visit = (node: Node | null | undefined) => {
|
|
7
|
+
if (!node) return;
|
|
8
|
+
|
|
9
|
+
if (
|
|
10
|
+
node.type === 'CallExpression' &&
|
|
11
|
+
node.callee.type === 'Identifier' &&
|
|
12
|
+
node.callee.name === 'render'
|
|
13
|
+
) {
|
|
14
|
+
calls.push(node as CallExpression);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const keys = VISITOR_KEYS[node.type] ?? [];
|
|
18
|
+
for (const key of keys) {
|
|
19
|
+
const value = (node as any)[key];
|
|
20
|
+
if (Array.isArray(value)) {
|
|
21
|
+
for (const child of value) {
|
|
22
|
+
if (child && typeof child.type === 'string') {
|
|
23
|
+
visit(child as Node);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
} else if (value && typeof value.type === 'string') {
|
|
27
|
+
visit(value as Node);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
visit(ast);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const wrapRenderCalls = (
|
|
36
|
+
ms: MagicString,
|
|
37
|
+
ast: File,
|
|
38
|
+
code: string
|
|
39
|
+
): boolean => {
|
|
40
|
+
const calls: CallExpression[] = [];
|
|
41
|
+
collectRenderCalls(ast, calls);
|
|
42
|
+
|
|
43
|
+
if (!calls.length) return false;
|
|
44
|
+
|
|
45
|
+
const sorted = calls
|
|
46
|
+
.map(node => ({
|
|
47
|
+
start: node.start ?? 0,
|
|
48
|
+
end: node.end ?? 0,
|
|
49
|
+
}))
|
|
50
|
+
.filter(({ start, end }) => start >= 0 && end > start)
|
|
51
|
+
.sort((a, b) => b.start - a.start);
|
|
52
|
+
|
|
53
|
+
for (const { start, end } of sorted) {
|
|
54
|
+
const original = code.slice(start, end);
|
|
55
|
+
ms.overwrite(start, end, `__lithentRenderOnce(() => ${original})`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return true;
|
|
59
|
+
};
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type MagicString from 'magic-string';
|
|
2
|
+
import type { File } from '@babel/types';
|
|
3
|
+
|
|
4
|
+
export interface BaseTransformOptions {
|
|
5
|
+
code: string;
|
|
6
|
+
boundaryImportSpecifier: string;
|
|
7
|
+
tagFunctionImportSpecifier: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface MarkerTransformOptions extends BaseTransformOptions {
|
|
11
|
+
markerRegex: RegExp;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface BaseAnalysisResult {
|
|
15
|
+
targetExports: string[];
|
|
16
|
+
shouldTransform: boolean;
|
|
17
|
+
importInsertionPos: number;
|
|
18
|
+
blockInsertionPos: number;
|
|
19
|
+
headerSnippet: string;
|
|
20
|
+
ast: File;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface MarkerAnalysisResult extends BaseAnalysisResult {
|
|
24
|
+
match: RegExpExecArray | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface HmrTransformOptions extends MarkerTransformOptions {}
|
|
28
|
+
|
|
29
|
+
export interface HmrTransformResult {
|
|
30
|
+
transformed: boolean;
|
|
31
|
+
code: string;
|
|
32
|
+
map: ReturnType<MagicString['generateMap']> | null;
|
|
33
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
File,
|
|
3
|
+
Node,
|
|
4
|
+
CallExpression,
|
|
5
|
+
Identifier,
|
|
6
|
+
ObjectPattern,
|
|
7
|
+
ArrayPattern,
|
|
8
|
+
} from '@babel/types';
|
|
9
|
+
|
|
10
|
+
export type MountInfo = {
|
|
11
|
+
insertPos: number;
|
|
12
|
+
propsExpression: string;
|
|
13
|
+
componentName: string | null;
|
|
14
|
+
declarationEnd: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const isMountCall = (node: Node): node is CallExpression =>
|
|
18
|
+
node.type === 'CallExpression' &&
|
|
19
|
+
node.callee.type === 'Identifier' &&
|
|
20
|
+
node.callee.name === 'mount';
|
|
21
|
+
|
|
22
|
+
const sliceWithoutType = (
|
|
23
|
+
code: string,
|
|
24
|
+
node: Node & { typeAnnotation?: Node | null }
|
|
25
|
+
): string => {
|
|
26
|
+
const start = node.start ?? 0;
|
|
27
|
+
const end = node.typeAnnotation?.start ?? node.end ?? 0;
|
|
28
|
+
return code.slice(start, end);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const extractPropsExpression = (
|
|
32
|
+
propsParam: Identifier | ObjectPattern | ArrayPattern | undefined,
|
|
33
|
+
code: string
|
|
34
|
+
): string => {
|
|
35
|
+
if (!propsParam) {
|
|
36
|
+
return 'props';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
switch (propsParam.type) {
|
|
40
|
+
case 'Identifier':
|
|
41
|
+
return propsParam.name;
|
|
42
|
+
case 'ObjectPattern':
|
|
43
|
+
case 'ArrayPattern':
|
|
44
|
+
return sliceWithoutType(code, propsParam);
|
|
45
|
+
default:
|
|
46
|
+
return 'props';
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const collectComponentMounts = (
|
|
51
|
+
ast: File,
|
|
52
|
+
code: string
|
|
53
|
+
): MountInfo[] => {
|
|
54
|
+
const results: MountInfo[] = [];
|
|
55
|
+
|
|
56
|
+
const inspectDeclaration = (node: any) => {
|
|
57
|
+
if (node.type !== 'VariableDeclaration' || node.declarations.length !== 1) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const declaration = node.declarations[0];
|
|
62
|
+
if (
|
|
63
|
+
!declaration.init ||
|
|
64
|
+
!isMountCall(declaration.init) ||
|
|
65
|
+
declaration.init.arguments.length === 0
|
|
66
|
+
) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const firstArg = declaration.init.arguments[0];
|
|
71
|
+
if (
|
|
72
|
+
firstArg.type !== 'ArrowFunctionExpression' ||
|
|
73
|
+
firstArg.body.type !== 'BlockStatement'
|
|
74
|
+
) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const block = firstArg.body;
|
|
79
|
+
const propsParam = firstArg.params[1] as
|
|
80
|
+
| Identifier
|
|
81
|
+
| ObjectPattern
|
|
82
|
+
| ArrayPattern
|
|
83
|
+
| undefined;
|
|
84
|
+
const propsExpression = extractPropsExpression(propsParam, code);
|
|
85
|
+
const insertPos =
|
|
86
|
+
block.body.length > 0
|
|
87
|
+
? (block.body[0].start ?? block.start ?? 0)
|
|
88
|
+
: (block.start ?? 0);
|
|
89
|
+
const componentName =
|
|
90
|
+
declaration.id && declaration.id.type === 'Identifier'
|
|
91
|
+
? declaration.id.name
|
|
92
|
+
: null;
|
|
93
|
+
const declarationEnd = node.end ?? block.end ?? insertPos;
|
|
94
|
+
|
|
95
|
+
results.push({
|
|
96
|
+
insertPos,
|
|
97
|
+
propsExpression,
|
|
98
|
+
componentName,
|
|
99
|
+
declarationEnd,
|
|
100
|
+
});
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
for (const statement of ast.program.body) {
|
|
104
|
+
if (statement.type === 'VariableDeclaration') {
|
|
105
|
+
inspectDeclaration(statement);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (
|
|
110
|
+
statement.type === 'ExportNamedDeclaration' &&
|
|
111
|
+
statement.declaration &&
|
|
112
|
+
statement.declaration.type === 'VariableDeclaration'
|
|
113
|
+
) {
|
|
114
|
+
inspectDeclaration(statement.declaration);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return results;
|
|
119
|
+
};
|