@lightningjs/blits 2.1.3 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## v2.3.0
4
+
5
+ _20 may 2026_
6
+
7
+ - Refactored router functionality
8
+ - Replaced `window` reference with `self` reference in announcer
9
+
10
+ ## v2.2.0
11
+
12
+ _11 may 2026_
13
+
14
+ - Exposed built-in reactivity to be used in custom Blits plugins
15
+ - Cleaned up unused key names from default key mapping
16
+ - Improved documentation
17
+ - Fixed reactive color not working for components
18
+ - Added EOL guard for Blits elements to prevent potential race conditions
19
+ - Updated MSDF generator version in boilerplate
20
+ - Fixed / improved GH workflow for automated tests
21
+ - Upgraded renderer to 3.0.3
22
+
23
+
3
24
  ## v2.1.3
4
25
 
5
26
  _16 apr 2026_
package/index.d.ts CHANGED
@@ -405,6 +405,11 @@ declare module '@lightningjs/blits' {
405
405
  */
406
406
  readonly $parent: ComponentBase | undefined,
407
407
 
408
+ /**
409
+ * Human-readable identifier for this component instance (e.g. "MyComponent_0")
410
+ */
411
+ readonly $componentId: string,
412
+
408
413
  /**
409
414
  * Indicates whether the component currently is hovered
410
415
  *
@@ -536,6 +541,14 @@ declare module '@lightningjs/blits' {
536
541
  * Router instance
537
542
  */
538
543
  $router: Router
544
+ /**
545
+ * Creates a reactive object. Changes to properties on the returned object
546
+ * will automatically trigger re-renders in any component that accesses them.
547
+ *
548
+ * Pre-configured with the app's reactivity mode and global scope.
549
+ * Primarily intended for use inside custom plugins.
550
+ */
551
+ $reactive: <T extends Record<string, any>>(target: T) => T
539
552
  /**
540
553
  * Dynamically set the size of a component holder node
541
554
  */
@@ -1331,7 +1344,7 @@ declare module '@lightningjs/blits' {
1331
1344
  * Should do all necessary setup and ideally return an object with
1332
1345
  * properties or methods that can be used in the App
1333
1346
  */
1334
- plugin: () => any
1347
+ plugin: (options?: any) => any
1335
1348
  }
1336
1349
 
1337
1350
 
@@ -1389,7 +1402,7 @@ declare module '@lightningjs/blits' {
1389
1402
  * Object with options to be passed into the plugin instantiation method.
1390
1403
  * Can be used to set default values
1391
1404
  */
1392
- Plugin(plugin: Plugin, nameOrOptions?: string | object , options?: object) : void
1405
+ Plugin(plugin: Plugin | ((options?: any) => any), nameOrOptions?: string | object , options?: object) : void
1393
1406
  }
1394
1407
 
1395
1408
  const Blits: Blits;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightningjs/blits",
3
- "version": "2.1.3",
3
+ "version": "2.3.0",
4
4
  "description": "Blits: The Lightning 3 App Development Framework",
5
5
  "bin": "bin/index.js",
6
6
  "exports": {
@@ -75,7 +75,7 @@
75
75
  "tape": "^5.5.0"
76
76
  },
77
77
  "dependencies": {
78
- "@lightningjs/renderer": "^3.0.2",
78
+ "@lightningjs/renderer": "^3.0.3",
79
79
  "magic-string": "^0.30.21"
80
80
  },
81
81
  "repository": {
@@ -27,7 +27,7 @@ let debounce = null
27
27
 
28
28
  // Global default utterance options
29
29
  let globalDefaultOptions = {
30
- enableUtteranceKeepAlive: !/android/i.test((window.navigator || {}).userAgent || ''),
30
+ enableUtteranceKeepAlive: !/android/i.test((self.navigator || {}).userAgent || ''),
31
31
  }
32
32
 
33
33
  const noopAnnouncement = {
@@ -17,7 +17,7 @@
17
17
 
18
18
  import { Log } from '../lib/log.js'
19
19
 
20
- const syn = window.speechSynthesis
20
+ const syn = self.speechSynthesis
21
21
 
22
22
  const utterances = new Map() // id -> { utterance, timer, ignoreResume }
23
23
 
@@ -22,8 +22,22 @@ import router from './router.js'
22
22
  import announcer from './announcer.js'
23
23
  import utils from './utils.js'
24
24
  import symbols from '../../lib/symbols.js'
25
+ import { reactive } from '../../lib/reactivity/reactive.js'
26
+ import Settings from '../../settings.js'
25
27
 
26
- export const shared = { ...events, ...router, ...announcer }
28
+ export const shared = {
29
+ ...events,
30
+ ...router,
31
+ ...announcer,
32
+ $reactive: {
33
+ value: function (target) {
34
+ return reactive(target, Settings.get('reactivityMode'), true)
35
+ },
36
+ writable: false,
37
+ enumerable: true,
38
+ configurable: false,
39
+ },
40
+ }
27
41
 
28
42
  export default Object.defineProperties(
29
43
  {
package/src/component.js CHANGED
@@ -432,6 +432,10 @@ const Component = (name = required('name'), config = required('config')) => {
432
432
  */
433
433
  const factory = (options = {}, parentEl, parentComponent, rootComponent) => {
434
434
  if (Base[symbols['launched']] === false) {
435
+ // create a context with shared properties so this.$reactive (etc.)
436
+ // is available during plugin initialization
437
+ const pluginContext = Object.defineProperties({}, shared)
438
+
435
439
  // Register user defined plugins once on the Base object (after launch)
436
440
  const pluginKeys = Object.keys(plugins)
437
441
  const pluginKeysLength = pluginKeys.length
@@ -449,8 +453,8 @@ const Component = (name = required('name'), config = required('config')) => {
449
453
  const plugin = plugins[pluginName]
450
454
 
451
455
  pluginInstances[prefixedPluginName] = {
452
- // instantiate the plugin, passing in provided options
453
- value: Object.defineProperties(plugin.plugin(plugin.options), shared),
456
+ // instantiate the plugin with shared context, then apply shared to the result
457
+ value: Object.defineProperties(plugin.plugin.call(pluginContext, plugin.options), shared),
454
458
  writable: false,
455
459
  enumerable: true,
456
460
  configurable: true,
@@ -629,6 +629,7 @@ const Element = {
629
629
  * @param {Object} data - Framework inspector metadata to merge
630
630
  */
631
631
  setInspectorMetadata(data) {
632
+ if (this.eol === true) return
632
633
  // Early return if inspector not enabled (performance optimization)
633
634
  if (inspectorEnabled !== true) {
634
635
  return
@@ -666,6 +667,7 @@ const Element = {
666
667
  * @returns {void}
667
668
  */
668
669
  set(prop, value) {
670
+ if (this.eol === true) return
669
671
  if (value === undefined) return
670
672
  // @ts-ignore
671
673
  if (this.props.raw[prop] === value) return
@@ -701,6 +703,8 @@ const Element = {
701
703
  }
702
704
  },
703
705
  animate(prop, value, transition) {
706
+ if (this.eol === true) return
707
+
704
708
  // Clear any existing debounce timeout for this property
705
709
  if (this.debounceTimeouts[prop] !== undefined) {
706
710
  clearTimeout(this.debounceTimeouts[prop])
@@ -812,6 +816,9 @@ const Element = {
812
816
  f.start()
813
817
  },
814
818
  destroy() {
819
+ if (this.eol === true) return
820
+ this.eol = true
821
+
815
822
  if (this.node === null) return
816
823
 
817
824
  Log.debug('Deleting Node', this.nodeId)
@@ -903,6 +910,7 @@ export default (config, component) => {
903
910
  inspectorEnabled = Settings.get('inspector', false)
904
911
  }
905
912
  return Object.assign(Object.create(Element), {
913
+ eol: false,
906
914
  props: Object.assign(Object.create(propsTransformer), { props: {} }),
907
915
  scheduledTransitions: {},
908
916
  debounceTimeouts: {},
@@ -225,7 +225,6 @@ const generateElementCode = function (
225
225
  const value = templateObject[key]
226
226
 
227
227
  if (isReactiveKey(key)) {
228
- if (options.holder && key === ':color') return
229
228
  if (options.holder) {
230
229
  this.effectsCode.push(`
231
230
  if(typeof skips === 'undefined' || (typeof skips[${counter}] === 'undefined' ||
@@ -15,7 +15,7 @@
15
15
  * SPDX-License-Identifier: Apache-2.0
16
16
  */
17
17
 
18
- import { default as fadeInFadeOutTransition } from './transitions/fadeInOut.js'
18
+ import { getHash, isObject, isString, matchHash } from './utils.js'
19
19
  import { reactive } from '../lib/reactivity/reactive.js'
20
20
 
21
21
  import symbols from '../lib/symbols.js'
@@ -85,157 +85,6 @@ let previousFocus
85
85
  // Skips internal router navigation when set to true only for the next "navigate"
86
86
  // execution, needed for window.history management
87
87
  let preventHashChangeNavigation = false
88
- /**
89
- * Get the current hash
90
- * @returns {Hash}
91
- */
92
- export const getHash = (hash) => {
93
- if (!hash) hash = '/'
94
- const hashParts = hash.replace(/^#/, '').split('?')
95
- return {
96
- path: hashParts[0],
97
- queryParams: new URLSearchParams(hashParts[1]),
98
- hash: hash,
99
- }
100
- }
101
-
102
- const normalizePath = (path) => {
103
- return (
104
- path
105
- // remove leading and trailing slashes
106
- .replace(/^\/+|\/+$/g, '')
107
- .toLowerCase()
108
- )
109
- }
110
-
111
- /**
112
- * Check if a value is an object
113
- * @param {any} v
114
- * @returns {boolean} True if v is an object
115
- */
116
- const isObject = (v) => typeof v === 'object' && v !== null
117
-
118
- /**
119
- * Check if a value is a function
120
- * @param {any} v
121
- * @returns {boolean} True if v is a string
122
- */
123
- const isString = (v) => typeof v === 'string'
124
-
125
- const queryParamsToObject = (queryParams) => {
126
- if (!queryParams) return {}
127
- const object = {}
128
- const queryParamsEntries = [...queryParams.entries()]
129
- for (let i = 0; i < queryParamsEntries.length; i++) {
130
- object[queryParamsEntries[i][0]] = queryParamsEntries[i][1]
131
- }
132
-
133
- return object
134
- }
135
-
136
- /**
137
- * Match a path to a route
138
- *
139
- * @param {object} hashObject
140
- * @param {Route[]} routes
141
- * @returns {Route}
142
- */
143
- export const matchHash = ({ hash, path, queryParams }, routes = []) => {
144
- // remove trailing slashes
145
- const originalPath = path.replace(/^\/+|\/+$/g, '')
146
- const originalNormalizedPath = normalizePath(path)
147
-
148
- const override = {
149
- hash: hash,
150
- queryParams: queryParamsToObject(queryParams),
151
- path: path,
152
- }
153
-
154
- /** @type {boolean|Route} */
155
- let matchingRoute = false
156
- let i = 0
157
- while (!matchingRoute && i < routes.length) {
158
- const route = routes[i]
159
-
160
- const normalizedPath = normalizePath(route.path)
161
- if (normalizePath(normalizedPath) === originalNormalizedPath) {
162
- matchingRoute = makeRouteObject(route, override)
163
- } else if (normalizedPath.indexOf(':') > -1) {
164
- // match dynamic route parts
165
- const dynamicRouteParts = [...normalizedPath.matchAll(/:([^\s/]+)/gi)]
166
-
167
- // construct a regex for the route with dynamic parts
168
- let dynamicRoutePartsRegex = normalizedPath
169
- dynamicRouteParts.reverse().forEach((part) => {
170
- dynamicRoutePartsRegex =
171
- dynamicRoutePartsRegex.substring(0, part.index) +
172
- '([^\\s/]+)' +
173
- dynamicRoutePartsRegex.substring(part.index + part[0].length)
174
- })
175
-
176
- dynamicRoutePartsRegex = '^' + dynamicRoutePartsRegex
177
-
178
- // test if the constructed regex matches the path
179
- const match = originalPath.match(new RegExp(`${dynamicRoutePartsRegex}`, 'i'))
180
-
181
- if (match) {
182
- // map the route params to a params object
183
- override.params = dynamicRouteParts.reverse().reduce((acc, part, index) => {
184
- acc[part[1]] = match[index + 1]
185
- return acc
186
- }, {})
187
-
188
- matchingRoute = makeRouteObject(route, override)
189
- }
190
- } else if (normalizedPath.endsWith('*')) {
191
- const regex = new RegExp(normalizedPath.replace(/\/?\*/, '/?([^\\s]*)'), 'i')
192
- const match = originalNormalizedPath.match(regex)
193
-
194
- if (match) {
195
- override.params = {}
196
- if (match[1]) override.params.path = match[1]
197
- matchingRoute = makeRouteObject(route, override)
198
- }
199
- }
200
- i++
201
- }
202
-
203
- // @ts-ignore - Remove me when we have a better way to handle this
204
- return matchingRoute
205
- }
206
-
207
- /**
208
- * Default Route options
209
- *
210
- */
211
- const defaultOptions = {
212
- inHistory: true,
213
- keepAlive: false,
214
- passFocus: true,
215
- reuseComponent: false,
216
- }
217
-
218
- const makeRouteObject = (route, overrides) => {
219
- // FIX: exclude keepAlive from the destination route options. Unlike other
220
- // overrides, keepAlive applies to the route being LEFT, not the destination.
221
- // It is consumed by removeView() instead.
222
- const { keepAlive: _keepAlive, ...destOverrides } = overrideOptions // eslint-disable-line no-unused-vars
223
-
224
- const cleanRoute = {
225
- hash: overrides.hash,
226
- path: route.path,
227
- component: route.component,
228
- transition: 'transition' in route ? route.transition : fadeInFadeOutTransition,
229
- options: { ...defaultOptions, ...route.options, ...destOverrides },
230
- announce: route.announce || false,
231
- hooks: route.hooks || {},
232
- data: { ...route.data, ...navigationData, ...overrides.queryParams },
233
- params: overrides.params || {},
234
- meta: route.meta || {},
235
- }
236
-
237
- return cleanRoute
238
- }
239
88
 
240
89
  /**
241
90
  * Navigate to a route
@@ -250,316 +99,252 @@ const makeRouteObject = (route, overrides) => {
250
99
  * @returns {Promise<void>}
251
100
  */
252
101
  export const navigate = async function () {
102
+ // early return when in preventHashChange mode
103
+ if (preventHashChangeNavigation !== false) return
104
+ // early return when no routes
105
+ if (!this[symbols.parent][symbols.routes] || this[symbols.parent][symbols.routes].length === 0)
106
+ return
107
+
108
+ state.navigating = true
109
+
253
110
  Announcer.stop()
254
111
  Announcer.clear()
255
- state.navigating = true
256
- let reuse = false
257
- if (preventHashChangeNavigation === false && this[symbols.parent][symbols.routes]) {
258
- let previousRoute = currentRoute //? Object.assign({}, currentRoute) : undefined
259
- let route = matchHash(getHash(location.hash), this[symbols.parent][symbols.routes])
260
-
261
- currentRoute = route
262
-
263
- if (route) {
264
- const currentPath = currentRoute.path
265
- let beforeEachResult
266
- if (this[symbols.parent][symbols.routerHooks]) {
267
- const hooks = this[symbols.parent][symbols.routerHooks]
268
- if (hooks.beforeEach) {
269
- try {
270
- beforeEachResult = await hooks.beforeEach.call(
271
- this[symbols.parent],
272
- route,
273
- previousRoute
274
- )
275
- if (isString(beforeEachResult)) {
276
- currentRoute = previousRoute
277
- to(beforeEachResult)
278
- return
279
- }
280
- } catch (error) {
281
- Log.error('Error or Rejected Promise in "BeforeEach" Hook', error)
282
-
283
- if (history.length > 0) {
284
- preventHashChangeNavigation = true
285
- currentRoute = previousRoute
286
- window.history.back()
287
-
288
- navigatingBack = false
289
- state.navigating = false
290
- return
291
- }
292
- }
293
- // If the resolved result is an object, redirect if the path in the object was changed
294
- if (isObject(beforeEachResult) === true && beforeEachResult.path !== currentPath) {
295
- currentRoute = previousRoute
296
- to(beforeEachResult.path, beforeEachResult.data, beforeEachResult.options)
297
- return
298
- }
299
- // If the resolved result is false, cancel navigation
300
- if (beforeEachResult === false && history.length > 0) {
301
- preventHashChangeNavigation = true
302
- currentRoute = previousRoute
303
- window.history.back()
304
-
305
- navigatingBack = false
306
- state.navigating = false
307
- return
308
- }
309
- }
310
- }
311
112
 
312
- let beforeHookOutput
313
- if (route.hooks.before) {
314
- try {
315
- beforeHookOutput = await route.hooks.before.call(
316
- this[symbols.parent],
317
- route,
318
- previousRoute
319
- )
320
- if (isString(beforeHookOutput)) {
321
- currentRoute = previousRoute
322
- to(beforeHookOutput)
323
- return
324
- }
325
- } catch (error) {
326
- Log.error('Error or Rejected Promise in "Before" Hook', error)
113
+ const hash = getHash(location.hash)
114
+ // try to find the route
115
+ let route = matchHash(hash, this[symbols.parent][symbols.routes], overrideOptions, navigationData)
327
116
 
328
- if (history.length > 0) {
329
- preventHashChangeNavigation = true
330
- currentRoute = previousRoute
331
- window.history.back()
117
+ // early return when route not found
118
+ if (route === false) {
119
+ state.navigating = false
332
120
 
333
- navigatingBack = false
334
- state.navigating = false
335
- return
336
- }
337
- }
338
- // If the resolved result is an object, redirect if the path in the object was changed
339
- if (isObject(beforeHookOutput) === true && beforeHookOutput.path !== currentPath) {
340
- currentRoute = previousRoute
341
- to(beforeHookOutput.path, beforeHookOutput.data, beforeHookOutput.options)
342
- return
343
- }
344
- // If the resolved result is false, cancel navigation
345
- if (beforeHookOutput === false && history.length > 0) {
346
- preventHashChangeNavigation = true
347
- currentRoute = previousRoute
348
- window.history.back()
349
-
350
- navigatingBack = false
351
- state.navigating = false
352
- return
353
- }
354
- }
121
+ Log.error(`Route ${hash.hash} not found`)
122
+ const routerHooks = this[symbols.parent][symbols.routerHooks]
123
+ if (routerHooks && typeof routerHooks.error === 'function') {
124
+ routerHooks.error.call(this[symbols.parent], `Route ${hash.hash} not found`)
125
+ }
126
+ return
127
+ }
355
128
 
356
- // add the previous route (technically still the current route at this point)
357
- // into the history stack when inHistory is true and we're not navigating back
358
- //
359
- // FIX: use truthy check instead of `!== undefined` because matchHash()
360
- // can return `false`, which survives `!== undefined` but has no `.options`.
361
- if (
362
- previousRoute &&
363
- previousRoute.options &&
364
- previousRoute.options.inHistory === true &&
365
- navigatingBack === false
366
- ) {
367
- history.push(previousRoute)
368
- }
129
+ let reuse = false
130
+ let previousRoute = currentRoute
131
+ currentRoute = route
132
+ const currentPath = currentRoute.path
133
+
134
+ // execute before each hook
135
+ const beforeEachResult = await executeBeforeHook(
136
+ this[symbols.parent][symbols.routerHooks],
137
+ 'beforeEach',
138
+ this[symbols.parent],
139
+ route,
140
+ previousRoute,
141
+ currentPath
142
+ )
143
+ if (beforeEachResult === false) {
144
+ preventHashChangeNavigation = false
145
+ return
146
+ }
369
147
 
370
- // a transition can be a function returning a dynamic transition object
371
- // based on current and previous route
372
- if (typeof route.transition === 'function') {
373
- route.transition = route.transition(previousRoute, route)
374
- }
148
+ // execute before route hook
149
+ const beforeResult = await executeBeforeHook(
150
+ route.hooks,
151
+ 'before',
152
+ this[symbols.parent],
153
+ route,
154
+ previousRoute,
155
+ currentPath
156
+ )
157
+ if (beforeResult === false) {
158
+ preventHashChangeNavigation = false
159
+ return
160
+ }
375
161
 
376
- /** @type {import('../engines/L3/element.js').BlitsElement} */
377
- let holder
378
-
379
- /** @type {RouteViewWithOptionalDefault|undefined|null} */
380
- let view
381
- let focus
382
- // when navigating back let's see if we're navigating back to a route that was kept alive
383
- if (navigatingBack === true && navigatingBackTo !== undefined) {
384
- view = navigatingBackTo.view
385
- focus = navigatingBackTo.focus
386
- navigatingBackTo = null
387
- }
388
- // merge props with potential route params, navigation data and route data to be injected into the component instance
389
- const props = {
390
- ...this[symbols.props],
391
- ...route.params,
392
- ...route.data,
393
- }
162
+ // add the previous route (technically still the current route at this point)
163
+ // into the history stack when inHistory is true and we're not navigating back
164
+ //
165
+ // FIX: use truthy check instead of `!== undefined` because matchHash()
166
+ // can return `false`, which survives `!== undefined` but has no `.options`.
167
+ if (
168
+ previousRoute &&
169
+ previousRoute.options &&
170
+ previousRoute.options.inHistory === true &&
171
+ navigatingBack === false
172
+ ) {
173
+ history.push(previousRoute)
174
+ }
394
175
 
395
- // see if the component of the previous route can be reused for the
396
- // current route
397
- if (
398
- previousRoute &&
399
- route.options.reuseComponent === true &&
400
- route.options.keepAlive !== true &&
401
- route.component === previousRoute.component
402
- ) {
403
- reuse = true
404
- view = this[symbols.children][this[symbols.children].length - 1]
405
- for (const prop in props) {
406
- view[symbols.props][prop] = props[prop]
407
- }
408
- }
176
+ // a transition can be a function returning a dynamic transition object
177
+ // based on current and previous route
178
+ if (typeof route.transition === 'function') {
179
+ route.transition = route.transition(previousRoute, route)
180
+ }
409
181
 
410
- // Announce route change if a message has been specified for this route
411
- if (route.announce) {
412
- if (typeof route.announce === 'string') {
413
- route.announce = {
414
- message: route.announce,
415
- }
416
- }
417
- Announcer.speak(route.announce.message, route.announce.politeness)
418
- }
182
+ /** @type {import('../engines/L3/element.js').BlitsElement} */
183
+ let holder
184
+
185
+ /** @type {RouteViewWithOptionalDefault|undefined|null} */
186
+ let view
187
+ let focus
188
+ // when navigating back let's see if we're navigating back to a route that was kept alive
189
+ if (navigatingBack === true && navigatingBackTo !== undefined) {
190
+ view = navigatingBackTo.view
191
+ focus = navigatingBackTo.focus
192
+ navigatingBackTo = null
193
+ }
194
+ // merge props with potential route params, navigation data and route data to be injected into the component instance
195
+ const props = {
196
+ ...this[symbols.props],
197
+ ...route.params,
198
+ ...route.data,
199
+ }
419
200
 
420
- // Update router state after announcements and final route resolution,
421
- // right before initializing or restoring the view
422
- state.path = route.path
423
- state.params = Object.keys(route.params).length === 0 ? null : route.params
424
- state.hash = route.hash
425
- state.data = null
426
- state.data = route.data || {}
427
-
428
- if (!view) {
429
- // create a holder element for the new view
430
- holder = stage.element({ parent: this[symbols.children][0] })
431
- holder.populate({})
432
- holder.set('w', '100%')
433
- holder.set('h', '100%')
434
-
435
- view = await route.component({ props }, holder, this)
436
-
437
- // is the component a dynamic module?
438
- if (view[Symbol.toStringTag] === 'Module') {
439
- if (view.default && typeof view.default === 'function') {
440
- view = view.default({ props }, holder, this)
441
- } else {
442
- Log.error("Dynamic import doesn't have a default export or default is not a function")
443
- }
444
- }
201
+ // see if the component of the previous route can be reused for the
202
+ // current route
203
+ if (
204
+ previousRoute &&
205
+ route.options.reuseComponent === true &&
206
+ route.options.keepAlive !== true &&
207
+ route.component === previousRoute.component
208
+ ) {
209
+ reuse = true
210
+ view = this[symbols.children][this[symbols.children].length - 1]
211
+ for (const prop in props) {
212
+ view[symbols.props][prop] = props[prop]
213
+ }
214
+ }
445
215
 
446
- if (typeof view === 'function') {
447
- // had to inline this because the tscompiler does not like LHS reassignments
448
- // that also change the type of the variable in a variable union
449
- view = /** @type {BlitsComponentFactory} */ (view)({ props }, holder, this)
450
- }
451
- } else {
452
- holder = view[symbols.holder]
453
-
454
- // Check, whether cached view holder's alpha prop is exists in transition or not
455
- let hasAlphaProp = false
456
- if (route.transition.before) {
457
- if (Array.isArray(route.transition.before)) {
458
- for (let i = 0; i < route.transition.before.length; i++) {
459
- if (route.transition.before[i].prop === 'alpha') {
460
- hasAlphaProp = true
461
- break
462
- }
463
- }
464
- } else if (route.transition.before.prop === 'alpha') {
216
+ // Announce route change if a message has been specified for this route
217
+ if (route.announce) {
218
+ if (typeof route.announce === 'string') {
219
+ Announcer.speak(route.announce)
220
+ } else {
221
+ Announcer.speak(route.announce.message, route.announce.politeness)
222
+ }
223
+ }
224
+
225
+ // Update router state after announcements and final route resolution,
226
+ // right before initializing or restoring the view
227
+ state.path = route.path
228
+ state.params = Object.keys(route.params).length === 0 ? null : route.params
229
+ state.hash = route.hash
230
+ state.data = null
231
+ state.data = route.data || {}
232
+
233
+ // routing to a new page (instead of routing back to a keepAlive page)
234
+ if (view === undefined) {
235
+ // create a holder element for the new view
236
+ holder = stage.element({ parent: this[symbols.children][0] })
237
+ holder.populate({})
238
+ holder.set('w', '100%')
239
+ holder.set('h', '100%')
240
+
241
+ view = await loadPage.call(this, route, holder, props)
242
+ } else {
243
+ holder = view[symbols.holder]
244
+
245
+ // Check, whether cached view holder's alpha prop is exists in transition or not
246
+ let hasAlphaProp = false
247
+ if (route.transition.before) {
248
+ if (Array.isArray(route.transition.before)) {
249
+ for (let i = 0; i < route.transition.before.length; i++) {
250
+ if (route.transition.before[i].prop === 'alpha') {
465
251
  hasAlphaProp = true
252
+ break
466
253
  }
467
254
  }
468
- // set holder alpha when alpha prop is not exists in route transition
469
- if (hasAlphaProp === false) {
470
- holder.set('alpha', 1)
471
- }
472
- }
473
-
474
- // store the new view as new child, only if we're not reusing the previous page component
475
- if (reuse === false) {
476
- this[symbols.children].push(view)
255
+ } else if (route.transition.before.prop === 'alpha') {
256
+ hasAlphaProp = true
477
257
  }
258
+ }
259
+ // set holder alpha when alpha prop is not exists in route transition
260
+ if (hasAlphaProp === false) {
261
+ holder.set('alpha', 1)
262
+ }
263
+ }
478
264
 
479
- // keep reference to the previous focus for storing in cache
480
- previousFocus = Focus.get()
265
+ // store the new view as new child, only if we're not reusing the previous page component
266
+ if (reuse === false) {
267
+ this[symbols.children].push(view)
268
+ }
481
269
 
482
- const children = this[symbols.children]
483
- this.activeView = children[children.length - 1]
270
+ // keep reference to the previous focus for storing in cache
271
+ previousFocus = Focus.get()
484
272
 
485
- // set focus to the view that we're routing to (unless explicitly disabling passing focus)
486
- if (route.options.passFocus !== false) {
487
- focus ? focus.$focus() : /** @type {BlitsComponent} */ (view).$focus()
488
- }
273
+ const children = this[symbols.children]
274
+ this.activeView = children[children.length - 1]
489
275
 
490
- // apply before settings to holder element
491
- if (route.transition.before) {
492
- if (Array.isArray(route.transition.before)) {
493
- for (let i = 0; i < route.transition.before.length; i++) {
494
- holder.set(route.transition.before[i].prop, route.transition.before[i].value)
495
- }
496
- } else {
497
- holder.set(route.transition.before.prop, route.transition.before.value)
498
- }
499
- }
276
+ // set focus to the view that we're routing to (unless explicitly disabling passing focus)
277
+ if (route.options.passFocus !== false) {
278
+ focus ? focus.$focus() : /** @type {BlitsComponent} */ (view).$focus()
279
+ }
500
280
 
501
- let shouldAnimate = false
502
-
503
- // apply out out transition on previous view if available, unless
504
- // we're reusing the prvious page component
505
- // FIX: truthy guard — previousRoute can be `false` (see history-push comment above).
506
- if (previousRoute && reuse === false) {
507
- // only animate when there is a previous route
508
- shouldAnimate = true
509
- const oldView = this[symbols.children].splice(1, 1).pop()
510
- if (oldView) {
511
- await removeView(previousRoute, oldView, route.transition.out, navigatingBack)
512
- }
513
- }
281
+ // apply starting state of transition
282
+ if (route.transition.before) {
283
+ await executeTransition(route.transition.before, holder, false)
284
+ }
514
285
 
515
- // apply in transition
516
- if (route.transition.in) {
517
- if (Array.isArray(route.transition.in)) {
518
- for (let i = 0; i < route.transition.in.length; i++) {
519
- i === route.transition.in.length - 1
520
- ? await setOrAnimate(holder, route.transition.in[i], shouldAnimate)
521
- : setOrAnimate(holder, route.transition.in[i], shouldAnimate)
522
- }
523
- } else {
524
- await setOrAnimate(holder, route.transition.in, shouldAnimate)
286
+ let shouldAnimate = false
287
+
288
+ // apply out out transition on previous view if available, unless
289
+ // we're reusing the prvious page component
290
+ // FIX: truthy guard — previousRoute can be `false` (see history-push comment above).
291
+ if (previousRoute && reuse === false) {
292
+ // only animate when there is a previous route
293
+ shouldAnimate = true
294
+ let oldView = this[symbols.children].splice(1, 1).pop()
295
+ if (oldView) {
296
+ executeTransition(previousRoute.transition.out, oldView[symbols.holder], true)
297
+
298
+ // Resolve effective keepAlive: runtime override from $router.to() takes precedence
299
+ // over the static route config option
300
+ const keepAlive =
301
+ overrideOptions.keepAlive !== undefined
302
+ ? overrideOptions.keepAlive
303
+ : previousRoute.options && previousRoute.options.keepAlive
304
+
305
+ // cache the page when it's as 'keepAlive' instead of destroying
306
+ if (
307
+ navigatingBack === false &&
308
+ keepAlive === true &&
309
+ previousRoute.options &&
310
+ previousRoute.options.inHistory === true
311
+ ) {
312
+ const historyItem = history[history.length - 1]
313
+ if (historyItem !== undefined) {
314
+ historyItem.view = oldView
315
+ historyItem.focus = previousFocus
525
316
  }
526
317
  }
527
318
 
528
- if (this[symbols.parent][symbols.routerHooks]) {
529
- const hooks = this[symbols.parent][symbols.routerHooks]
530
- if (hooks.afterEach) {
531
- try {
532
- await hooks.afterEach.call(
533
- this[symbols.parent],
534
- route, // to
535
- previousRoute // from
536
- )
537
- } catch (error) {
538
- Log.error('Error in "AfterEach" Hook', error)
539
- }
540
- }
319
+ /* Destroy the view in the following cases:
320
+ * 1. Navigating forward, and the previous route is not configured with "keep alive" set to true.
321
+ * 2. Navigating back, and the previous route is configured with "keep alive" set to true.
322
+ * 3. Navigating back, and the previous route is not configured with "keep alive" set to true.
323
+ */
324
+ if (previousRoute.options && (keepAlive !== true || navigatingBack === true)) {
325
+ oldView.destroy()
326
+ oldView = null
541
327
  }
542
328
 
543
- if (route.hooks.after) {
544
- try {
545
- await route.hooks.after.call(
546
- this[symbols.parent],
547
- route, // to
548
- previousRoute // from
549
- )
550
- } catch (error) {
551
- Log.error('Error or Rejected Promise in "After" Hook', error)
552
- }
553
- }
554
- } else {
555
- Log.error(`Route ${route.hash} not found`)
556
- const routerHooks = this[symbols.parent][symbols.routerHooks]
557
- if (routerHooks && typeof routerHooks.error === 'function') {
558
- routerHooks.error.call(this[symbols.parent], `Route ${route.hash} not found`)
559
- }
329
+ previousFocus = null
560
330
  }
561
331
  }
562
332
 
333
+ // apply in transition
334
+ if (route.transition.in) await executeTransition(route.transition.in, holder, shouldAnimate)
335
+
336
+ // execute after each Hook
337
+ await executeAfterHook(
338
+ this[symbols.parent][symbols.routerHooks],
339
+ 'afterEach',
340
+ this[symbols.parent],
341
+ route,
342
+ previousRoute
343
+ )
344
+
345
+ // execute after route Hook
346
+ await executeAfterHook(route.hooks, 'after', this[symbols.parent], route, previousRoute)
347
+
563
348
  // Clear module-level variables after removeView has consumed them.
564
349
  // Placed here so it executes for all navigation flows, not only when
565
350
  // previousRoute exists and reuse is false.
@@ -572,81 +357,118 @@ export const navigate = async function () {
572
357
  preventHashChangeNavigation = false
573
358
  }
574
359
 
575
- /**
576
- * Remove the currently active view
577
- *
578
- * @param {Route} route
579
- * @param {BlitsComponent} view
580
- * @param {Object} transition
581
- */
582
- const removeView = async (route, view, transition, navigatingBack) => {
583
- // apply out transition
584
- if (transition) {
585
- if (Array.isArray(transition)) {
586
- for (let i = 0; i < transition.length; i++) {
587
- i === transition.length - 1
588
- ? await setOrAnimate(view[symbols.holder], transition[i])
589
- : setOrAnimate(view[symbols.holder], transition[i])
360
+ const setOrAnimate = (element, transition, shouldAnimate = true) => {
361
+ if (shouldAnimate === true) {
362
+ return new Promise((resolve) => {
363
+ // resolve the promise in the transition end-callback
364
+ // ("extending" end callback when one is already specified)
365
+ let existingEndCallback = transition.end
366
+ transition.end = (...args) => {
367
+ existingEndCallback && existingEndCallback(args)
368
+ // null the callback to enable memory cleanup
369
+ existingEndCallback = null
370
+ resolve()
590
371
  }
591
- } else {
592
- await setOrAnimate(view[symbols.holder], transition)
372
+ if (element !== undefined) element.set(transition.prop, { transition })
373
+ else resolve()
374
+ })
375
+ } else {
376
+ element !== undefined && element.set(transition.prop, transition.value)
377
+ return true
378
+ }
379
+ }
380
+
381
+ const executeBeforeHook = async function (
382
+ hooks,
383
+ hookName,
384
+ parent,
385
+ route,
386
+ previousRoute,
387
+ currentPath
388
+ ) {
389
+ let result
390
+ if (hooks && hooks[hookName]) {
391
+ try {
392
+ result = await hooks[hookName].call(parent, route, previousRoute)
393
+ if (isString(result)) {
394
+ currentRoute = previousRoute
395
+ to(result)
396
+ return false
397
+ }
398
+ } catch (error) {
399
+ Log.error(`Error or Rejected Promise in "${hookName}" Hook`, error)
400
+ if (history.length > 0) {
401
+ preventHashChangeNavigation = true
402
+ currentRoute = previousRoute
403
+ window.history.back()
404
+ navigatingBack = false
405
+ state.navigating = false
406
+ return false
407
+ }
408
+ }
409
+ // If the resolved result is an object, redirect if the path in the object was changed
410
+ if (isObject(result) === true && result.path !== currentPath) {
411
+ currentRoute = previousRoute
412
+ to(result.path, result.data, result.options)
413
+ return false
414
+ }
415
+ // If the resolved result is false, cancel navigation
416
+ if (result === false && history.length > 0) {
417
+ preventHashChangeNavigation = true
418
+ currentRoute = previousRoute
419
+ window.history.back()
420
+ navigatingBack = false
421
+ state.navigating = false
422
+ return false
593
423
  }
594
424
  }
425
+ }
595
426
 
596
- // Resolve effective keepAlive: runtime override from $router.to() takes precedence
597
- // over the static route config option
598
- const keepAlive =
599
- overrideOptions.keepAlive !== undefined
600
- ? overrideOptions.keepAlive
601
- : route.options && route.options.keepAlive
427
+ const loadPage = async function (route, holder, props) {
428
+ let view = await route.component({ props }, holder, this)
602
429
 
603
- // cache the page when it's as 'keepAlive' instead of destroying
604
- if (
605
- navigatingBack === false &&
606
- route.options &&
607
- keepAlive === true &&
608
- route.options.inHistory === true
609
- ) {
610
- const historyItem = history[history.length - 1]
611
- if (historyItem !== undefined) {
612
- historyItem.view = view
613
- historyItem.focus = previousFocus
430
+ // is the component a dynamic module?
431
+ if (view[Symbol.toStringTag] === 'Module') {
432
+ if (view.default && typeof view.default === 'function') {
433
+ view = view.default({ props }, holder, this)
434
+ } else {
435
+ Log.error("Dynamic import doesn't have a default export or default is not a function")
614
436
  }
615
437
  }
616
438
 
617
- /* Destroy the view in the following cases:
618
- * 1. Navigating forward, and the previous route is not configured with "keep alive" set to true.
619
- * 2. Navigating back, and the previous route is configured with "keep alive" set to true.
620
- * 3. Navigating back, and the previous route is not configured with "keep alive" set to true.
621
- */
622
- if (route.options && (keepAlive !== true || navigatingBack === true)) {
623
- view.destroy()
624
- view = null
439
+ if (typeof view === 'function') {
440
+ // had to inline this because the tscompiler does not like LHS reassignments
441
+ // that also change the type of the variable in a variable union
442
+ view = /** @type {BlitsComponentFactory} */ (view)({ props }, holder, this)
625
443
  }
626
444
 
627
- previousFocus = null
628
- route = null
445
+ return view
629
446
  }
630
447
 
631
- const setOrAnimate = (node, transition, shouldAnimate = true) => {
632
- return new Promise((resolve) => {
633
- if (shouldAnimate === true) {
634
- // resolve the promise in the transition end-callback
635
- // ("extending" end callback when one is already specified)
636
- let existingEndCallback = transition.end
637
- transition.end = (...args) => {
638
- existingEndCallback && existingEndCallback(args)
639
- // null the callback to enable memory cleanup
640
- existingEndCallback = null
641
- resolve()
642
- }
643
- if (node !== undefined) node.set(transition.prop, { transition })
644
- else resolve()
645
- } else {
646
- node !== undefined && node.set(transition.prop, transition.value)
647
- resolve()
448
+ const executeAfterHook = async function (hooks, hookName, parent, route, previousRoute) {
449
+ if (hooks && hooks[hookName]) {
450
+ try {
451
+ await hooks[hookName].call(
452
+ parent,
453
+ route, // to
454
+ previousRoute // from
455
+ )
456
+ } catch (error) {
457
+ Log.error(`Error or Rejected Promise in "${hookName}" Hook`, error)
648
458
  }
649
- })
459
+ }
460
+ }
461
+
462
+ const executeTransition = async (transition, element, animate) => {
463
+ if (Array.isArray(transition)) {
464
+ for (let i = 0; i < transition.length; i++) {
465
+ i === transition.length - 1
466
+ ? await setOrAnimate(element, transition[i], animate)
467
+ : setOrAnimate(element, transition[i], animate)
468
+ }
469
+ } else {
470
+ await setOrAnimate(element, transition, animate)
471
+ }
650
472
  }
651
473
 
652
474
  export const to = (path, data = {}, options = {}) => {
@@ -690,7 +512,12 @@ export const back = function () {
690
512
  }
691
513
  // Construct new path to backtrack to
692
514
  path = path.replace(hashEnd, '')
693
- const route = matchHash(getHash(path), this[symbols.parent][symbols.routes])
515
+ const route = matchHash(
516
+ getHash(path),
517
+ this[symbols.parent][symbols.routes],
518
+ overrideOptions,
519
+ navigationData
520
+ )
694
521
 
695
522
  if (route && backtrack) {
696
523
  to(route.path, route.data, route.options)
@@ -0,0 +1,158 @@
1
+ import { default as fadeInFadeOutTransition } from './transitions/fadeInOut.js'
2
+
3
+ /**
4
+ * Get the current hash
5
+ * @returns {Hash}
6
+ */
7
+ export const getHash = (hash) => {
8
+ if (!hash) hash = '/'
9
+ const hashParts = hash.replace(/^#/, '').split('?')
10
+ return {
11
+ path: hashParts[0],
12
+ queryParams: new URLSearchParams(hashParts[1]),
13
+ hash: hash,
14
+ }
15
+ }
16
+
17
+ export const normalizePath = (path) => {
18
+ return (
19
+ path
20
+ // remove leading and trailing slashes
21
+ .replace(/^\/+|\/+$/g, '')
22
+ .toLowerCase()
23
+ )
24
+ }
25
+
26
+ /**
27
+ * Check if a value is an object
28
+ * @param {any} v
29
+ * @returns {boolean} True if v is an object
30
+ */
31
+ export const isObject = (v) => typeof v === 'object' && v !== null
32
+
33
+ /**
34
+ * Check if a value is a string
35
+ * @param {any} v
36
+ * @returns {boolean} True if v is a string
37
+ */
38
+ export const isString = (v) => typeof v === 'string'
39
+
40
+ export const queryParamsToObject = (queryParams) => {
41
+ if (!queryParams) return {}
42
+ const object = {}
43
+ const queryParamsEntries = [...queryParams.entries()]
44
+ for (let i = 0; i < queryParamsEntries.length; i++) {
45
+ object[queryParamsEntries[i][0]] = queryParamsEntries[i][1]
46
+ }
47
+
48
+ return object
49
+ }
50
+
51
+ /**
52
+ * Default Route options
53
+ *
54
+ */
55
+ const defaultOptions = {
56
+ inHistory: true,
57
+ keepAlive: false,
58
+ passFocus: true,
59
+ reuseComponent: false,
60
+ }
61
+
62
+ export const makeRouteObject = (route, overrides, overrideOptions, navigationData) => {
63
+ // FIX: exclude keepAlive from the destination route options. Unlike other
64
+ // overrides, keepAlive applies to the route being LEFT, not the destination.
65
+ // It is consumed by removeView() instead.
66
+ const { keepAlive: _keepAlive, ...destOverrides } = overrideOptions // eslint-disable-line no-unused-vars
67
+
68
+ const cleanRoute = {
69
+ hash: overrides.hash,
70
+ path: route.path,
71
+ component: route.component,
72
+ transition: 'transition' in route ? route.transition : fadeInFadeOutTransition,
73
+ options: { ...defaultOptions, ...route.options, ...destOverrides },
74
+ announce: route.announce || false,
75
+ hooks: route.hooks || {},
76
+ data: { ...route.data, ...navigationData, ...overrides.queryParams },
77
+ params: overrides.params || {},
78
+ meta: route.meta || {},
79
+ }
80
+
81
+ return cleanRoute
82
+ }
83
+
84
+ /**
85
+ * Match a path to a route
86
+ *
87
+ * @param {object} hashObject
88
+ * @param {Route[]} routes
89
+ * @returns {Route}
90
+ */
91
+ export const matchHash = (
92
+ { hash, path, queryParams },
93
+ routes = [],
94
+ overrideOptions = {},
95
+ navigationData = {}
96
+ ) => {
97
+ // remove trailing slashes
98
+ const originalPath = path.replace(/^\/+|\/+$/g, '')
99
+ const originalNormalizedPath = normalizePath(path)
100
+
101
+ const override = {
102
+ hash: hash,
103
+ queryParams: queryParamsToObject(queryParams),
104
+ path: path,
105
+ }
106
+
107
+ /** @type {boolean|Route} */
108
+ let matchingRoute = false
109
+ let i = 0
110
+ while (!matchingRoute && i < routes.length) {
111
+ const route = routes[i]
112
+
113
+ const normalizedPath = normalizePath(route.path)
114
+ if (normalizePath(normalizedPath) === originalNormalizedPath) {
115
+ matchingRoute = makeRouteObject(route, override, overrideOptions, navigationData)
116
+ } else if (normalizedPath.indexOf(':') > -1) {
117
+ // match dynamic route parts
118
+ const dynamicRouteParts = [...normalizedPath.matchAll(/:([^\s/]+)/gi)]
119
+
120
+ // construct a regex for the route with dynamic parts
121
+ let dynamicRoutePartsRegex = normalizedPath
122
+ dynamicRouteParts.reverse().forEach((part) => {
123
+ dynamicRoutePartsRegex =
124
+ dynamicRoutePartsRegex.substring(0, part.index) +
125
+ '([^\\s/]+)' +
126
+ dynamicRoutePartsRegex.substring(part.index + part[0].length)
127
+ })
128
+
129
+ dynamicRoutePartsRegex = '^' + dynamicRoutePartsRegex
130
+
131
+ // test if the constructed regex matches the path
132
+ const match = originalPath.match(new RegExp(`${dynamicRoutePartsRegex}`, 'i'))
133
+
134
+ if (match) {
135
+ // map the route params to a params object
136
+ override.params = dynamicRouteParts.reverse().reduce((acc, part, index) => {
137
+ acc[part[1]] = match[index + 1]
138
+ return acc
139
+ }, {})
140
+
141
+ matchingRoute = makeRouteObject(route, override, overrideOptions, navigationData)
142
+ }
143
+ } else if (normalizedPath.endsWith('*')) {
144
+ const regex = new RegExp(normalizedPath.replace(/\/?\*/, '/?([^\\s]*)'), 'i')
145
+ const match = originalNormalizedPath.match(regex)
146
+
147
+ if (match) {
148
+ override.params = {}
149
+ if (match[1]) override.params.path = match[1]
150
+ matchingRoute = makeRouteObject(route, override, overrideOptions, navigationData)
151
+ }
152
+ }
153
+ i++
154
+ }
155
+
156
+ // @ts-ignore - Remove me when we have a better way to handle this
157
+ return matchingRoute
158
+ }