assign-gingerly 0.0.31 → 0.0.33

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/assignGingerly.ts CHANGED
@@ -60,6 +60,43 @@ export interface IAssignGingerlyOptions {
60
60
  * // Calls: elementRef.deref().querySelector('myElement').classList.add('active')
61
61
  */
62
62
  withMethods?: string[] | Set<string>;
63
+
64
+ /**
65
+ * Alias mappings for property and method names.
66
+ * Allows shorter, customizable shortcuts in path expressions.
67
+ *
68
+ * Aliases are substituted before path evaluation, matching complete tokens
69
+ * between `?.` delimiters (not substrings).
70
+ *
71
+ * Reserved characters (cannot be used in aliases): space, backtick (`)
72
+ *
73
+ * Example:
74
+ * assignGingerly(element, {
75
+ * '?.$?.my-element?.c?.+': 'highlighted'
76
+ * }, {
77
+ * withMethods: ['querySelector', 'add'],
78
+ * aka: { '$': 'querySelector', 'c': 'classList', '+': 'add' }
79
+ * });
80
+ * // Equivalent to: element.querySelector('my-element').classList.add('highlighted')
81
+ */
82
+ aka?: Record<string, string>;
83
+
84
+ /**
85
+ * AbortSignal for cleaning up reactive subscriptions (@eachTime)
86
+ * Required when using @eachTime symbol for reactive iteration
87
+ * When the signal is aborted, all event listeners are automatically removed
88
+ *
89
+ * Example:
90
+ * const controller = new AbortController();
91
+ * assignGingerly(div, {
92
+ * '?.mountObserver?.@eachTime?.classList?.add': 'highlighted'
93
+ * }, {
94
+ * withMethods: ['add'],
95
+ * signal: controller.signal
96
+ * });
97
+ * // Later: controller.abort(); // Cleanup all listeners
98
+ */
99
+ signal?: AbortSignal;
63
100
  }
64
101
 
65
102
  /**
@@ -319,12 +356,17 @@ function ensureNestedPath(obj: any, pathParts: string[]): any {
319
356
  }
320
357
 
321
358
  /**
322
- * Helper function to check if a property is readonly
323
- * A property is readonly if:
359
+ * Helper function to check if a property should be merged into rather than replaced.
360
+ * A property is non-replaceable if:
324
361
  * - It's a data property with writable: false, OR
325
362
  * - It's an accessor property with a getter but no setter
363
+ *
364
+ * Properties with both a getter and setter (e.g., element.style) are treated as
365
+ * replaceable — the setter runs with whatever value is provided (garbage in, garbage out).
366
+ *
367
+ * Exported for use by eachTime.ts
326
368
  */
