@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/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
+ }