neo.mjs 10.0.0-beta.3 → 10.0.0-beta.5

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.
Files changed (76) hide show
  1. package/.github/RELEASE_NOTES/v10.0.0-beta.4.md +41 -0
  2. package/ServiceWorker.mjs +2 -2
  3. package/apps/portal/index.html +1 -1
  4. package/apps/portal/view/ViewportController.mjs +1 -1
  5. package/apps/portal/view/home/FooterContainer.mjs +1 -1
  6. package/apps/portal/view/learn/MainContainerController.mjs +6 -6
  7. package/examples/button/effect/MainContainer.mjs +207 -0
  8. package/examples/button/effect/app.mjs +6 -0
  9. package/examples/button/effect/index.html +11 -0
  10. package/examples/button/effect/neo-config.json +6 -0
  11. package/learn/guides/{Collections.md → datahandling/Collections.md} +6 -6
  12. package/learn/guides/datahandling/Grids.md +621 -0
  13. package/learn/guides/{Records.md → datahandling/Records.md} +4 -3
  14. package/learn/guides/{StateProviders.md → datahandling/StateProviders.md} +146 -1
  15. package/learn/guides/fundamentals/DeclarativeVDOMWithEffects.md +166 -0
  16. package/learn/guides/fundamentals/ExtendingNeoClasses.md +359 -0
  17. package/learn/guides/{Layouts.md → uibuildingblocks/Layouts.md} +40 -38
  18. package/learn/guides/{form_fields → userinteraction/form_fields}/ComboBox.md +3 -3
  19. package/learn/tree.json +64 -57
  20. package/package.json +3 -3
  21. package/src/DefaultConfig.mjs +2 -2
  22. package/src/Neo.mjs +244 -88
  23. package/src/button/Effect.mjs +435 -0
  24. package/src/collection/Base.mjs +35 -3
  25. package/src/component/Base.mjs +72 -61
  26. package/src/container/Base.mjs +28 -24
  27. package/src/controller/Base.mjs +87 -63
  28. package/src/core/Base.mjs +207 -33
  29. package/src/core/Compare.mjs +3 -13
  30. package/src/core/Config.mjs +230 -0
  31. package/src/core/ConfigSymbols.mjs +3 -0
  32. package/src/core/Effect.mjs +127 -0
  33. package/src/core/EffectBatchManager.mjs +68 -0
  34. package/src/core/EffectManager.mjs +38 -0
  35. package/src/core/Util.mjs +3 -18
  36. package/src/data/RecordFactory.mjs +22 -3
  37. package/src/grid/Container.mjs +8 -4
  38. package/src/grid/column/Component.mjs +1 -1
  39. package/src/state/Provider.mjs +343 -452
  40. package/src/state/createHierarchicalDataProxy.mjs +124 -0
  41. package/src/tab/header/EffectButton.mjs +75 -0
  42. package/src/util/Function.mjs +52 -5
  43. package/src/vdom/Helper.mjs +9 -10
  44. package/src/vdom/VNode.mjs +1 -1
  45. package/src/worker/App.mjs +0 -5
  46. package/test/siesta/siesta.js +32 -0
  47. package/test/siesta/tests/CollectionBase.mjs +10 -10
  48. package/test/siesta/tests/VdomHelper.mjs +22 -59
  49. package/test/siesta/tests/config/AfterSetConfig.mjs +100 -0
  50. package/test/siesta/tests/config/Basic.mjs +149 -0
  51. package/test/siesta/tests/config/CircularDependencies.mjs +166 -0
  52. package/test/siesta/tests/config/CustomFunctions.mjs +69 -0
  53. package/test/siesta/tests/config/Hierarchy.mjs +94 -0
  54. package/test/siesta/tests/config/MemoryLeak.mjs +92 -0
  55. package/test/siesta/tests/config/MultiLevelHierarchy.mjs +85 -0
  56. package/test/siesta/tests/core/Effect.mjs +131 -0
  57. package/test/siesta/tests/core/EffectBatching.mjs +322 -0
  58. package/test/siesta/tests/neo/MixinStaticConfig.mjs +138 -0
  59. package/test/siesta/tests/state/Provider.mjs +537 -0
  60. package/test/siesta/tests/state/createHierarchicalDataProxy.mjs +217 -0
  61. package/learn/guides/ExtendingNeoClasses.md +0 -331
  62. /package/learn/guides/{Tables.md → datahandling/Tables.md} +0 -0
  63. /package/learn/guides/{ApplicationBootstrap.md → fundamentals/ApplicationBootstrap.md} +0 -0
  64. /package/learn/guides/{ConfigSystemDeepDive.md → fundamentals/ConfigSystemDeepDive.md} +0 -0
  65. /package/learn/guides/{DeclarativeComponentTreesVsImperativeVdom.md → fundamentals/DeclarativeComponentTreesVsImperativeVdom.md} +0 -0
  66. /package/learn/guides/{InstanceLifecycle.md → fundamentals/InstanceLifecycle.md} +0 -0
  67. /package/learn/guides/{MainThreadAddons.md → fundamentals/MainThreadAddons.md} +0 -0
  68. /package/learn/guides/{Mixins.md → specificfeatures/Mixins.md} +0 -0
  69. /package/learn/guides/{MultiWindow.md → specificfeatures/MultiWindow.md} +0 -0
  70. /package/learn/guides/{PortalApp.md → specificfeatures/PortalApp.md} +0 -0
  71. /package/learn/guides/{ComponentsAndContainers.md → uibuildingblocks/ComponentsAndContainers.md} +0 -0
  72. /package/learn/guides/{CustomComponents.md → uibuildingblocks/CustomComponents.md} +0 -0
  73. /package/learn/guides/{WorkingWithVDom.md → uibuildingblocks/WorkingWithVDom.md} +0 -0
  74. /package/learn/guides/{Forms.md → userinteraction/Forms.md} +0 -0
  75. /package/learn/guides/{events → userinteraction/events}/CustomEvents.md +0 -0
  76. /package/learn/guides/{events → userinteraction/events}/DomEvents.md +0 -0