327
- function isReadonlyProperty(obj: any, propName: string | symbol): boolean {
369
+ export function isReadonlyProperty(obj: any, propName: string | symbol): boolean {
328
370
  let descriptor = Object.getOwnPropertyDescriptor(obj, propName);
329
371
 
330
372
  if (!descriptor) {
@@ -344,8 +386,8 @@ function isReadonlyProperty(obj: any, propName: string | symbol): boolean {
344
386
  return descriptor.writable === false;
345
387
  }
346
388
 
347
- // If it's an accessor property, check if it has only a getter (no setter)
348
- if ('get' in descriptor) {
389
+ // If it's an accessor property with a getter but no setter, it's readonly
390
+ if ('get' in descriptor && descriptor.get !== undefined) {
349
391
  return descriptor.set === undefined;
350
392
  }
351
393
 
@@ -355,8 +397,10 @@ function isReadonlyProperty(obj: any, propName: string | symbol): boolean {
355
397
  /**
356
398
  * Helper function to check if a value is a class instance (not a plain object)
357
399
  * Returns true for instances of classes, false for plain objects, arrays, and primitives
400
+ *
401
+ * Exported for use by eachTime.ts
358
402
  */
359
- function isClassInstance(value: any): boolean {
403
+ export function isClassInstance(value: any): boolean {
360
404
  if (!value || typeof value !== 'object') return false;
361
405
  if (Array.isArray(value)) return false;
362
406
 
@@ -368,8 +412,10 @@ function isClassInstance(value: any): boolean {
368
412
  /**
369
413
  * Helper function to evaluate a nested path with method calls
370
414
  * Handles chained method calls where path segments can be methods
415
+ *
416
+ * Exported for use by eachTime.ts
371
417
  */
372
- function evaluatePathWithMethods(
418
+ export function evaluatePathWithMethods(
373
419
  target: any,
374
420
  pathParts: string[],
375
421
  value: any,
@@ -421,6 +467,172 @@ function evaluatePathWithMethods(
421
467
  };
422
468
  }
423
469
 
470
+ /**
471
+ * Check if a value is iterable (can be used with for...of or has forEach)
472
+ */
473
+ function isIterable(value: any): boolean {
474
+ if (value == null) return false;
475
+
476
+ // Check for Symbol.iterator
477
+ if (typeof value[Symbol.iterator] === 'function') return true;
478
+
479
+ // Check if it's an Array
480
+ if (Array.isArray(value)) return true;
481
+
482
+ // Check if it's array-like (has length and numeric indices)
483
+ // This covers NodeList, HTMLCollection, etc.
484
+ if (typeof value.length === 'number' && value.length >= 0) {
485
+ return true;
486
+ }
487
+
488
+ return false;
489
+ }
490
+
491
+ /**
492
+ * Check if a segment is the forEach symbol (@each) or aliased to it
493
+ */
494
+ function isForEachSymbol(segment: string, aliasMap: Map<string, string>): boolean {
495
+ // Direct match
496
+ if (segment === '@each') return true;
497
+
498
+ // Check if this segment is aliased to '@each'
499
+ const aliasTarget = aliasMap.get(segment);
500
+ return aliasTarget === '@each';
501
+ }
502
+
503
+ /**
504
+ * Check if a segment is the reactive forEach symbol (@eachTime) or aliased to it
505
+ */
506
+ function isReactiveForEachSymbol(segment: string, aliasMap: Map<string, string>): boolean {
507
+ // Direct match
508
+ if (segment === '@eachTime') return true;
509
+
510
+ // Check if this segment is aliased to '@eachTime'
511
+ const aliasTarget = aliasMap.get(segment);
512
+ return aliasTarget === '@eachTime';
513
+ }
514
+
515
+ /**
516
+ * Apply a path to each item in an iterable
517
+ */
518
+ function applyToEach(
519
+ iterable: any,
520
+ remainingPath: string[],
521
+ value: any,
522
+ withMethods: Set<string>,
523
+ aliasMap: Map<string, string>,
524
+ options?: IAssignGingerlyOptions
525
+ ): void {
526
+ // Convert to array for iteration
527
+ const items = Array.isArray(iterable) ? iterable : Array.from(iterable);
528
+
529
+ // Apply the remaining path to each item
530
+ for (const item of items) {
531
+ if (remainingPath.length === 0) {
532
+ // No remaining path, can't do anything
533
+ continue;
534
+ }
535
+
536
+ // Check if there's another @each in the remaining path
537
+ const forEachIndex = remainingPath.findIndex(part => isForEachSymbol(part, aliasMap));
538
+
539
+ if (forEachIndex !== -1) {
540
+ // There's a nested @each
541
+ // Evaluate path up to the @each
542
+ const pathToForEach = remainingPath.slice(0, forEachIndex);
543
+ const pathAfterForEach = remainingPath.slice(forEachIndex + 1);
544
+
545
+ // Navigate to the nested iterable
546
+ let current = item;
547
+ for (const part of pathToForEach) {
548
+ if (withMethods.has(part)) {
549
+ const method = current[part];
550
+ if (typeof method === 'function') {
551
+ // For methods in the middle, we need to check the next part
552
+ const nextIndex = pathToForEach.indexOf(part) + 1;
553
+ const nextPart = pathToForEach[nextIndex];
554
+ if (nextPart && withMethods.has(nextPart)) {
555
+ current = method.call(current);
556
+ } else if (nextPart) {
557
+ current = method.call(current, nextPart);
558
+ // Skip next part
559
+ pathToForEach.splice(nextIndex, 1);
560
+ } else {
561
+ current = method.call(current);
562
+ }
563
+ } else {
564
+ current = current[part];
565
+ }
566
+ } else {
567
+ current = current[part];
568
+ }
569
+ }
570
+
571
+ // Recursively apply to the nested iterable
572
+ if (isIterable(current)) {
573
+ applyToEach(current, pathAfterForEach, value, withMethods, aliasMap, options);
574
+ }
575
+ } else {
576
+ // No nested @each, evaluate the remaining path normally
577
+ const result = evaluatePathWithMethods(item, remainingPath, value, withMethods);
578
+
579
+ if (result.isMethod) {
580
+ // Last segment is a method - call it
581
+ const method = result.target[result.lastKey];
582
+ if (typeof method === 'function') {
583
+ if (Array.isArray(value)) {
584
+ method.apply(result.target, value);
585
+ } else {
586
+ method.call(result.target, value);
587
+ }
588
+ }
589
+ } else {
590
+ // Normal assignment
591
+ const lastKey = result.lastKey;
592
+ const parent = result.target;
593
+
594
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
595
+ if (lastKey in parent && isReadonlyProperty(parent, lastKey)) {
596
+ const currentValue = parent[lastKey];
597
+ if (typeof currentValue !== 'object' || currentValue === null) {
598
+ throw new Error(`Cannot merge object into readonly primitive property '${String(lastKey)}'`);
599
+ }
600
+ assignGingerly(currentValue, value, options);
601
+ } else {
602
+ parent[lastKey] = value;
603
+ }
604
+ } else {
605
+ parent[lastKey] = value;
606
+ }
607
+ }
608
+ }
609
+ }
610
+ }
611
+
612
+ /**
613
+ * Apply alias substitutions to a key string.
614
+ * Replaces complete tokens between `?.` delimiters with their aliased values.
615
+ *
616
+ * @param key - The key string (e.g., '?.$?.my-element?.c?.+')
617
+ * @param aliasMap - Map of alias -> target name
618
+ * @returns The key with aliases substituted (e.g., '?.querySelector?.my-element?.classList?.add')
619
+ */
620
+ function applyAliases(key: string, aliasMap: Map<string, string>): string {
621
+ if (aliasMap.size === 0) return key;
622
+
623
+ // Split by ?. to get tokens
624
+ const parts = key.split('?.');
625
+
626
+ // Apply aliases to each part
627
+ const substituted = parts.map(part => {
628
+ // Check if this exact part is an alias
629
+ return aliasMap.get(part) ?? part;
630
+ });
631
+
632
+ // Rejoin with ?.
633
+ return substituted.join('?.');
634
+ }
635
+
424
636
  /**
425
637
  * Main assignGingerly function
426
638
  */
@@ -440,13 +652,25 @@ export function assignGingerly(
440
652
  : new Set(options.withMethods)
441
653
  : undefined;
442
654
 
655
+ // Convert aka object to Map for O(1) lookup and validate aliases
656
+ const aliasMap = new Map<string, string>();
657
+ if (options?.aka) {
658
+ for (const [alias, target] of Object.entries(options.aka)) {
659
+ // Validate: disallow space and backtick in aliases
660
+ if (alias.includes(' ') || alias.includes('`')) {
661
+ throw new Error(`Invalid alias '${alias}': aliases cannot contain space or backtick characters`);
662
+ }
663
+ aliasMap.set(alias, target);
664
+ }
665
+ }
666
+
443
667
  const registry = options?.registry instanceof EnhancementRegistry
444
668
  ? options.registry
445
669
  : options?.registry
446
670
  ? new options.registry()
447
671
  : undefined;
448
672
 
449
- // Convert Symbol.for string keys to actual symbols
673
+ // Convert Symbol.for string keys to actual symbols and apply aliases
450
674
  const processedSource: Record<string | symbol, any> = {};
451
675
  for (const key of Object.keys(source)) {
452
676
  if (isSymbolForKey(key)) {
@@ -458,7 +682,9 @@ export function assignGingerly(
458
682
  processedSource[key] = source[key];
459
683
  }
460
684
  } else {
461
- processedSource[key] = source[key];
685
+ // Apply aliases to string keys
686
+ const substitutedKey = applyAliases(key, aliasMap);
687
+ processedSource[substitutedKey] = source[key];
462
688
  }
463
689
  }
464
690
  // Copy over actual symbol keys
@@ -613,6 +839,65 @@ export function assignGingerly(
613
839
  if (isNestedPath(key)) {
614
840
  const pathParts = parsePath(key);
615
841
 
842
+ // Check if path contains @each or @eachTime (forEach)
843
+ const forEachIndex = pathParts.findIndex(part =>
844
+ isForEachSymbol(part, aliasMap) || isReactiveForEachSymbol(part, aliasMap)
845
+ );
846
+
847
+ if (forEachIndex !== -1) {
848
+ // Check if it's reactive (@eachTime)
849
+ const isReactive = isReactiveForEachSymbol(pathParts[forEachIndex], aliasMap);
850
+
851
+ if (isReactive) {
852
+ // Reactive forEach - dynamic load and fire-and-forget
853
+ (async () => {
854
+ try {
855
+ const { handleEachTime } = await import('./eachTime.js');
856
+ await handleEachTime(
857
+ target,
858
+ pathParts,
859
+ forEachIndex,
860
+ value,
861
+ withMethodsSet,
862
+ aliasMap,
863
+ options
864
+ );
865
+ } catch (error) {
866
+ console.error('Error in @eachTime:', error);
867
+ }
868
+ })();
869
+ continue;
870
+ }
871
+
872
+ // Static forEach (@each) - existing logic
873
+ const pathToForEach = pathParts.slice(0, forEachIndex);
874
+ const pathAfterForEach = pathParts.slice(forEachIndex + 1);
875
+
876
+ // Navigate to the iterable
877
+ let current = target;
878
+ if (pathToForEach.length > 0) {
879
+ if (withMethodsSet) {
880
+ const result = evaluatePathWithMethods(target, pathToForEach, value, withMethodsSet);
881
+ // The result.target is the current position after evaluating the path
882
+ // This is already the iterable we want
883
+ current = result.target;
884
+ } else {
885
+ for (const part of pathToForEach) {
886
+ current = current[part];
887
+ }
888
+ }
889
+ }
890
+
891
+ // Apply to each item in the iterable
892
+ if (isIterable(current)) {
893
+ applyToEach(current, pathAfterForEach, value, withMethodsSet || new Set(), aliasMap, options);
894
+ }
895
+ // If not iterable, let JavaScript throw error naturally when trying to iterate
896
+
897
+ continue;
898
+ }
899
+
900
+ // No @each in path - handle normally
616
901
  // Check if we need to handle methods
617
902
  if (withMethodsSet) {
618
903
  const result = evaluatePathWithMethods(target, pathParts, value, withMethodsSet);
@@ -636,15 +921,15 @@ export function assignGingerly(
636
921
  const parent = result.target;
637
922
 
638
923
  if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
639
- // Check if property exists and is readonly OR is a class instance
640
- if (lastKey in parent && (isReadonlyProperty(parent, lastKey) || isClassInstance(parent[lastKey]))) {
924
+ // Check if property exists and is readonly
925
+ if (lastKey in parent && isReadonlyProperty(parent, lastKey)) {
641
926
  const currentValue = parent[lastKey];
642
927
  if (typeof currentValue !== 'object' || currentValue === null) {
643
- throw new Error(`Cannot merge object into ${isReadonlyProperty(parent, lastKey) ? 'readonly ' : ''}primitive property '${String(lastKey)}'`);
928
+ throw new Error(`Cannot merge object into readonly primitive property '${String(lastKey)}'`);
644
929
  }
645
930
  assignGingerly(currentValue, value, options);
646
931
  } else {
647
- // Property is writable and not a class instance - replace it
932
+ // Property is writable - replace it
648
933
  parent[lastKey] = value;
649
934
  }
650
935
  } else {
@@ -656,17 +941,17 @@ export function assignGingerly(
656
941
  const parent = ensureNestedPath(target, pathParts);
657
942
 
658
943
  if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
659
- // Check if property exists and is readonly OR is a class instance
660
- if (lastKey in parent && (isReadonlyProperty(parent, lastKey) || isClassInstance(parent[lastKey]))) {
661
- // Property is readonly or a class instance - check if current value is an object
944
+ // Check if property exists and is readonly
945
+ if (lastKey in parent && isReadonlyProperty(parent, lastKey)) {
946
+ // Property is readonly - check if current value is an object
662
947
  const currentValue = parent[lastKey];
663
948
  if (typeof currentValue !== 'object' || currentValue === null) {
664
- throw new Error(`Cannot merge object into ${isReadonlyProperty(parent, lastKey) ? 'readonly ' : ''}primitive property '${String(lastKey)}'`);
949
+ throw new Error(`Cannot merge object into readonly primitive property '${String(lastKey)}'`);
665
950
  }
666
- // Recursively apply assignGingerly to the readonly object or class instance
951
+ // Recursively apply assignGingerly to the readonly object
667
952
  assignGingerly(currentValue, value, options);
668
953
  } else {
669
- // Property is writable and not a class instance - replace it
954
+ // Property is writable - replace it
670
955
  parent[lastKey] = value;
671
956
  }
672
957
  } else {
@@ -692,17 +977,17 @@ export function assignGingerly(
692
977
 
693
978
  // Normal assignment
694
979
  if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
695
- // Check if property exists and is readonly OR is a class instance
696
- if (key in target && (isReadonlyProperty(target, key) || isClassInstance(target[key]))) {
697
- // Property is readonly or a class instance - check if current value is an object
980
+ // Check if property exists and is readonly
981
+ if (key in target && isReadonlyProperty(target, key)) {
982
+ // Property is readonly - check if current value is an object
698
983
  const currentValue = target[key];
699
984
  if (typeof currentValue !== 'object' || currentValue === null) {
700
- throw new Error(`Cannot merge object into ${isReadonlyProperty(target, key) ? 'readonly ' : ''}primitive property '${String(key)}'`);
985
+ throw new Error(`Cannot merge object into readonly primitive property '${String(key)}'`);
701
986
  }
702
- // Recursively apply assignGingerly to the readonly object or class instance
987
+ // Recursively apply assignGingerly to the readonly object
703
988
  assignGingerly(currentValue, value, options);
704
989
  } else {
705
- // Property is writable and not a class instance - replace it
990
+ // Property is writable - replace it
706
991
  target[key] = value;
707
992
  }
708
993
  } else {
package/eachTime.js ADDED
@@ -0,0 +1,110 @@
1
+ /**
2
+ * eachTime.ts - Reactive forEach implementation for @eachTime symbol
3
+ * This module is dynamically loaded only when @eachTime is encountered
4
+ * Provides event-driven iteration over elements as they mount
5
+ */
6
+ /**
7
+ * Check if a value is an EventTarget
8
+ */
9
+ function isEventTarget(value) {
10
+ return value != null && typeof value.addEventListener === 'function';
11
+ }
12
+ /**
13
+ * Handle reactive forEach (@eachTime) by setting up event listeners
14
+ *
15
+ * @param target - The root object to start navigation from
16
+ * @param pathParts - Complete path parts array including @eachTime
17
+ * @param forEachIndex - Index of @eachTime in pathParts
18
+ * @param value - Value to assign to each mounted element
19
+ * @param withMethods - Set of method names to call instead of assign
20
+ * @param aliasMap - Map of aliases for token substitution
21
+ * @param options - Options including required AbortSignal
22
+ */
23
+ export async function handleEachTime(target, pathParts, forEachIndex, value, withMethods, aliasMap, options) {
24
+ // Validate signal - required for cleanup
25
+ if (!options?.signal) {
26
+ throw new Error('@eachTime requires an AbortSignal in options.signal for cleanup');
27
+ }
28
+ // Split path into before and after @eachTime
29
+ const pathToEventSource = pathParts.slice(0, forEachIndex);
30
+ const pathAfterForEach = pathParts.slice(forEachIndex + 1);
31
+ // Navigate to the event source
32
+ let eventSource = target;
33
+ if (pathToEventSource.length > 0) {
34
+ // Import evaluatePathWithMethods for navigation
35
+ const { evaluatePathWithMethods } = await import('./assignGingerly.js');
36
+ if (withMethods && withMethods.size > 0) {
37
+ const result = evaluatePathWithMethods(target, pathToEventSource, value, withMethods);
38
+ eventSource = result.target;
39
+ }
40
+ else {
41
+ // Simple property navigation
42
+ for (const part of pathToEventSource) {
43
+ eventSource = eventSource[part];
44
+ }
45
+ }
46
+ }
47
+ // Validate event source is an EventTarget
48
+ if (!isEventTarget(eventSource)) {
49
+ throw new Error('@eachTime requires an EventTarget (e.g., IMountObserver)');
50
+ }
51
+ // Setup event listener for 'mount' events
52
+ const handler = (event) => {
53
+ // Extract mounted element from IMountEvent
54
+ const mountedElement = event.mountedElement;
55
+ if (!mountedElement)
56
+ return;
57
+ // Apply remaining path to mounted element (async to avoid blocking)
58
+ (async () => {
59
+ try {
60
+ // Import needed functions from assignGingerly
61
+ const { evaluatePathWithMethods, assignGingerly, isReadonlyProperty } = await import('./assignGingerly.js');
62
+ if (pathAfterForEach.length > 0) {
63
+ const result = evaluatePathWithMethods(mountedElement, pathAfterForEach, value, withMethods || new Set());
64
+ if (result.isMethod) {
65
+ // Last segment is a method - call it
66
+ const method = result.target[result.lastKey];
67
+ if (typeof method === 'function') {
68
+ if (Array.isArray(value)) {
69
+ method.apply(result.target, value);
70
+ }
71
+ else {
72
+ method.call(result.target, value);
73
+ }
74
+ }
75
+ }
76
+ else {
77
+ // Normal assignment
78
+ const lastKey = result.lastKey;
79
+ const parent = result.target;
80
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
81
+ // Check if property exists and is readonly
82
+ if (lastKey in parent && isReadonlyProperty(parent, lastKey)) {
83
+ const currentValue = parent[lastKey];
84
+ if (typeof currentValue !== 'object' || currentValue === null) {
85
+ throw new Error(`Cannot merge object into readonly primitive property '${String(lastKey)}'`);
86
+ }
87
+ // Recursively apply assignGingerly
88
+ assignGingerly(currentValue, value, options);
89
+ }
90
+ else {
91
+ // Property is writable - replace it
92
+ parent[lastKey] = value;
93
+ }
94
+ }
95
+ else {
96
+ // Primitive value - direct assignment
97
+ parent[lastKey] = value;
98
+ }
99
+ }
100
+ }
101
+ }
102
+ catch (error) {
103
+ console.error('Error applying @eachTime to mounted element:', error);
104
+ }
105
+ })();
106
+ };
107
+ // Register listener with AbortSignal for automatic cleanup
108
+ // Hardcode 'mount' event for IMountObserver
109
+ eventSource.addEventListener('mount', handler, { signal: options.signal });
110
+ }
package/eachTime.ts ADDED
@@ -0,0 +1,136 @@
1
+ /**
2
+ * eachTime.ts - Reactive forEach implementation for @eachTime symbol
3
+ * This module is dynamically loaded only when @eachTime is encountered
4
+ * Provides event-driven iteration over elements as they mount
5
+ */
6
+
7
+ import type { IAssignGingerlyOptions } from './assignGingerly.js';
8
+
9
+ /**
10
+ * Check if a value is an EventTarget
11
+ */
12
+ function isEventTarget(value: any): boolean {
13
+ return value != null && typeof value.addEventListener === 'function';
14
+ }
15
+
16
+ /**
17
+ * Handle reactive forEach (@eachTime) by setting up event listeners
18
+ *
19
+ * @param target - The root object to start navigation from
20
+ * @param pathParts - Complete path parts array including @eachTime
21
+ * @param forEachIndex - Index of @eachTime in pathParts
22
+ * @param value - Value to assign to each mounted element
23
+ * @param withMethods - Set of method names to call instead of assign
24
+ * @param aliasMap - Map of aliases for token substitution
25
+ * @param options - Options including required AbortSignal
26
+ */
27
+ export async function handleEachTime(
28
+ target: any,
29
+ pathParts: string[],
30
+ forEachIndex: number,
31
+ value: any,
32
+ withMethods: Set<string> | undefined,
33
+ aliasMap: Map<string, string>,
34
+ options?: IAssignGingerlyOptions
35
+ ): Promise<void> {
36
+ // Validate signal - required for cleanup
37
+ if (!options?.signal) {
38
+ throw new Error('@eachTime requires an AbortSignal in options.signal for cleanup');
39
+ }
40
+
41
+ // Split path into before and after @eachTime
42
+ const pathToEventSource = pathParts.slice(0, forEachIndex);
43
+ const pathAfterForEach = pathParts.slice(forEachIndex + 1);
44
+
45
+ // Navigate to the event source
46
+ let eventSource = target;
47
+ if (pathToEventSource.length > 0) {
48
+ // Import evaluatePathWithMethods for navigation
49
+ const { evaluatePathWithMethods } = await import('./assignGingerly.js');
50
+
51
+ if (withMethods && withMethods.size > 0) {
52
+ const result = evaluatePathWithMethods(target, pathToEventSource, value, withMethods);
53
+ eventSource = result.target;
54
+ } else {
55
+ // Simple property navigation
56
+ for (const part of pathToEventSource) {
57
+ eventSource = eventSource[part];
58
+ }
59
+ }
60
+ }
61
+
62
+ // Validate event source is an EventTarget
63
+ if (!isEventTarget(eventSource)) {
64
+ throw new Error('@eachTime requires an EventTarget (e.g., IMountObserver)');
65
+ }
66
+
67
+ // Setup event listener for 'mount' events
68
+ const handler = (event: Event) => {
69
+ // Extract mounted element from IMountEvent
70
+ const mountedElement = (event as any).mountedElement;
71
+ if (!mountedElement) return;
72
+
73
+ // Apply remaining path to mounted element (async to avoid blocking)
74
+ (async () => {
75
+ try {
76
+ // Import needed functions from assignGingerly
77
+ const {
78
+ evaluatePathWithMethods,
79
+ assignGingerly,
80
+ isReadonlyProperty
81
+ } = await import('./assignGingerly.js');
82
+
83
+ if (pathAfterForEach.length > 0) {
84
+ const result = evaluatePathWithMethods(
85
+ mountedElement,
86
+ pathAfterForEach,
87
+ value,
88
+ withMethods || new Set()
89
+ );
90
+
91
+ if (result.isMethod) {
92
+ // Last segment is a method - call it
93
+ const method = result.target[result.lastKey];
94
+ if (typeof method === 'function') {
95
+ if (Array.isArray(value)) {
96
+ method.apply(result.target, value);
97
+ } else {
98
+ method.call(result.target, value);
99
+ }
100
+ }
101
+ } else {
102
+ // Normal assignment
103
+ const lastKey = result.lastKey;
104
+ const parent = result.target;
105
+
106
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
107
+ // Check if property exists and is readonly
108
+ if (lastKey in parent && isReadonlyProperty(parent, lastKey)) {
109
+ const currentValue = parent[lastKey];
110
+ if (typeof currentValue !== 'object' || currentValue === null) {
111
+ throw new Error(
112
+ `Cannot merge object into readonly primitive property '${String(lastKey)}'`
113
+ );
114
+ }
115
+ // Recursively apply assignGingerly
116
+ assignGingerly(currentValue, value, options);
117
+ } else {
118
+ // Property is writable - replace it
119
+ parent[lastKey] = value;
120
+ }
121
+ } else {
122
+ // Primitive value - direct assignment
123
+ parent[lastKey] = value;
124
+ }
125
+ }
126
+ }
127
+ } catch (error) {
128
+ console.error('Error applying @eachTime to mounted element:', error);
129
+ }
130
+ })();
131
+ };
132
+
133
+ // Register listener with AbortSignal for automatic cleanup
134
+ // Hardcode 'mount' event for IMountObserver
135
+ eventSource.addEventListener('mount', handler, { signal: options.signal });
136
+ }