@bromscandium/runtime 1.0.0 → 1.0.2
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/LICENSE +20 -20
- package/README.md +91 -91
- package/package.json +49 -49
- package/src/index.ts +56 -56
- package/src/jsx-runtime.ts +132 -132
- package/src/jsx.d.ts +373 -373
- package/src/lifecycle.ts +133 -133
- package/src/renderer.ts +655 -655
- package/src/vnode.ts +159 -159
package/src/renderer.ts
CHANGED
|
@@ -1,655 +1,655 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* DOM Renderer with virtual DOM reconciliation and diffing.
|
|
3
|
-
* @module
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { VNode, Fragment, Text, ComponentFunction, ComponentInstance } from './vnode.js';
|
|
7
|
-
import { effect, cleanup, EffectFn } from '@bromscandium/core';
|
|
8
|
-
import { setCurrentInstance, invokeLifecycleHooks } from './lifecycle.js';
|
|
9
|
-
|
|
10
|
-
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
11
|
-
|
|
12
|
-
const SVG_TAGS = new Set([
|
|
13
|
-
'svg', 'path', 'circle', 'ellipse', 'line', 'polygon', 'polyline', 'rect',
|
|
14
|
-
'g', 'defs', 'symbol', 'use', 'image', 'text', 'tspan', 'textPath',
|
|
15
|
-
'clipPath', 'mask', 'pattern', 'marker', 'linearGradient', 'radialGradient',
|
|
16
|
-
'stop', 'filter', 'feBlend', 'feColorMatrix', 'feComponentTransfer',
|
|
17
|
-
'feComposite', 'feConvolveMatrix', 'feDiffuseLighting', 'feDisplacementMap',
|
|
18
|
-
'feFlood', 'feGaussianBlur', 'feImage', 'feMerge', 'feMergeNode',
|
|
19
|
-
'feMorphology', 'feOffset', 'feSpecularLighting', 'feTile', 'feTurbulence',
|
|
20
|
-
'foreignObject', 'animate', 'animateMotion', 'animateTransform', 'set'
|
|
21
|
-
]);
|
|
22
|
-
|
|
23
|
-
function camelToKebab(str: string): string {
|
|
24
|
-
return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
const componentMap = new WeakMap<VNode, ComponentInstance>();
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Renders a virtual DOM tree into a container element.
|
|
31
|
-
* Handles mounting, updating, and unmounting based on the previous state.
|
|
32
|
-
*
|
|
33
|
-
* @param vnode - The virtual node to render, or null to unmount
|
|
34
|
-
* @param container - The DOM element to render into
|
|
35
|
-
*
|
|
36
|
-
* @example
|
|
37
|
-
* ```ts
|
|
38
|
-
* const vnode = h('div', { className: 'app' }, 'Hello');
|
|
39
|
-
* render(vnode, document.getElementById('root')!);
|
|
40
|
-
*
|
|
41
|
-
* // Update
|
|
42
|
-
* render(h('div', { className: 'app' }, 'Updated'), container);
|
|
43
|
-
*
|
|
44
|
-
* // Unmount
|
|
45
|
-
* render(null, container);
|
|
46
|
-
* ```
|
|
47
|
-
*/
|
|
48
|
-
export function render(vnode: VNode | null, container: Element): void {
|
|
49
|
-
const oldVNode = (container as any)._vnode as VNode | null;
|
|
50
|
-
|
|
51
|
-
if (vnode == null) {
|
|
52
|
-
if (oldVNode) {
|
|
53
|
-
unmount(oldVNode);
|
|
54
|
-
}
|
|
55
|
-
} else if (oldVNode) {
|
|
56
|
-
patch(oldVNode, vnode, container, null);
|
|
57
|
-
} else {
|
|
58
|
-
mount(vnode, container, null);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
(container as any)._vnode = vnode;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function mount(
|
|
65
|
-
vnode: VNode,
|
|
66
|
-
container: Element,
|
|
67
|
-
anchor: Node | null,
|
|
68
|
-
isSVG: boolean = false
|
|
69
|
-
): void {
|
|
70
|
-
const { type } = vnode;
|
|
71
|
-
|
|
72
|
-
if (type === Fragment) {
|
|
73
|
-
mountFragment(vnode, container, anchor, isSVG);
|
|
74
|
-
return;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
if (type === Text) {
|
|
78
|
-
mountText(vnode, container, anchor);
|
|
79
|
-
return;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
if (typeof type === 'function') {
|
|
83
|
-
mountComponent(vnode, container, anchor, isSVG);
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
mountElement(vnode, container, anchor, isSVG);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function mountElement(
|
|
91
|
-
vnode: VNode,
|
|
92
|
-
container: Element,
|
|
93
|
-
anchor: Node | null,
|
|
94
|
-
isSVG: boolean = false
|
|
95
|
-
): void {
|
|
96
|
-
const { type, props, children } = vnode;
|
|
97
|
-
const tagName = type as string;
|
|
98
|
-
|
|
99
|
-
const isCurrentSVG = isSVG || SVG_TAGS.has(tagName);
|
|
100
|
-
|
|
101
|
-
const el = isCurrentSVG
|
|
102
|
-
? document.createElementNS(SVG_NS, tagName)
|
|
103
|
-
: document.createElement(tagName);
|
|
104
|
-
vnode.el = el;
|
|
105
|
-
|
|
106
|
-
for (const [key, value] of Object.entries(props)) {
|
|
107
|
-
if (key === 'key' || key === 'ref') continue;
|
|
108
|
-
setProp(el, key, value, null, isCurrentSVG);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
children.forEach(child => {
|
|
112
|
-
if (child == null) return;
|
|
113
|
-
|
|
114
|
-
if (typeof child === 'object') {
|
|
115
|
-
mount(child as VNode, el, null, isCurrentSVG);
|
|
116
|
-
} else {
|
|
117
|
-
el.appendChild(document.createTextNode(String(child)));
|
|
118
|
-
}
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
container.insertBefore(el, anchor);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function mountText(
|
|
125
|
-
vnode: VNode,
|
|
126
|
-
container: Element,
|
|
127
|
-
anchor: Node | null
|
|
128
|
-
): void {
|
|
129
|
-
const textContent = vnode.children[0] as string;
|
|
130
|
-
const el = document.createTextNode(textContent);
|
|
131
|
-
vnode.el = el;
|
|
132
|
-
container.insertBefore(el, anchor);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function mountFragment(
|
|
136
|
-
vnode: VNode,
|
|
137
|
-
container: Element,
|
|
138
|
-
anchor: Node | null,
|
|
139
|
-
isSVG: boolean = false
|
|
140
|
-
): void {
|
|
141
|
-
const fragmentAnchor = document.createComment('');
|
|
142
|
-
container.insertBefore(fragmentAnchor, anchor);
|
|
143
|
-
vnode.anchor = fragmentAnchor;
|
|
144
|
-
|
|
145
|
-
vnode.children.forEach(child => {
|
|
146
|
-
if (child == null) return;
|
|
147
|
-
|
|
148
|
-
if (typeof child === 'object') {
|
|
149
|
-
mount(child as VNode, container, fragmentAnchor, isSVG);
|
|
150
|
-
} else {
|
|
151
|
-
const textNode = document.createTextNode(String(child));
|
|
152
|
-
container.insertBefore(textNode, fragmentAnchor);
|
|
153
|
-
}
|
|
154
|
-
});
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
function mountComponent(
|
|
158
|
-
vnode: VNode,
|
|
159
|
-
container: Element,
|
|
160
|
-
anchor: Node | null,
|
|
161
|
-
isSVG: boolean = false
|
|
162
|
-
): void {
|
|
163
|
-
const component = vnode.type as ComponentFunction;
|
|
164
|
-
|
|
165
|
-
const propsWithChildren = {
|
|
166
|
-
...vnode.props,
|
|
167
|
-
children: vnode.children.length > 0 ? vnode.children : undefined,
|
|
168
|
-
};
|
|
169
|
-
|
|
170
|
-
const instance: ComponentInstance = {
|
|
171
|
-
vnode,
|
|
172
|
-
props: propsWithChildren,
|
|
173
|
-
subTree: null,
|
|
174
|
-
isMounted: false,
|
|
175
|
-
update: null,
|
|
176
|
-
mounted: [],
|
|
177
|
-
unmounted: [],
|
|
178
|
-
updated: [],
|
|
179
|
-
hooks: [],
|
|
180
|
-
hookIndex: 0,
|
|
181
|
-
};
|
|
182
|
-
|
|
183
|
-
vnode.component = instance;
|
|
184
|
-
componentMap.set(vnode, instance);
|
|
185
|
-
|
|
186
|
-
const updateFn = () => {
|
|
187
|
-
setCurrentInstance(instance);
|
|
188
|
-
instance.hookIndex = 0;
|
|
189
|
-
|
|
190
|
-
try {
|
|
191
|
-
const subTree = component(instance.props);
|
|
192
|
-
|
|
193
|
-
if (!instance.isMounted) {
|
|
194
|
-
if (subTree) {
|
|
195
|
-
mount(subTree, container, anchor, isSVG);
|
|
196
|
-
instance.subTree = subTree;
|
|
197
|
-
}
|
|
198
|
-
instance.isMounted = true;
|
|
199
|
-
|
|
200
|
-
queueMicrotask(() => {
|
|
201
|
-
invokeLifecycleHooks(instance.mounted, 'onMounted');
|
|
202
|
-
});
|
|
203
|
-
} else {
|
|
204
|
-
if (instance.subTree && subTree) {
|
|
205
|
-
patch(instance.subTree, subTree, container, anchor, isSVG);
|
|
206
|
-
} else if (subTree) {
|
|
207
|
-
mount(subTree, container, anchor, isSVG);
|
|
208
|
-
} else if (instance.subTree) {
|
|
209
|
-
unmount(instance.subTree);
|
|
210
|
-
}
|
|
211
|
-
instance.subTree = subTree;
|
|
212
|
-
|
|
213
|
-
queueMicrotask(() => {
|
|
214
|
-
invokeLifecycleHooks(instance.updated, 'onUpdated');
|
|
215
|
-
});
|
|
216
|
-
}
|
|
217
|
-
} finally {
|
|
218
|
-
setCurrentInstance(null);
|
|
219
|
-
}
|
|
220
|
-
};
|
|
221
|
-
|
|
222
|
-
instance.update = effect(updateFn) as unknown as () => void;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
function patch(
|
|
226
|
-
oldVNode: VNode,
|
|
227
|
-
newVNode: VNode,
|
|
228
|
-
container: Element,
|
|
229
|
-
anchor: Node | null,
|
|
230
|
-
isSVG: boolean = false
|
|
231
|
-
): void {
|
|
232
|
-
if (oldVNode.type !== newVNode.type) {
|
|
233
|
-
unmount(oldVNode);
|
|
234
|
-
mount(newVNode, container, anchor, isSVG);
|
|
235
|
-
return;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
newVNode.el = oldVNode.el;
|
|
239
|
-
|
|
240
|
-
if (newVNode.type === Text) {
|
|
241
|
-
patchText(oldVNode, newVNode);
|
|
242
|
-
return;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
if (newVNode.type === Fragment) {
|
|
246
|
-
patchFragment(oldVNode, newVNode, container, isSVG);
|
|
247
|
-
return;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
if (typeof newVNode.type === 'function') {
|
|
251
|
-
patchComponent(oldVNode, newVNode, isSVG);
|
|
252
|
-
return;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
const tagName = newVNode.type as string;
|
|
256
|
-
const isCurrentSVG = isSVG || SVG_TAGS.has(tagName);
|
|
257
|
-
patchElement(oldVNode, newVNode, isCurrentSVG);
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
function patchElement(oldVNode: VNode, newVNode: VNode, isSVG: boolean = false): void {
|
|
261
|
-
const el = (newVNode.el = oldVNode.el) as Element;
|
|
262
|
-
|
|
263
|
-
const oldProps = oldVNode.props;
|
|
264
|
-
const newProps = newVNode.props;
|
|
265
|
-
|
|
266
|
-
for (const key of Object.keys(newProps)) {
|
|
267
|
-
if (key === 'key' || key === 'ref') continue;
|
|
268
|
-
if (oldProps[key] !== newProps[key]) {
|
|
269
|
-
setProp(el, key, newProps[key], oldProps[key], isSVG);
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
for (const key of Object.keys(oldProps)) {
|
|
274
|
-
if (!(key in newProps)) {
|
|
275
|
-
setProp(el, key, null, oldProps[key], isSVG);
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
patchChildren(oldVNode, newVNode, el, isSVG);
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
function patchText(oldVNode: VNode, newVNode: VNode): void {
|
|
283
|
-
const el = (newVNode.el = oldVNode.el) as Text;
|
|
284
|
-
const oldText = oldVNode.children[0] as string;
|
|
285
|
-
const newText = newVNode.children[0] as string;
|
|
286
|
-
|
|
287
|
-
if (oldText !== newText) {
|
|
288
|
-
el.textContent = newText;
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
function patchFragment(
|
|
293
|
-
oldVNode: VNode,
|
|
294
|
-
newVNode: VNode,
|
|
295
|
-
container: Element,
|
|
296
|
-
isSVG: boolean = false
|
|
297
|
-
): void {
|
|
298
|
-
newVNode.anchor = oldVNode.anchor;
|
|
299
|
-
patchChildren(oldVNode, newVNode, container, isSVG);
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
function patchComponent(oldVNode: VNode, newVNode: VNode, _isSVG: boolean = false): void {
|
|
303
|
-
const instance = oldVNode.component!;
|
|
304
|
-
newVNode.component = instance;
|
|
305
|
-
instance.vnode = newVNode;
|
|
306
|
-
|
|
307
|
-
const oldPropsWithChildren: Record<string, any> = {
|
|
308
|
-
...oldVNode.props,
|
|
309
|
-
children: oldVNode.children.length > 0 ? oldVNode.children : undefined,
|
|
310
|
-
};
|
|
311
|
-
const newPropsWithChildren: Record<string, any> = {
|
|
312
|
-
...newVNode.props,
|
|
313
|
-
children: newVNode.children.length > 0 ? newVNode.children : undefined,
|
|
314
|
-
};
|
|
315
|
-
|
|
316
|
-
let hasChanged = false;
|
|
317
|
-
|
|
318
|
-
for (const key of Object.keys(newPropsWithChildren)) {
|
|
319
|
-
if (oldPropsWithChildren[key] !== newPropsWithChildren[key]) {
|
|
320
|
-
hasChanged = true;
|
|
321
|
-
break;
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
if (!hasChanged) {
|
|
326
|
-
for (const key of Object.keys(oldPropsWithChildren)) {
|
|
327
|
-
if (!(key in newPropsWithChildren)) {
|
|
328
|
-
hasChanged = true;
|
|
329
|
-
break;
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
if (hasChanged) {
|
|
335
|
-
instance.props = newPropsWithChildren;
|
|
336
|
-
instance.update?.();
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
function patchChildren(
|
|
341
|
-
oldVNode: VNode,
|
|
342
|
-
newVNode: VNode,
|
|
343
|
-
container: Element,
|
|
344
|
-
isSVG: boolean = false
|
|
345
|
-
): void {
|
|
346
|
-
const oldChildren = oldVNode.children;
|
|
347
|
-
const newChildren = newVNode.children;
|
|
348
|
-
const anchor = newVNode.anchor || null;
|
|
349
|
-
|
|
350
|
-
if (oldChildren.length === 1 && newChildren.length === 1 &&
|
|
351
|
-
typeof oldChildren[0] !== 'object' && typeof newChildren[0] !== 'object') {
|
|
352
|
-
const oldText = String(oldChildren[0] ?? '');
|
|
353
|
-
const newText = String(newChildren[0] ?? '');
|
|
354
|
-
if (oldText !== newText) {
|
|
355
|
-
const textNode = Array.from(container.childNodes).find(
|
|
356
|
-
n => n.nodeType === Node.TEXT_NODE
|
|
357
|
-
);
|
|
358
|
-
if (textNode) {
|
|
359
|
-
textNode.textContent = newText;
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
return;
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
const hasOnlyVNodeChildren = oldChildren.every(c => c == null || typeof c === 'object') &&
|
|
366
|
-
newChildren.every(c => c == null || typeof c === 'object');
|
|
367
|
-
|
|
368
|
-
if (!hasOnlyVNodeChildren) {
|
|
369
|
-
oldChildren.forEach((child) => {
|
|
370
|
-
if (child && typeof child === 'object') {
|
|
371
|
-
unmount(child as VNode);
|
|
372
|
-
}
|
|
373
|
-
});
|
|
374
|
-
|
|
375
|
-
while (container.firstChild && container.firstChild !== anchor) {
|
|
376
|
-
container.removeChild(container.firstChild);
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
newChildren.forEach((child) => {
|
|
380
|
-
if (child == null) return;
|
|
381
|
-
|
|
382
|
-
if (typeof child === 'object') {
|
|
383
|
-
mount(child as VNode, container, anchor, isSVG);
|
|
384
|
-
} else {
|
|
385
|
-
const textNode = document.createTextNode(String(child));
|
|
386
|
-
container.insertBefore(textNode, anchor);
|
|
387
|
-
}
|
|
388
|
-
});
|
|
389
|
-
return;
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
const oldKeyed = new Map<string | number, VNode>();
|
|
393
|
-
const oldUnkeyed: VNode[] = [];
|
|
394
|
-
|
|
395
|
-
oldChildren.forEach((child) => {
|
|
396
|
-
if (child && typeof child === 'object') {
|
|
397
|
-
const vnode = child as VNode;
|
|
398
|
-
if (vnode.key != null) {
|
|
399
|
-
oldKeyed.set(vnode.key, vnode);
|
|
400
|
-
} else {
|
|
401
|
-
oldUnkeyed.push(vnode);
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
});
|
|
405
|
-
|
|
406
|
-
let unkeyedIndex = 0;
|
|
407
|
-
|
|
408
|
-
newChildren.forEach((child) => {
|
|
409
|
-
if (child == null) return;
|
|
410
|
-
|
|
411
|
-
if (typeof child === 'object') {
|
|
412
|
-
const newChild = child as VNode;
|
|
413
|
-
let oldChild: VNode | undefined;
|
|
414
|
-
|
|
415
|
-
if (newChild.key != null) {
|
|
416
|
-
oldChild = oldKeyed.get(newChild.key);
|
|
417
|
-
if (oldChild) {
|
|
418
|
-
oldKeyed.delete(newChild.key);
|
|
419
|
-
}
|
|
420
|
-
} else {
|
|
421
|
-
oldChild = oldUnkeyed[unkeyedIndex++];
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
if (oldChild) {
|
|
425
|
-
patch(oldChild, newChild, container, anchor, isSVG);
|
|
426
|
-
} else {
|
|
427
|
-
mount(newChild, container, anchor, isSVG);
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
});
|
|
431
|
-
|
|
432
|
-
oldKeyed.forEach(child => unmount(child));
|
|
433
|
-
|
|
434
|
-
for (let i = unkeyedIndex; i < oldUnkeyed.length; i++) {
|
|
435
|
-
unmount(oldUnkeyed[i]);
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
function setProp(
|
|
440
|
-
el: Element,
|
|
441
|
-
key: string,
|
|
442
|
-
newValue: any,
|
|
443
|
-
oldValue: any,
|
|
444
|
-
isSVG: boolean = false
|
|
445
|
-
): void {
|
|
446
|
-
if (key.startsWith('on')) {
|
|
447
|
-
const event = key.slice(2).toLowerCase();
|
|
448
|
-
|
|
449
|
-
if (oldValue) {
|
|
450
|
-
el.removeEventListener(event, oldValue);
|
|
451
|
-
}
|
|
452
|
-
if (newValue) {
|
|
453
|
-
el.addEventListener(event, newValue);
|
|
454
|
-
}
|
|
455
|
-
} else if (key === 'className') {
|
|
456
|
-
if (isSVG) {
|
|
457
|
-
if (newValue) {
|
|
458
|
-
el.setAttribute('class', newValue);
|
|
459
|
-
} else {
|
|
460
|
-
el.removeAttribute('class');
|
|
461
|
-
}
|
|
462
|
-
} else {
|
|
463
|
-
if (newValue) {
|
|
464
|
-
el.setAttribute('class', newValue);
|
|
465
|
-
} else {
|
|
466
|
-
el.removeAttribute('class');
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
} else if (key === 'style') {
|
|
470
|
-
if (typeof newValue === 'object' && newValue !== null) {
|
|
471
|
-
const style = (el as HTMLElement).style;
|
|
472
|
-
if (typeof oldValue === 'object' && oldValue !== null) {
|
|
473
|
-
for (const prop of Object.keys(oldValue)) {
|
|
474
|
-
if (!(prop in newValue)) {
|
|
475
|
-
style.setProperty(prop, '');
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
for (const [prop, value] of Object.entries(newValue)) {
|
|
480
|
-
style.setProperty(prop, String(value));
|
|
481
|
-
}
|
|
482
|
-
} else if (typeof newValue === 'string') {
|
|
483
|
-
(el as HTMLElement).style.cssText = newValue;
|
|
484
|
-
} else {
|
|
485
|
-
el.removeAttribute('style');
|
|
486
|
-
}
|
|
487
|
-
} else if (key === 'dangerouslySetInnerHTML') {
|
|
488
|
-
if (newValue?.__html != null) {
|
|
489
|
-
el.innerHTML = newValue.__html;
|
|
490
|
-
}
|
|
491
|
-
} else if (!isSVG && key === 'value' && (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement)) {
|
|
492
|
-
const newVal = newValue ?? '';
|
|
493
|
-
if (el.value !== newVal) {
|
|
494
|
-
el.value = newVal;
|
|
495
|
-
}
|
|
496
|
-
} else if (!isSVG && key === 'checked' && el instanceof HTMLInputElement) {
|
|
497
|
-
el.checked = !!newValue;
|
|
498
|
-
} else if (key === 'disabled') {
|
|
499
|
-
if (newValue) {
|
|
500
|
-
el.setAttribute('disabled', '');
|
|
501
|
-
} else {
|
|
502
|
-
el.removeAttribute('disabled');
|
|
503
|
-
}
|
|
504
|
-
} else if (newValue == null || newValue === false) {
|
|
505
|
-
const attrName = isSVG ? camelToKebab(key) : key;
|
|
506
|
-
el.removeAttribute(attrName);
|
|
507
|
-
} else if (newValue === true) {
|
|
508
|
-
const attrName = isSVG ? camelToKebab(key) : key;
|
|
509
|
-
el.setAttribute(attrName, '');
|
|
510
|
-
} else {
|
|
511
|
-
const attrName = isSVG ? camelToKebab(key) : key;
|
|
512
|
-
el.setAttribute(attrName, String(newValue));
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
function unmount(vnode: VNode): void {
|
|
517
|
-
const { type, el, component, children, anchor } = vnode;
|
|
518
|
-
|
|
519
|
-
if (typeof type === 'function' && component) {
|
|
520
|
-
invokeLifecycleHooks(component.unmounted, 'onUnmounted');
|
|
521
|
-
|
|
522
|
-
if (component.subTree) {
|
|
523
|
-
unmount(component.subTree);
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
if (component.update) {
|
|
527
|
-
cleanup(component.update as unknown as EffectFn);
|
|
528
|
-
}
|
|
529
|
-
return;
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
if (type === Fragment) {
|
|
533
|
-
children.forEach(child => {
|
|
534
|
-
if (child && typeof child === 'object') {
|
|
535
|
-
unmount(child as VNode);
|
|
536
|
-
}
|
|
537
|
-
});
|
|
538
|
-
anchor?.parentNode?.removeChild(anchor);
|
|
539
|
-
return;
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
children.forEach(child => {
|
|
543
|
-
if (child && typeof child === 'object') {
|
|
544
|
-
unmount(child as VNode);
|
|
545
|
-
}
|
|
546
|
-
});
|
|
547
|
-
|
|
548
|
-
if (el?.parentNode) {
|
|
549
|
-
el.parentNode.removeChild(el);
|
|
550
|
-
}
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
/**
|
|
554
|
-
* Configuration options for creating a Bromium application.
|
|
555
|
-
*/
|
|
556
|
-
export interface AppConfig {
|
|
557
|
-
/** Path to the favicon image */
|
|
558
|
-
favicon?: string;
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
function setupFavicon(faviconPath: string) {
|
|
562
|
-
let baseUrl = (import.meta as any).env?.BASE_URL || '/';
|
|
563
|
-
|
|
564
|
-
if (faviconPath.startsWith('/') || faviconPath.startsWith('http')) {
|
|
565
|
-
baseUrl = '';
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
const fullPath = baseUrl + faviconPath;
|
|
569
|
-
|
|
570
|
-
let favicon = document.querySelector("link[rel='icon']") as HTMLLinkElement;
|
|
571
|
-
|
|
572
|
-
if (!favicon) {
|
|
573
|
-
favicon = document.createElement('link');
|
|
574
|
-
favicon.rel = 'icon';
|
|
575
|
-
document.head.appendChild(favicon);
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
favicon.href = fullPath;
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
/**
|
|
582
|
-
* Creates a new Bromium application instance.
|
|
583
|
-
*
|
|
584
|
-
* @param rootComponent - The root component function to render
|
|
585
|
-
* @param config - Optional application configuration
|
|
586
|
-
* @returns An app instance with mount and unmount methods
|
|
587
|
-
*
|
|
588
|
-
* @example
|
|
589
|
-
* ```ts
|
|
590
|
-
* function App() {
|
|
591
|
-
* return <div>Hello, Bromium!</div>;
|
|
592
|
-
* }
|
|
593
|
-
*
|
|
594
|
-
* const app = createApp(App, { favicon: '/icon.png' });
|
|
595
|
-
* app.mount('#app');
|
|
596
|
-
*
|
|
597
|
-
* // Later: app.unmount();
|
|
598
|
-
* ```
|
|
599
|
-
*/
|
|
600
|
-
export function createApp(rootComponent: ComponentFunction, config?: AppConfig) {
|
|
601
|
-
let isMounted = false;
|
|
602
|
-
let rootVNode: VNode | null = null;
|
|
603
|
-
let container: Element | null = null;
|
|
604
|
-
|
|
605
|
-
if (config?.favicon) {
|
|
606
|
-
setupFavicon(config.favicon);
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
const app = {
|
|
610
|
-
/**
|
|
611
|
-
* Mounts the application to a DOM element.
|
|
612
|
-
*
|
|
613
|
-
* @param selector - A CSS selector string or DOM Element to mount to
|
|
614
|
-
* @returns The app instance for chaining
|
|
615
|
-
*/
|
|
616
|
-
mount(selector: string | Element) {
|
|
617
|
-
container =
|
|
618
|
-
typeof selector === 'string'
|
|
619
|
-
? document.querySelector(selector)
|
|
620
|
-
: selector;
|
|
621
|
-
|
|
622
|
-
if (!container) {
|
|
623
|
-
throw new Error(`Mount target "${selector}" not found`);
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
if (!isMounted) {
|
|
627
|
-
rootVNode = {
|
|
628
|
-
type: rootComponent,
|
|
629
|
-
props: {},
|
|
630
|
-
children: [],
|
|
631
|
-
key: null,
|
|
632
|
-
el: null,
|
|
633
|
-
};
|
|
634
|
-
|
|
635
|
-
render(rootVNode, container);
|
|
636
|
-
isMounted = true;
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
return app;
|
|
640
|
-
},
|
|
641
|
-
|
|
642
|
-
/**
|
|
643
|
-
* Unmounts the application and cleans up resources.
|
|
644
|
-
*/
|
|
645
|
-
unmount() {
|
|
646
|
-
if (isMounted && container) {
|
|
647
|
-
render(null, container);
|
|
648
|
-
isMounted = false;
|
|
649
|
-
rootVNode = null;
|
|
650
|
-
}
|
|
651
|
-
},
|
|
652
|
-
};
|
|
653
|
-
|
|
654
|
-
return app;
|
|
655
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* DOM Renderer with virtual DOM reconciliation and diffing.
|
|
3
|
+
* @module
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { VNode, Fragment, Text, ComponentFunction, ComponentInstance } from './vnode.js';
|
|
7
|
+
import { effect, cleanup, EffectFn } from '@bromscandium/core';
|
|
8
|
+
import { setCurrentInstance, invokeLifecycleHooks } from './lifecycle.js';
|
|
9
|
+
|
|
10
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
11
|
+
|
|
12
|
+
const SVG_TAGS = new Set([
|
|
13
|
+
'svg', 'path', 'circle', 'ellipse', 'line', 'polygon', 'polyline', 'rect',
|
|
14
|
+
'g', 'defs', 'symbol', 'use', 'image', 'text', 'tspan', 'textPath',
|
|
15
|
+
'clipPath', 'mask', 'pattern', 'marker', 'linearGradient', 'radialGradient',
|
|
16
|
+
'stop', 'filter', 'feBlend', 'feColorMatrix', 'feComponentTransfer',
|
|
17
|
+
'feComposite', 'feConvolveMatrix', 'feDiffuseLighting', 'feDisplacementMap',
|
|
18
|
+
'feFlood', 'feGaussianBlur', 'feImage', 'feMerge', 'feMergeNode',
|
|
19
|
+
'feMorphology', 'feOffset', 'feSpecularLighting', 'feTile', 'feTurbulence',
|
|
20
|
+
'foreignObject', 'animate', 'animateMotion', 'animateTransform', 'set'
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
function camelToKebab(str: string): string {
|
|
24
|
+
return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const componentMap = new WeakMap<VNode, ComponentInstance>();
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Renders a virtual DOM tree into a container element.
|
|
31
|
+
* Handles mounting, updating, and unmounting based on the previous state.
|
|
32
|
+
*
|
|
33
|
+
* @param vnode - The virtual node to render, or null to unmount
|
|
34
|
+
* @param container - The DOM element to render into
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* ```ts
|
|
38
|
+
* const vnode = h('div', { className: 'app' }, 'Hello');
|
|
39
|
+
* render(vnode, document.getElementById('root')!);
|
|
40
|
+
*
|
|
41
|
+
* // Update
|
|
42
|
+
* render(h('div', { className: 'app' }, 'Updated'), container);
|
|
43
|
+
*
|
|
44
|
+
* // Unmount
|
|
45
|
+
* render(null, container);
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export function render(vnode: VNode | null, container: Element): void {
|
|
49
|
+
const oldVNode = (container as any)._vnode as VNode | null;
|
|
50
|
+
|
|
51
|
+
if (vnode == null) {
|
|
52
|
+
if (oldVNode) {
|
|
53
|
+
unmount(oldVNode);
|
|
54
|
+
}
|
|
55
|
+
} else if (oldVNode) {
|
|
56
|
+
patch(oldVNode, vnode, container, null);
|
|
57
|
+
} else {
|
|
58
|
+
mount(vnode, container, null);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
(container as any)._vnode = vnode;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function mount(
|
|
65
|
+
vnode: VNode,
|
|
66
|
+
container: Element,
|
|
67
|
+
anchor: Node | null,
|
|
68
|
+
isSVG: boolean = false
|
|
69
|
+
): void {
|
|
70
|
+
const { type } = vnode;
|
|
71
|
+
|
|
72
|
+
if (type === Fragment) {
|
|
73
|
+
mountFragment(vnode, container, anchor, isSVG);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (type === Text) {
|
|
78
|
+
mountText(vnode, container, anchor);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (typeof type === 'function') {
|
|
83
|
+
mountComponent(vnode, container, anchor, isSVG);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
mountElement(vnode, container, anchor, isSVG);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function mountElement(
|
|
91
|
+
vnode: VNode,
|
|
92
|
+
container: Element,
|
|
93
|
+
anchor: Node | null,
|
|
94
|
+
isSVG: boolean = false
|
|
95
|
+
): void {
|
|
96
|
+
const { type, props, children } = vnode;
|
|
97
|
+
const tagName = type as string;
|
|
98
|
+
|
|
99
|
+
const isCurrentSVG = isSVG || SVG_TAGS.has(tagName);
|
|
100
|
+
|
|
101
|
+
const el = isCurrentSVG
|
|
102
|
+
? document.createElementNS(SVG_NS, tagName)
|
|
103
|
+
: document.createElement(tagName);
|
|
104
|
+
vnode.el = el;
|
|
105
|
+
|
|
106
|
+
for (const [key, value] of Object.entries(props)) {
|
|
107
|
+
if (key === 'key' || key === 'ref') continue;
|
|
108
|
+
setProp(el, key, value, null, isCurrentSVG);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
children.forEach(child => {
|
|
112
|
+
if (child == null) return;
|
|
113
|
+
|
|
114
|
+
if (typeof child === 'object') {
|
|
115
|
+
mount(child as VNode, el, null, isCurrentSVG);
|
|
116
|
+
} else {
|
|
117
|
+
el.appendChild(document.createTextNode(String(child)));
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
container.insertBefore(el, anchor);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function mountText(
|
|
125
|
+
vnode: VNode,
|
|
126
|
+
container: Element,
|
|
127
|
+
anchor: Node | null
|
|
128
|
+
): void {
|
|
129
|
+
const textContent = vnode.children[0] as string;
|
|
130
|
+
const el = document.createTextNode(textContent);
|
|
131
|
+
vnode.el = el;
|
|
132
|
+
container.insertBefore(el, anchor);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function mountFragment(
|
|
136
|
+
vnode: VNode,
|
|
137
|
+
container: Element,
|
|
138
|
+
anchor: Node | null,
|
|
139
|
+
isSVG: boolean = false
|
|
140
|
+
): void {
|
|
141
|
+
const fragmentAnchor = document.createComment('');
|
|
142
|
+
container.insertBefore(fragmentAnchor, anchor);
|
|
143
|
+
vnode.anchor = fragmentAnchor;
|
|
144
|
+
|
|
145
|
+
vnode.children.forEach(child => {
|
|
146
|
+
if (child == null) return;
|
|
147
|
+
|
|
148
|
+
if (typeof child === 'object') {
|
|
149
|
+
mount(child as VNode, container, fragmentAnchor, isSVG);
|
|
150
|
+
} else {
|
|
151
|
+
const textNode = document.createTextNode(String(child));
|
|
152
|
+
container.insertBefore(textNode, fragmentAnchor);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function mountComponent(
|
|
158
|
+
vnode: VNode,
|
|
159
|
+
container: Element,
|
|
160
|
+
anchor: Node | null,
|
|
161
|
+
isSVG: boolean = false
|
|
162
|
+
): void {
|
|
163
|
+
const component = vnode.type as ComponentFunction;
|
|
164
|
+
|
|
165
|
+
const propsWithChildren = {
|
|
166
|
+
...vnode.props,
|
|
167
|
+
children: vnode.children.length > 0 ? vnode.children : undefined,
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const instance: ComponentInstance = {
|
|
171
|
+
vnode,
|
|
172
|
+
props: propsWithChildren,
|
|
173
|
+
subTree: null,
|
|
174
|
+
isMounted: false,
|
|
175
|
+
update: null,
|
|
176
|
+
mounted: [],
|
|
177
|
+
unmounted: [],
|
|
178
|
+
updated: [],
|
|
179
|
+
hooks: [],
|
|
180
|
+
hookIndex: 0,
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
vnode.component = instance;
|
|
184
|
+
componentMap.set(vnode, instance);
|
|
185
|
+
|
|
186
|
+
const updateFn = () => {
|
|
187
|
+
setCurrentInstance(instance);
|
|
188
|
+
instance.hookIndex = 0;
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
const subTree = component(instance.props);
|
|
192
|
+
|
|
193
|
+
if (!instance.isMounted) {
|
|
194
|
+
if (subTree) {
|
|
195
|
+
mount(subTree, container, anchor, isSVG);
|
|
196
|
+
instance.subTree = subTree;
|
|
197
|
+
}
|
|
198
|
+
instance.isMounted = true;
|
|
199
|
+
|
|
200
|
+
queueMicrotask(() => {
|
|
201
|
+
invokeLifecycleHooks(instance.mounted, 'onMounted');
|
|
202
|
+
});
|
|
203
|
+
} else {
|
|
204
|
+
if (instance.subTree && subTree) {
|
|
205
|
+
patch(instance.subTree, subTree, container, anchor, isSVG);
|
|
206
|
+
} else if (subTree) {
|
|
207
|
+
mount(subTree, container, anchor, isSVG);
|
|
208
|
+
} else if (instance.subTree) {
|
|
209
|
+
unmount(instance.subTree);
|
|
210
|
+
}
|
|
211
|
+
instance.subTree = subTree;
|
|
212
|
+
|
|
213
|
+
queueMicrotask(() => {
|
|
214
|
+
invokeLifecycleHooks(instance.updated, 'onUpdated');
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
} finally {
|
|
218
|
+
setCurrentInstance(null);
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
instance.update = effect(updateFn) as unknown as () => void;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function patch(
|
|
226
|
+
oldVNode: VNode,
|
|
227
|
+
newVNode: VNode,
|
|
228
|
+
container: Element,
|
|
229
|
+
anchor: Node | null,
|
|
230
|
+
isSVG: boolean = false
|
|
231
|
+
): void {
|
|
232
|
+
if (oldVNode.type !== newVNode.type) {
|
|
233
|
+
unmount(oldVNode);
|
|
234
|
+
mount(newVNode, container, anchor, isSVG);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
newVNode.el = oldVNode.el;
|
|
239
|
+
|
|
240
|
+
if (newVNode.type === Text) {
|
|
241
|
+
patchText(oldVNode, newVNode);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (newVNode.type === Fragment) {
|
|
246
|
+
patchFragment(oldVNode, newVNode, container, isSVG);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (typeof newVNode.type === 'function') {
|
|
251
|
+
patchComponent(oldVNode, newVNode, isSVG);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const tagName = newVNode.type as string;
|
|
256
|
+
const isCurrentSVG = isSVG || SVG_TAGS.has(tagName);
|
|
257
|
+
patchElement(oldVNode, newVNode, isCurrentSVG);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function patchElement(oldVNode: VNode, newVNode: VNode, isSVG: boolean = false): void {
|
|
261
|
+
const el = (newVNode.el = oldVNode.el) as Element;
|
|
262
|
+
|
|
263
|
+
const oldProps = oldVNode.props;
|
|
264
|
+
const newProps = newVNode.props;
|
|
265
|
+
|
|
266
|
+
for (const key of Object.keys(newProps)) {
|
|
267
|
+
if (key === 'key' || key === 'ref') continue;
|
|
268
|
+
if (oldProps[key] !== newProps[key]) {
|
|
269
|
+
setProp(el, key, newProps[key], oldProps[key], isSVG);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
for (const key of Object.keys(oldProps)) {
|
|
274
|
+
if (!(key in newProps)) {
|
|
275
|
+
setProp(el, key, null, oldProps[key], isSVG);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
patchChildren(oldVNode, newVNode, el, isSVG);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function patchText(oldVNode: VNode, newVNode: VNode): void {
|
|
283
|
+
const el = (newVNode.el = oldVNode.el) as Text;
|
|
284
|
+
const oldText = oldVNode.children[0] as string;
|
|
285
|
+
const newText = newVNode.children[0] as string;
|
|
286
|
+
|
|
287
|
+
if (oldText !== newText) {
|
|
288
|
+
el.textContent = newText;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function patchFragment(
|
|
293
|
+
oldVNode: VNode,
|
|
294
|
+
newVNode: VNode,
|
|
295
|
+
container: Element,
|
|
296
|
+
isSVG: boolean = false
|
|
297
|
+
): void {
|
|
298
|
+
newVNode.anchor = oldVNode.anchor;
|
|
299
|
+
patchChildren(oldVNode, newVNode, container, isSVG);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function patchComponent(oldVNode: VNode, newVNode: VNode, _isSVG: boolean = false): void {
|
|
303
|
+
const instance = oldVNode.component!;
|
|
304
|
+
newVNode.component = instance;
|
|
305
|
+
instance.vnode = newVNode;
|
|
306
|
+
|
|
307
|
+
const oldPropsWithChildren: Record<string, any> = {
|
|
308
|
+
...oldVNode.props,
|
|
309
|
+
children: oldVNode.children.length > 0 ? oldVNode.children : undefined,
|
|
310
|
+
};
|
|
311
|
+
const newPropsWithChildren: Record<string, any> = {
|
|
312
|
+
...newVNode.props,
|
|
313
|
+
children: newVNode.children.length > 0 ? newVNode.children : undefined,
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
let hasChanged = false;
|
|
317
|
+
|
|
318
|
+
for (const key of Object.keys(newPropsWithChildren)) {
|
|
319
|
+
if (oldPropsWithChildren[key] !== newPropsWithChildren[key]) {
|
|
320
|
+
hasChanged = true;
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (!hasChanged) {
|
|
326
|
+
for (const key of Object.keys(oldPropsWithChildren)) {
|
|
327
|
+
if (!(key in newPropsWithChildren)) {
|
|
328
|
+
hasChanged = true;
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (hasChanged) {
|
|
335
|
+
instance.props = newPropsWithChildren;
|
|
336
|
+
instance.update?.();
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function patchChildren(
|
|
341
|
+
oldVNode: VNode,
|
|
342
|
+
newVNode: VNode,
|
|
343
|
+
container: Element,
|
|
344
|
+
isSVG: boolean = false
|
|
345
|
+
): void {
|
|
346
|
+
const oldChildren = oldVNode.children;
|
|
347
|
+
const newChildren = newVNode.children;
|
|
348
|
+
const anchor = newVNode.anchor || null;
|
|
349
|
+
|
|
350
|
+
if (oldChildren.length === 1 && newChildren.length === 1 &&
|
|
351
|
+
typeof oldChildren[0] !== 'object' && typeof newChildren[0] !== 'object') {
|
|
352
|
+
const oldText = String(oldChildren[0] ?? '');
|
|
353
|
+
const newText = String(newChildren[0] ?? '');
|
|
354
|
+
if (oldText !== newText) {
|
|
355
|
+
const textNode = Array.from(container.childNodes).find(
|
|
356
|
+
n => n.nodeType === Node.TEXT_NODE
|
|
357
|
+
);
|
|
358
|
+
if (textNode) {
|
|
359
|
+
textNode.textContent = newText;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const hasOnlyVNodeChildren = oldChildren.every(c => c == null || typeof c === 'object') &&
|
|
366
|
+
newChildren.every(c => c == null || typeof c === 'object');
|
|
367
|
+
|
|
368
|
+
if (!hasOnlyVNodeChildren) {
|
|
369
|
+
oldChildren.forEach((child) => {
|
|
370
|
+
if (child && typeof child === 'object') {
|
|
371
|
+
unmount(child as VNode);
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
while (container.firstChild && container.firstChild !== anchor) {
|
|
376
|
+
container.removeChild(container.firstChild);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
newChildren.forEach((child) => {
|
|
380
|
+
if (child == null) return;
|
|
381
|
+
|
|
382
|
+
if (typeof child === 'object') {
|
|
383
|
+
mount(child as VNode, container, anchor, isSVG);
|
|
384
|
+
} else {
|
|
385
|
+
const textNode = document.createTextNode(String(child));
|
|
386
|
+
container.insertBefore(textNode, anchor);
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const oldKeyed = new Map<string | number, VNode>();
|
|
393
|
+
const oldUnkeyed: VNode[] = [];
|
|
394
|
+
|
|
395
|
+
oldChildren.forEach((child) => {
|
|
396
|
+
if (child && typeof child === 'object') {
|
|
397
|
+
const vnode = child as VNode;
|
|
398
|
+
if (vnode.key != null) {
|
|
399
|
+
oldKeyed.set(vnode.key, vnode);
|
|
400
|
+
} else {
|
|
401
|
+
oldUnkeyed.push(vnode);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
let unkeyedIndex = 0;
|
|
407
|
+
|
|
408
|
+
newChildren.forEach((child) => {
|
|
409
|
+
if (child == null) return;
|
|
410
|
+
|
|
411
|
+
if (typeof child === 'object') {
|
|
412
|
+
const newChild = child as VNode;
|
|
413
|
+
let oldChild: VNode | undefined;
|
|
414
|
+
|
|
415
|
+
if (newChild.key != null) {
|
|
416
|
+
oldChild = oldKeyed.get(newChild.key);
|
|
417
|
+
if (oldChild) {
|
|
418
|
+
oldKeyed.delete(newChild.key);
|
|
419
|
+
}
|
|
420
|
+
} else {
|
|
421
|
+
oldChild = oldUnkeyed[unkeyedIndex++];
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (oldChild) {
|
|
425
|
+
patch(oldChild, newChild, container, anchor, isSVG);
|
|
426
|
+
} else {
|
|
427
|
+
mount(newChild, container, anchor, isSVG);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
oldKeyed.forEach(child => unmount(child));
|
|
433
|
+
|
|
434
|
+
for (let i = unkeyedIndex; i < oldUnkeyed.length; i++) {
|
|
435
|
+
unmount(oldUnkeyed[i]);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function setProp(
|
|
440
|
+
el: Element,
|
|
441
|
+
key: string,
|
|
442
|
+
newValue: any,
|
|
443
|
+
oldValue: any,
|
|
444
|
+
isSVG: boolean = false
|
|
445
|
+
): void {
|
|
446
|
+
if (key.startsWith('on')) {
|
|
447
|
+
const event = key.slice(2).toLowerCase();
|
|
448
|
+
|
|
449
|
+
if (oldValue) {
|
|
450
|
+
el.removeEventListener(event, oldValue);
|
|
451
|
+
}
|
|
452
|
+
if (newValue) {
|
|
453
|
+
el.addEventListener(event, newValue);
|
|
454
|
+
}
|
|
455
|
+
} else if (key === 'className') {
|
|
456
|
+
if (isSVG) {
|
|
457
|
+
if (newValue) {
|
|
458
|
+
el.setAttribute('class', newValue);
|
|
459
|
+
} else {
|
|
460
|
+
el.removeAttribute('class');
|
|
461
|
+
}
|
|
462
|
+
} else {
|
|
463
|
+
if (newValue) {
|
|
464
|
+
el.setAttribute('class', newValue);
|
|
465
|
+
} else {
|
|
466
|
+
el.removeAttribute('class');
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
} else if (key === 'style') {
|
|
470
|
+
if (typeof newValue === 'object' && newValue !== null) {
|
|
471
|
+
const style = (el as HTMLElement).style;
|
|
472
|
+
if (typeof oldValue === 'object' && oldValue !== null) {
|
|
473
|
+
for (const prop of Object.keys(oldValue)) {
|
|
474
|
+
if (!(prop in newValue)) {
|
|
475
|
+
style.setProperty(prop, '');
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
for (const [prop, value] of Object.entries(newValue)) {
|
|
480
|
+
style.setProperty(prop, String(value));
|
|
481
|
+
}
|
|
482
|
+
} else if (typeof newValue === 'string') {
|
|
483
|
+
(el as HTMLElement).style.cssText = newValue;
|
|
484
|
+
} else {
|
|
485
|
+
el.removeAttribute('style');
|
|
486
|
+
}
|
|
487
|
+
} else if (key === 'dangerouslySetInnerHTML') {
|
|
488
|
+
if (newValue?.__html != null) {
|
|
489
|
+
el.innerHTML = newValue.__html;
|
|
490
|
+
}
|
|
491
|
+
} else if (!isSVG && key === 'value' && (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement)) {
|
|
492
|
+
const newVal = newValue ?? '';
|
|
493
|
+
if (el.value !== newVal) {
|
|
494
|
+
el.value = newVal;
|
|
495
|
+
}
|
|
496
|
+
} else if (!isSVG && key === 'checked' && el instanceof HTMLInputElement) {
|
|
497
|
+
el.checked = !!newValue;
|
|
498
|
+
} else if (key === 'disabled') {
|
|
499
|
+
if (newValue) {
|
|
500
|
+
el.setAttribute('disabled', '');
|
|
501
|
+
} else {
|
|
502
|
+
el.removeAttribute('disabled');
|
|
503
|
+
}
|
|
504
|
+
} else if (newValue == null || newValue === false) {
|
|
505
|
+
const attrName = isSVG ? camelToKebab(key) : key;
|
|
506
|
+
el.removeAttribute(attrName);
|
|
507
|
+
} else if (newValue === true) {
|
|
508
|
+
const attrName = isSVG ? camelToKebab(key) : key;
|
|
509
|
+
el.setAttribute(attrName, '');
|
|
510
|
+
} else {
|
|
511
|
+
const attrName = isSVG ? camelToKebab(key) : key;
|
|
512
|
+
el.setAttribute(attrName, String(newValue));
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function unmount(vnode: VNode): void {
|
|
517
|
+
const { type, el, component, children, anchor } = vnode;
|
|
518
|
+
|
|
519
|
+
if (typeof type === 'function' && component) {
|
|
520
|
+
invokeLifecycleHooks(component.unmounted, 'onUnmounted');
|
|
521
|
+
|
|
522
|
+
if (component.subTree) {
|
|
523
|
+
unmount(component.subTree);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
if (component.update) {
|
|
527
|
+
cleanup(component.update as unknown as EffectFn);
|
|
528
|
+
}
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (type === Fragment) {
|
|
533
|
+
children.forEach(child => {
|
|
534
|
+
if (child && typeof child === 'object') {
|
|
535
|
+
unmount(child as VNode);
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
anchor?.parentNode?.removeChild(anchor);
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
children.forEach(child => {
|
|
543
|
+
if (child && typeof child === 'object') {
|
|
544
|
+
unmount(child as VNode);
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
if (el?.parentNode) {
|
|
549
|
+
el.parentNode.removeChild(el);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Configuration options for creating a Bromium application.
|
|
555
|
+
*/
|
|
556
|
+
export interface AppConfig {
|
|
557
|
+
/** Path to the favicon image */
|
|
558
|
+
favicon?: string;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function setupFavicon(faviconPath: string) {
|
|
562
|
+
let baseUrl = (import.meta as any).env?.BASE_URL || '/';
|
|
563
|
+
|
|
564
|
+
if (faviconPath.startsWith('/') || faviconPath.startsWith('http')) {
|
|
565
|
+
baseUrl = '';
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const fullPath = baseUrl + faviconPath;
|
|
569
|
+
|
|
570
|
+
let favicon = document.querySelector("link[rel='icon']") as HTMLLinkElement;
|
|
571
|
+
|
|
572
|
+
if (!favicon) {
|
|
573
|
+
favicon = document.createElement('link');
|
|
574
|
+
favicon.rel = 'icon';
|
|
575
|
+
document.head.appendChild(favicon);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
favicon.href = fullPath;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Creates a new Bromium application instance.
|
|
583
|
+
*
|
|
584
|
+
* @param rootComponent - The root component function to render
|
|
585
|
+
* @param config - Optional application configuration
|
|
586
|
+
* @returns An app instance with mount and unmount methods
|
|
587
|
+
*
|
|
588
|
+
* @example
|
|
589
|
+
* ```ts
|
|
590
|
+
* function App() {
|
|
591
|
+
* return <div>Hello, Bromium!</div>;
|
|
592
|
+
* }
|
|
593
|
+
*
|
|
594
|
+
* const app = createApp(App, { favicon: '/icon.png' });
|
|
595
|
+
* app.mount('#app');
|
|
596
|
+
*
|
|
597
|
+
* // Later: app.unmount();
|
|
598
|
+
* ```
|
|
599
|
+
*/
|
|
600
|
+
export function createApp(rootComponent: ComponentFunction, config?: AppConfig) {
|
|
601
|
+
let isMounted = false;
|
|
602
|
+
let rootVNode: VNode | null = null;
|
|
603
|
+
let container: Element | null = null;
|
|
604
|
+
|
|
605
|
+
if (config?.favicon) {
|
|
606
|
+
setupFavicon(config.favicon);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const app = {
|
|
610
|
+
/**
|
|
611
|
+
* Mounts the application to a DOM element.
|
|
612
|
+
*
|
|
613
|
+
* @param selector - A CSS selector string or DOM Element to mount to
|
|
614
|
+
* @returns The app instance for chaining
|
|
615
|
+
*/
|
|
616
|
+
mount(selector: string | Element) {
|
|
617
|
+
container =
|
|
618
|
+
typeof selector === 'string'
|
|
619
|
+
? document.querySelector(selector)
|
|
620
|
+
: selector;
|
|
621
|
+
|
|
622
|
+
if (!container) {
|
|
623
|
+
throw new Error(`Mount target "${selector}" not found`);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
if (!isMounted) {
|
|
627
|
+
rootVNode = {
|
|
628
|
+
type: rootComponent,
|
|
629
|
+
props: {},
|
|
630
|
+
children: [],
|
|
631
|
+
key: null,
|
|
632
|
+
el: null,
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
render(rootVNode, container);
|
|
636
|
+
isMounted = true;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
return app;
|
|
640
|
+
},
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Unmounts the application and cleans up resources.
|
|
644
|
+
*/
|
|
645
|
+
unmount() {
|
|
646
|
+
if (isMounted && container) {
|
|
647
|
+
render(null, container);
|
|
648
|
+
isMounted = false;
|
|
649
|
+
rootVNode = null;
|
|
650
|
+
}
|
|
651
|
+
},
|
|
652
|
+
};
|
|
653
|
+
|
|
654
|
+
return app;
|
|
655
|
+
}
|