@@ -10,6 +10,7 @@ import Rectangle from '../util/Rectangle.mjs';
10
10
  import Style from '../util/Style.mjs';
11
11
  import VDomUtil from '../util/VDom.mjs';
12
12
  import VNodeUtil from '../util/VNode.mjs';
13
+ import {isDescriptor} from '../core/ConfigSymbols.mjs';
13
14
 
14
15
  const
15
16
  addUnits = value => value == null ? value : isNaN(value) ? value : `${value}px`,
@@ -53,11 +54,15 @@ class Component extends Base {
53
54
  /**
54
55
  * The default alignment specification to position this Component relative to some other
55
56
  * Component, or Element or Rectangle. Only applies in case floating = true.
56
- * @member {Object|String} align_={edgeAlign:'t-b',constrainTo:'document.body'}
57
+ * @member {Object|String} align_={[isDescriptor]: true, merge: 'deep', value: {edgeAlign: 't-b',constrainTo: 'document.body'}}
57
58
  */
58
59
  align_: {
59
- edgeAlign : 't-b',
60
- constrainTo: 'document.body'
60
+ [isDescriptor]: true,
61
+ merge : 'deep',
62
+ value: {
63
+ edgeAlign : 't-b',
64
+ constrainTo: 'document.body'
65
+ }
61
66
  },
62
67
  /**
63
68
  * The name of the App this component belongs to
@@ -326,9 +331,13 @@ class Component extends Base {
326
331
  stateProvider_: null,
327
332
  /**
328
333
  * Style attributes added to this vdom root. see: getVdomRoot()
329
- * @member {Object} style_=null
334
+ * @member {Object} style={[isDescriptor]: true, merge: 'shallow', value: null}
330
335
  */
331
- style_: null,
336
+ style_: {
337
+ [isDescriptor]: true,
338
+ merge : 'shallow',
339
+ value : null
340
+ },
332
341
  /**
333
342
  * You can pass a used theme directly to any component,
334
343
  * to style specific component trees differently from your main view.
@@ -375,10 +384,16 @@ class Component extends Base {
375
384
  updateDepth_: 1,
376
385
  /**
377
386
  * The component vnode tree. Available after the component got rendered.
378
- * @member {Object} vnode_=null
387
+ * @member {Object} vnode_=={[isDescriptor]: true, value: null, isEqual: (a, b) => a === b,}
379
388
  * @protected
380
389
  */
381
- vnode_: null,
390
+ vnode_: {
391
+ [isDescriptor]: true,
392
+ clone : 'none',
393
+ cloneOnGet : 'none',
394
+ isEqual : (a, b) => a === b, // vnode trees can be huge, and will get compared by the vdom worker.
395
+ value : null,
396
+ },
382
397
  /**
383
398
  * Shortcut for style.width, defaults to px
384
399
  * @member {Number|String|null} width_=null
@@ -395,9 +410,13 @@ class Component extends Base {
395
410
  wrapperCls_: null,
396
411
  /**
397
412
  * Top level style attributes. Useful in case getVdomRoot() does not point to the top level DOM node.
398
- * @member {Object|null} wrapperStyle_=null
413
+ * @member {Object|null} wrapperStyle_={[isDescriptor]: true, merge: 'shallow', value: null}
399
414
  */
400
- wrapperStyle_: null,
415
+ wrapperStyle_: {
416
+ [isDescriptor]: true,
417
+ merge : 'shallow',
418
+ value : null
419
+ },
401
420
  /**
402
421
  * The vdom markup for this component.
403
422
  * @member {Object} _vdom={}
@@ -588,7 +607,12 @@ class Component extends Base {
588
607
  afterSetConfig(key, value, oldValue) {
589
608
  let me = this;
590
609
 
591
- if (currentWorker.isUsingStateProviders && me[twoWayBindingSymbol] && oldValue !== undefined) {
610
+ if (Neo.isUsingStateProviders && me[twoWayBindingSymbol]) {
611
+ // When a component config is updated by its state provider, this flag is set to the config's key.
612
+ // This prevents circular updates in two-way data bindings by skipping the push back to the state provider.
613
+ if (me._skipTwoWayPush === key) {
614
+ return;
615
+ }
592
616
  let binding = me.bind?.[key];
593
617
 
594
618
  if (binding?.twoWay) {
@@ -646,21 +670,6 @@ class Component extends Base {
646
670
  }
647
671
  }
648
672
 
649
- /**
650
- * Triggered after the flex config got changed
651
- * @param {Number|String|null} value
652
- * @param {Number|String|null} oldValue
653
- * @protected
654
- */
655
- afterSetFlex(value, oldValue) {
656
- if (!isNaN(value)) {
657
- value = `${value} ${value} 0%`
658
- }
659
-
660
- this.configuredFlex = value;
661
- this.changeVdomRootKey('flex', value)
662
- }
663
-
664
673
  /**
665
674
  * Triggered after the hasUnmountedVdomChanges config got changed
666
675
  * @param {Boolean} value
@@ -949,6 +958,16 @@ class Component extends Base {
949
958
  }
950
959
  }
951
960
 
961
+ /**
962
+ * Triggered after the stateProvider config got changed
963
+ * @param {Neo.state.Provider} value
964
+ * @param {Object|Neo.state.Provider|null} oldValue
965
+ * @protected
966
+ */
967
+ afterSetStateProvider(value, oldValue) {
968
+ value?.createBindings(this)
969
+ }
970
+
952
971
  /**
953
972
  * Triggered after the style config got changed
954
973
  * @param {Object} value
@@ -1242,8 +1261,7 @@ class Component extends Base {
1242
1261
  }
1243
1262
  }
1244
1263
 
1245
- // merge the incoming alignment specification into the configured default
1246
- return Neo.merge({}, value, me.constructor.config.align)
1264
+ return value
1247
1265
  }
1248
1266
 
1249
1267
  /**
@@ -1803,7 +1821,7 @@ class Component extends Base {
1803
1821
  * @returns {Neo.state.Provider|null}
1804
1822
  */
1805
1823
  getStateProvider(ntype) {
1806
- if (!currentWorker.isUsingStateProviders) {
1824
+ if (!Neo.isUsingStateProviders) {
1807
1825
  return null
1808
1826
  }
1809
1827
 
@@ -1940,16 +1958,11 @@ class Component extends Base {
1940
1958
  }
1941
1959
 
1942
1960
  /**
1943
- * We are using this method as a ctor hook here to add the initial state.Provider & controller.Component parsing
1944
- * @param {Object} config
1945
- * @param {Boolean} [preventOriginalConfig] True prevents the instance from getting an originalConfig property
1961
+ * @param args
1946
1962
  */
1947
- initConfig(config, preventOriginalConfig) {
1948
- super.initConfig(config, preventOriginalConfig);
1949
-
1950
- let me = this;
1951
-
1952
- me.getStateProvider()?.parseConfig(me)
1963
+ initConfig(...args) {
1964
+ super.initConfig(...args);
1965
+ this.getStateProvider()?.createBindings(this)
1953
1966
  }
1954
1967
 
1955
1968
  /**
@@ -2035,28 +2048,15 @@ class Component extends Base {
2035
2048
  * @returns {Object} config
2036
2049
  */
2037
2050
  mergeConfig(...args) {
2038
- let me = this,
2039
- config = super.mergeConfig(...args),
2040
-
2041
- // it should be possible to set custom configs for the vdom on instance level,
2042
- // however there will be already added attributes (e.g. id), so a merge seems to be the best strategy.
2043
- vdom = {...me._vdom || {}, ...config.vdom || {}};
2051
+ let config = super.mergeConfig(...args),
2052
+ vdom = config.vdom || config._vdom || {};
2044
2053
 
2045
- // avoid any interference on prototype level
2046
- // does not clone existing Neo instances
2047
- me._vdom = Neo.clone(vdom, true, true);
2048
-
2049
- if (config.style) {
2050
- // If we are passed an object, merge it with the class's own style
2051
- me.style = Neo.typeOf(config.style) === 'Object' ? {...config.style, ...me.constructor.config.style} : config.style
2052
- }
2054
+ // It should be possible to modify root level vdom attributes on instance level.
2055
+ // Note that vdom is not a real config, but implemented via get() & set().
2056
+ this._vdom = Neo.clone({...vdom, ...this._vdom || {}}, true);
2053
2057
 
2054
- me.wrapperStyle = Neo.clone(config.wrapperStyle, false);
2055
-
2056
- delete config.style;
2057
2058
  delete config._vdom;
2058
2059
  delete config.vdom;
2059
- delete config.wrapperStyle;
2060
2060
 
2061
2061
  return config
2062
2062
  }
@@ -2141,8 +2141,12 @@ class Component extends Base {
2141
2141
  *
2142
2142
  */
2143
2143
  onConstructed() {
2144
- super.onConstructed();
2145
- this.keys?.register(this)
2144
+ super.onConstructed()
2145
+
2146
+ let me = this;
2147
+
2148
+ me.keys?.register(me);
2149
+ me.getStateProvider()?.createBindings(me)
2146
2150
  }
2147
2151
 
2148
2152
  /**
@@ -2315,10 +2319,12 @@ class Component extends Base {
2315
2319
  * @param {Boolean} [mount] Mount the DOM after the vnode got created
2316
2320
  */
2317
2321
  async render(mount) {
2318
- let me = this,
2319
- autoMount = mount || me.autoMount,
2320
- {app} = me,
2321
- {useVdomWorker} = Neo.config;
2322
+ let me = this,
2323
+ autoMount = mount || me.autoMount,
2324
+ {app} = me,
2325
+ {unitTestMode, useVdomWorker} = Neo.config;
2326
+
2327
+ if (unitTestMode) return;
2322
2328
 
2323
2329
  // Verify that the critical rendering path => CSS files for the new tree is in place
2324
2330
  if (autoMount && currentWorker.countLoadingThemeFiles !== 0) {
@@ -2632,6 +2638,11 @@ class Component extends Base {
2632
2638
  * @protected
2633
2639
  */
2634
2640
  updateVdom(resolve, reject) {
2641
+ if (Neo.config.unitTestMode) {
2642
+ reject?.();
2643
+ return
2644
+ }
2645
+
2635
2646
  let me = this,
2636
2647
  {app, mounted, parentId, vnode} = me;
2637
2648
 
@@ -1,12 +1,13 @@
1
- import Component from '../component/Base.mjs';
2
- import LayoutBase from '../layout/Base.mjs';
3
- import LayoutCard from '../layout/Card.mjs';
4
- import LayoutFit from '../layout/Fit.mjs';
5
- import LayoutGrid from '../layout/Grid.mjs';
6
- import LayoutHbox from '../layout/HBox.mjs';
7
- import LayoutVBox from '../layout/VBox.mjs';
8
- import Logger from '../util/Logger.mjs';
9
- import NeoArray from '../util/Array.mjs';
1
+ import Component from '../component/Base.mjs';
2
+ import LayoutBase from '../layout/Base.mjs';
3
+ import LayoutCard from '../layout/Card.mjs';
4
+ import LayoutFit from '../layout/Fit.mjs';
5
+ import LayoutGrid from '../layout/Grid.mjs';
6
+ import LayoutHbox from '../layout/HBox.mjs';
7
+ import LayoutVBox from '../layout/VBox.mjs';
8
+ import Logger from '../util/Logger.mjs';
9
+ import NeoArray from '../util/Array.mjs';
10
+ import {isDescriptor} from '../core/ConfigSymbols.mjs';
10
11
 
11
12
  const byWeight = ({ weight : lhs = 0 }, { weight : rhs = 0 }) => lhs - rhs;
12
13
 
@@ -31,9 +32,15 @@ class Container extends Component {
31
32
  */
32
33
  baseCls: ['neo-container'],
33
34
  /**
34
- * @member {Object} itemDefaults_=null
35
+ * Default configuration for child items within this container.
36
+ * This config uses a descriptor to enable deep merging with instance based itemDefaults.
37
+ * @member {Object} itemDefaults_={[isDescriptor]: true, merge: 'deep', value: null}
35
38
  */
36
- itemDefaults_: null,
39
+ itemDefaults_: {
40
+ [isDescriptor]: true,
41
+ merge : 'deep',
42
+ value : null
43
+ },
37
44
  /**
38
45
  * An array or an object of config objects|instances|modules for each child component
39
46
  * @member {Object[]} items_=[]
@@ -85,7 +92,13 @@ class Container extends Component {
85
92
  * ]
86
93
  * });
87
94
  */
88
- items_: [],
95
+ items_: {
96
+ [isDescriptor]: true,
97
+ clone : 'shallow',
98
+ cloneOnGet : 'none',
99
+ isEqual : () => false,
100
+ value : []
101
+ },
89
102
  /**
90
103
  * It is crucial to define a layout before the container does get rendered.
91
104
  * Meaning: onConstructed() is the latest life-cycle point.
@@ -342,10 +355,7 @@ class Container extends Component {
342
355
  }
343
356
 
344
357
  item.set(config);
345
-
346
- // In case an item got created outside a stateProvider based hierarchy, there might be bindings or string
347
- // based listeners which still need to get resolved.
348
- item.getStateProvider()?.parseConfig(item);
358
+ item.getStateProvider()?.createBindings(item);
349
359
  break
350
360
  }
351
361
 
@@ -617,14 +627,8 @@ class Container extends Component {
617
627
  config = super.mergeConfig(...args),
618
628
  ctorItems;
619
629
 
620
- // avoid any interference on prototype level
621
- // does not clone existing Neo instances
622
-
623
- if (config.itemDefaults) {
624
- me._itemDefaults = Neo.clone(config.itemDefaults, true, true);
625
- delete config.itemDefaults
626
- }
627
-
630
+ // Avoid any interference on prototype level
631
+ // Does not clone existing Neo instances
628
632
  if (config.items) {
629
633
  ctorItems = me.constructor.config.items;
630
634
 
@@ -2,8 +2,11 @@ import Base from '../core/Base.mjs';
2
2
  import HashHistory from '../util/HashHistory.mjs';
3
3
 
4
4
  const
5
- amountSlashesRegex = /\//g,
6
- routeParamRegex = /{[^\s/]+}/g
5
+ regexAmountSlashes = /\//g,
6
+ // Regex to extract the parameter name from a single route segment (e.g., {*itemId} -> itemId)
7
+ regexParamNameExtraction = /{(\*|\.\.\.)?([^}]+)}/,
8
+ // Regex to match route parameters like {paramName}, {*paramName}, or {...paramName}
9
+ regexRouteParam = /{(\*|\.\.\.)?([^}]+)}/g;
7
10
 
8
11
  /**
9
12
  * @class Neo.controller.Base
@@ -22,25 +25,33 @@ class Controller extends Base {
22
25
  */
23
26
  ntype: 'controller',
24
27
  /**
25
- * If the URL does not contain a hash value when creating this controller instance,
26
- * neo will set this hash value for us.
28
+ * If the URL does not contain a hash value when this controller instance is created,
29
+ * Neo.mjs will automatically set this hash value, ensuring a default route is active.
27
30
  * @member {String|null} defaultHash=null
28
31
  */
29
32
  defaultHash: null,
30
33
  /**
34
+ * Specifies the handler method to be invoked when no other defined route matches the URL hash.
35
+ * This acts as a fallback for unhandled routes.
31
36
  * @member {String|null} defaultRoute=null
32
37
  */
33
38
  defaultRoute: null,
34
39
  /**
40
+ * Internal map of compiled regular expressions for each route, used for efficient hash matching.
41
+ * @protected
35
42
  * @member {Object} handleRoutes={}
36
43
  */
37
44
  handleRoutes: {},
38
45
  /**
46
+ * Defines the routing rules for the controller. Keys are route patterns, and values are either
47
+ * handler method names (String) or objects containing `handler` and optional `preHandler` method names.
48
+ * Route patterns can include parameters like `{paramName}` and wildcards like `{*paramName}` for nested paths.
39
49
  * @example
40
50
  * routes: {
41
51
  * '/home' : 'handleHomeRoute',
42
52
  * '/users/{userId}' : {handler: 'handleUserRoute', preHandler: 'preHandleUserRoute'},
43
53
  * '/users/{userId}/posts/{postId}': 'handlePostRoute',
54
+ * '/learn/{*itemId}' : 'onLearnRoute', // Captures nested paths like /learn/gettingstarted/Workspaces
44
55
  * 'default' : 'handleOtherRoutes'
45
56
  * }
46
57
  * @member {Object} routes_={}
@@ -49,16 +60,18 @@ class Controller extends Base {
49
60
  }
50
61
 
51
62
  /**
63
+ * Creates a new Controller instance and registers its `onHashChange` method
64
+ * to listen for changes in the browser's URL hash.
52
65
  * @param {Object} config
53
66
  */
54
67
  construct(config) {
55
68
  super.construct(config);
56
-
57
69
  HashHistory.on('change', this.onHashChange, this)
58
70
  }
59
71
 
60
72
  /**
61
- * Triggered after the routes config got changed
73
+ * Processes the defined routes configuration, compiling route patterns into regular expressions
74
+ * for efficient matching and sorting them by specificity (more slashes first).
62
75
  * @param {Object} value
63
76
  * @param {Object} oldValue
64
77
  * @protected
@@ -78,7 +91,13 @@ class Controller extends Base {
78
91
  if (key.toLowerCase() === 'default'){
79
92
  me.defaultRoute = value[key]
80
93
  } else {
81
- me.handleRoutes[key] = new RegExp(key.replace(routeParamRegex, '([\\w-.]+)')+'$')
94
+ me.handleRoutes[key] = new RegExp(key.replace(regexRouteParam, (match, isWildcard, paramName) => {
95
+ if (isWildcard || paramName.startsWith('*')) {
96
+ return '(.*)'
97
+ } else {
98
+ return '([\\w-.]+)'
99
+ }
100
+ }))
82
101
  }
83
102
  })
84
103
  }
@@ -88,21 +107,19 @@ class Controller extends Base {
88
107
  */
89
108
  destroy(...args) {
90
109
  HashHistory.un('change', this.onHashChange, this);
91
-
92
110
  super.destroy(...args)
93
111
  }
94
112
 
95
113
  /**
96
- *
114
+ * @returns {Promise<void>}
97
115
  */
98
- async onConstructed() {
116
+ async initAsync() {
117
+ await super.initAsync();
118
+
99
119
  let me = this,
100
120
  {defaultHash, windowId} = me,
101
121
  currentHash = HashHistory.first(windowId);
102
122
 
103
- // get outside the construction chain => a related cmp & vm has to be constructed too
104
- await me.timeout(1);
105
-
106
123
  if (currentHash) {
107
124
  if (currentHash.windowId === windowId) {
108
125
  await me.onHashChange(currentHash, null)
@@ -118,9 +135,10 @@ class Controller extends Base {
118
135
  }
119
136
 
120
137
  /**
121
- * Placeholder method which gets triggered when the hash inside the browser url changes
122
- * @param {Object} value
123
- * @param {Object} oldValue
138
+ * Handles changes in the browser's URL hash. It identifies the most specific matching route
139
+ * and dispatches the corresponding handler, optionally executing a preHandler first.
140
+ * @param {Object} value - The new hash history entry.
141
+ * @param {Object} oldValue - The previous hash history entry.
124
142
  */
125
143
  async onHashChange(value, oldValue) {
126
144
  // We only want to trigger hash changes for the same browser window (SharedWorker context)
@@ -129,63 +147,67 @@ class Controller extends Base {
129
147
  }
130
148
 
131
149
  let me = this,
132
- counter = 0,
133
- hasRouteBeenFound = false,
134
150
  {handleRoutes, routes} = me,
135
151
  routeKeys = Object.keys(handleRoutes),
136
- routeKeysLength = routeKeys.length,
137
- arrayParamIds, arrayParamValues, handler, key, paramObject, preHandler, responsePreHandler, result, route;
152
+ bestMatch = null,
153
+ bestMatchKey = null,
154
+ bestMatchParams = null;
138
155
 
139
- while (routeKeysLength > 0 && counter < routeKeysLength && !hasRouteBeenFound) {
140
- key = routeKeys[counter];
141
- handler = null;
142
- preHandler = null;
143
- responsePreHandler = null;
144
- paramObject = {};
145
- result = value.hashString.match(handleRoutes[key]);
156
+ for (let i = 0; i < routeKeys.length; i++) {
157
+ const key = routeKeys[i];
158
+ const result = value.hashString.match(handleRoutes[key]);
146
159
 
147
160
  if (result) {
148
- arrayParamIds = key.match(routeParamRegex);
149
- arrayParamValues = result.splice(1, result.length - 1);
150
-
151
- if (arrayParamIds && arrayParamIds.length !== arrayParamValues.length) {
152
- throw 'Number of IDs and number of Values do not match'
161
+ const
162
+ arrayParamIds = key.match(regexRouteParam),
163
+ arrayParamValues = result.splice(1, result.length - 1),
164
+ paramObject = {};
165
+
166
+ if (arrayParamIds) {
167
+ for (let j = 0; j < arrayParamIds.length; j++) {
168
+ const paramMatch = arrayParamIds[j].match(regexParamNameExtraction);
169
+
170
+ if (paramMatch) {
171
+ const paramName = paramMatch[2];
172
+ paramObject[paramName] = arrayParamValues[j];
173
+ }
174
+ }
153
175
  }
154
176
 
155
- for (let i = 0; arrayParamIds && i < arrayParamIds.length; i++) {
156
- paramObject[arrayParamIds[i].substring(1, arrayParamIds[i].length - 1)] = arrayParamValues[i]
177
+ // Logic to determine the best matching route:
178
+ // 1. Prioritize routes that match a longer string (more specific match).
179
+ // 2. If lengths are equal, prioritize routes with more slashes (deeper nesting).
180
+ if (!bestMatch || (result[0].length > bestMatch[0].length) ||
181
+ (result[0].length === bestMatch[0].length && (key.match(regexAmountSlashes) || []).length > (bestMatchKey.match(regexAmountSlashes) || []).length)) {
182
+ bestMatch = result;
183
+ bestMatchKey = key;
184
+ bestMatchParams = paramObject;
157
185
  }
186
+ }
187
+ }
158
188
 
159
- route = routes[key];
160
-
161
- if (Neo.isString(route)) {
162
- handler = route;
163
- responsePreHandler = true
164
- } else if (Neo.isObject(route)) {
165
- handler = route.handler;
166
- preHandler = route.preHandler
167
- }
189
+ if (bestMatch) {
190
+ const route = routes[bestMatchKey];
191
+ let handler = null,
192
+ preHandler = null;
168
193
 
169
- hasRouteBeenFound = true
194
+ if (Neo.isString(route)) {
195
+ handler = route
196
+ } else if (Neo.isObject(route)) {
197
+ handler = route.handler;
198
+ preHandler = route.preHandler
170
199
  }
171
200
 
172
- counter++
173
- }
201
+ let responsePreHandler = true;
174
202
 
175
- // execute
176
- if (hasRouteBeenFound) {
177
203
  if (preHandler) {
178
- responsePreHandler = await me[preHandler]?.call(me, paramObject, value, oldValue)
179
- } else {
180
- responsePreHandler = true
204
+ responsePreHandler = await me[preHandler]?.call(me, bestMatchParams, value, oldValue)
181
205
  }
182
206
 
183
207
  if (responsePreHandler) {
184
- await me[handler]?.call(me, paramObject, value, oldValue)
208
+ await me[handler]?.call(me, bestMatchParams, value, oldValue)
185
209
  }
186
- }
187
-
188
- if (routeKeys.length > 0 && !hasRouteBeenFound) {
210
+ } else {
189
211
  if (me.defaultRoute) {
190
212
  me[me.defaultRoute]?.(value, oldValue)
191
213
  } else {
@@ -195,22 +217,24 @@ class Controller extends Base {
195
217
  }
196
218
 
197
219
  /**
198
- * Placeholder method which gets triggered when an invalid route is called
199
- * @param {Object} value
200
- * @param {Object} oldValue
220
+ * Placeholder method invoked when no matching route is found for the current URL hash.
221
+ * Controllers can override this to implement custom behavior for unhandled routes.
222
+ * @param {Object} value - The current hash history entry.
223
+ * @param {Object} oldValue - The previous hash history entry.
201
224
  */
202
225
  onNoRouteFound(value, oldValue) {
203
226
 
204
227
  }
205
228
 
206
229
  /**
207
- * Internal helper method to sort routes by their amount of slashes
208
- * @param {String} route1
209
- * @param {String} route2
210
- * @returns {Number}
230
+ * Internal helper method to sort routes by their specificity.
231
+ * Routes with more slashes are considered more specific and are prioritized.
232
+ * @param {String} route1 - The first route string to compare.
233
+ * @param {String} route2 - The second route string to compare.
234
+ * @returns {Number} A negative value if route1 is more specific, a positive value if route2 is more specific, or 0 if they have equal specificity.
211
235
  */
212
236
  #sortRoutes(route1, route2) {
213
- return (route1.match(amountSlashesRegex) || []).length - (route2.match(amountSlashesRegex)|| []).length
237
+ return (route1.match(regexAmountSlashes) || []).length - (route2.match(regexAmountSlashes)|| []).length
214
238
  }
215
239
  }
216
240