@lightningjs/blits 1.38.2 → 1.39.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,18 @@
1
1
  # Changelog
2
2
 
3
+ ## v1.39.0
4
+
5
+ _16 Sept 2025_
6
+
7
+ - Refactored router to be more performant and prevent runtime route changes to affect other route instances
8
+
9
+ ## v1.38.3
10
+
11
+ _4 Sept 2025_
12
+
13
+ - Fixed clearing of `idle` and `frameTick` hooks upon component destroy
14
+ - Fixed updating of router state to before init hook
15
+
3
16
  ## v1.38.2
4
17
 
5
18
  _1 Sept 2025_
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightningjs/blits",
3
- "version": "1.38.2",
3
+ "version": "1.39.0",
4
4
  "description": "Blits: The Lightning 3 App Development Framework",
5
5
  "bin": "bin/index.js",
6
6
  "exports": {
@@ -21,6 +21,7 @@ import eventListeners from '../../lib/eventListeners.js'
21
21
  import { trigger } from '../../lib/reactivity/effect.js'
22
22
  import { Log } from '../../lib/log.js'
23
23
  import { removeGlobalEffects } from '../../lib/reactivity/effect.js'
24
+ import { renderer } from '../../launch.js'
24
25
 
25
26
  export default {
26
27
  focus: {
@@ -83,6 +84,17 @@ export default {
83
84
  this.$clearTimeouts()
84
85
  this.$clearIntervals()
85
86
  eventListeners.removeListeners(this)
87
+
88
+ const rendererEventListenersLength = this[symbols.rendererEventListeners].length
89
+ if (rendererEventListenersLength > 0) {
90
+ for (let i = 0; i < rendererEventListenersLength; i++) {
91
+ const eventListener = this[symbols.rendererEventListeners][i]
92
+ renderer.off(eventListener.event, eventListener.cb)
93
+ }
94
+ }
95
+
96
+ this[symbols.rendererEventListeners] = null
97
+
86
98
  deleteChildren(this[symbols.children])
87
99
  this[symbols.children].length = 0
88
100
  removeGlobalEffects(this[symbols.effects])
package/src/component.js CHANGED
@@ -263,19 +263,22 @@ const Component = (name = required('name'), config = required('config')) => {
263
263
  // create a reference to an array of children that are slots
264
264
  this[symbols.slots] = this[symbols.children].filter((child) => child[symbols.isSlot])
265
265
 
266
+ this[symbols.rendererEventListeners] = []
266
267
  // register hooks if component has hooks specified
267
268
  if (config.hooks) {
268
269
  // frame tick event
269
270
  if (config.hooks.frameTick) {
270
- renderer.on('frameTick', (r, data) =>
271
- emit('frameTick', this[symbols.identifier], this, [data])
272
- )
271
+ const cb = (r, data) => emit('frameTick', this[symbols.identifier], this, [data])
272
+ this[symbols.rendererEventListeners].push({ event: 'frameTick', cb })
273
+ renderer.on('frameTick', cb)
273
274
  }
274
275
 
275
276
  if (config.hooks.idle) {
276
- renderer.on('idle', () => {
277
+ const cb = () => {
277
278
  emit('idle', this[symbols.identifier], this)
278
- })
279
+ }
280
+ this[symbols.rendererEventListeners].push({ event: 'idle', cb })
281
+ renderer.on('idle', cb)
279
282
  }
280
283
 
281
284
  // inBounds event emiting a lifecycle attach event
@@ -7,6 +7,7 @@
7
7
  * @property {symbol} cursorTagStart
8
8
  * @property {symbol} computedKeys
9
9
  * @property {symbol} destroy
10
+ * @property {symbol} rendererEventListeners
10
11
  * @property {symbol} getChildren
11
12
  * @property {symbol} holder
12
13
  * @property {symbol} id
@@ -59,6 +60,7 @@ export default {
59
60
  cursorTagStart: Symbol('cursorTagStart'),
60
61
  computedKeys: Symbol('computedKeys'),
61
62
  destroy: Symbol('destroy'),
63
+ rendererEventListeners: Symbol('rendererEventListeners'),
62
64
  getChildren: Symbol('getChildren'),
63
65
  holder: Symbol('holder'),
64
66
  id: Symbol('id'),
@@ -71,25 +71,25 @@ export const state = reactive(
71
71
  * @property {BlitsComponent} focus - The focus of the route
72
72
  */
73
73
 
74
- /** @type {Map<String, CacheMapEntry>} */
75
- const cacheMap = new Map()
76
74
  const history = []
77
75
 
78
76
  let overrideOptions = {}
79
77
  let navigationData = {}
80
78
  let navigatingBack = false
79
+ let navigatingBackTo = undefined
81
80
  let previousFocus
82
81
 
83
82
  /**
84
83
  * Get the current hash
85
84
  * @returns {Hash}
86
85
  */
87
- export const getHash = () => {
88
- const hashParts = (document.location.hash || '/').replace(/^#/, '').split('?')
86
+ export const getHash = (hash) => {
87
+ if (!hash) hash = '/'
88
+ const hashParts = hash.replace(/^#/, '').split('?')
89
89
  return {
90
90
  path: hashParts[0],
91
91
  queryParams: new URLSearchParams(hashParts[1]),
92
- hash: document.location.hash,
92
+ hash: hash,
93
93
  }
94
94
  }
95
95
 
@@ -116,33 +116,50 @@ const isObject = (v) => typeof v === 'object' && v !== null
116
116
  */
117
117
  const isString = (v) => typeof v === 'string'
118
118
 
119
+ const queryParamsToObject = (queryParams) => {
120
+ if (!queryParams) return {}
121
+ const object = {}
122
+ const queryParamsEntries = [...queryParams.entries()]
123
+ for (let i = 0; i < queryParamsEntries.length; i++) {
124
+ object[queryParamsEntries[i][0]] = queryParamsEntries[i][1]
125
+ }
126
+
127
+ return object
128
+ }
129
+
119
130
  /**
120
131
  * Match a path to a route
121
132
  *
122
- * @param {string} path
133
+ * @param {object} hashObject
123
134
  * @param {Route[]} routes
124
135
  * @returns {Route}
125
136
  */
126
- export const matchHash = (path, routes = []) => {
137
+ export const matchHash = ({ hash, path, queryParams }, routes = []) => {
127
138
  // remove trailing slashes
128
139
  const originalPath = path.replace(/^\/+|\/+$/g, '')
129
- path = normalizePath(path)
140
+ const originalNormalizedPath = normalizePath(path)
141
+
142
+ const override = {
143
+ hash: hash,
144
+ queryParams: queryParamsToObject(queryParams),
145
+ path: path,
146
+ }
130
147
 
131
148
  /** @type {boolean|Route} */
132
149
  let matchingRoute = false
133
150
  let i = 0
134
151
  while (!matchingRoute && i < routes.length) {
135
152
  const route = routes[i]
136
- route.path = normalizePath(route.path)
137
- if (route.path === path) {
138
- route.params = {}
139
- matchingRoute = route
140
- } else if (route.path.indexOf(':') > -1) {
153
+
154
+ const normalizedPath = normalizePath(route.path)
155
+ if (normalizePath(normalizedPath) === originalNormalizedPath) {
156
+ matchingRoute = makeRouteObject(route, override)
157
+ } else if (normalizedPath.indexOf(':') > -1) {
141
158
  // match dynamic route parts
142
- const dynamicRouteParts = [...route.path.matchAll(/:([^\s/]+)/gi)]
159
+ const dynamicRouteParts = [...normalizedPath.matchAll(/:([^\s/]+)/gi)]
143
160
 
144
161
  // construct a regex for the route with dynamic parts
145
- let dynamicRoutePartsRegex = route.path
162
+ let dynamicRoutePartsRegex = normalizedPath
146
163
  dynamicRouteParts.reverse().forEach((part) => {
147
164
  dynamicRoutePartsRegex =
148
165
  dynamicRoutePartsRegex.substring(0, part.index) +
@@ -157,31 +174,26 @@ export const matchHash = (path, routes = []) => {
157
174
 
158
175
  if (match) {
159
176
  // map the route params to a params object
160
- route.params = dynamicRouteParts.reverse().reduce((acc, part, index) => {
177
+ override.params = dynamicRouteParts.reverse().reduce((acc, part, index) => {
161
178
  acc[part[1]] = match[index + 1]
162
179
  return acc
163
180
  }, {})
164
- matchingRoute = route
181
+
182
+ matchingRoute = makeRouteObject(route, override)
165
183
  }
166
- } else if (route.path.endsWith('*')) {
167
- const regex = new RegExp(route.path.replace(/\/?\*/, '/?([^\\s]*)'), 'i')
168
- const match = path.match(regex)
184
+ } else if (normalizedPath.endsWith('*')) {
185
+ const regex = new RegExp(normalizedPath.replace(/\/?\*/, '/?([^\\s]*)'), 'i')
186
+ const match = originalNormalizedPath.match(regex)
169
187
 
170
188
  if (match) {
171
- if (match[1]) route.params = { path: match[1] }
172
- matchingRoute = route
189
+ override.params = {}
190
+ if (match[1]) override.params.path = match[1]
191
+ matchingRoute = makeRouteObject(route, override)
173
192
  }
174
193
  }
175
194
  i++
176
195
  }
177
196
 
178
- if (matchingRoute) {
179
- matchingRoute.options = { ...defaultOptions, ...matchingRoute.options, ...overrideOptions }
180
- if (!matchingRoute.data) {
181
- matchingRoute.data = {}
182
- }
183
- }
184
-
185
197
  // @ts-ignore - Remove me when we have a better way to handle this
186
198
  return matchingRoute
187
199
  }
@@ -197,6 +209,22 @@ const defaultOptions = {
197
209
  reuseComponent: false,
198
210
  }
199
211
 
212
+ const makeRouteObject = (route, overrides) => {
213
+ const cleanRoute = {
214
+ hash: overrides.hash,
215
+ path: route.path,
216
+ component: route.component,
217
+ transition: 'transition' in route ? route.transition : fadeInFadeOutTransition,
218
+ options: { ...defaultOptions, ...route.options, ...overrideOptions },
219
+ announce: route.announce || false,
220
+ hooks: route.hooks || {},
221
+ data: { ...route.data, ...navigationData, ...overrides.queryParams },
222
+ params: overrides.params || {},
223
+ }
224
+
225
+ return cleanRoute
226
+ }
227
+
200
228
  /**
201
229
  * Navigate to a route
202
230
  *
@@ -216,27 +244,11 @@ export const navigate = async function () {
216
244
  let reuse = false
217
245
  if (this.parent[symbols.routes]) {
218
246
  let previousRoute = currentRoute //? Object.assign({}, currentRoute) : undefined
219
- const { hash, path, queryParams } = getHash()
220
- let route = matchHash(path, this.parent[symbols.routes])
247
+ let route = matchHash(getHash(document.location.hash), this.parent[symbols.routes])
221
248
 
222
249
  currentRoute = route
223
250
 
224
251
  if (route) {
225
- const queryParamsData = {}
226
- const queryParamsEntries = [...queryParams.entries()]
227
- for (let i = 0; i < queryParamsEntries.length; i++) {
228
- queryParamsData[queryParamsEntries[i][0]] = queryParamsEntries[i][1]
229
- }
230
-
231
- route.data = {
232
- ...route.data,
233
- ...navigationData,
234
- ...queryParamsData,
235
- }
236
- // Adding the location hash to the route if it exists.
237
- if (hash !== null) {
238
- route.hash = hash
239
- }
240
252
  let beforeEachResult
241
253
  if (this.parent[symbols.routerHooks]) {
242
254
  const hooks = this.parent[symbols.routerHooks]
@@ -249,30 +261,33 @@ export const navigate = async function () {
249
261
  }
250
262
  }
251
263
  // If the resolved result is an object, assign it to the target route object
252
- route = isObject(beforeEachResult) ? beforeEachResult : route
264
+ if (isObject(beforeEachResult) === true) {
265
+ route = beforeEachResult
266
+ }
253
267
 
254
268
  let beforeHookOutput
255
- if (route.hooks) {
256
- if (route.hooks.before) {
257
- beforeHookOutput = await route.hooks.before.call(this.parent, route, previousRoute)
258
- if (isString(beforeHookOutput)) {
259
- currentRoute = previousRoute
260
- to(beforeHookOutput)
261
- return
262
- }
269
+ if (route.hooks.before) {
270
+ beforeHookOutput = await route.hooks.before.call(this.parent, route, previousRoute)
271
+ if (isString(beforeHookOutput)) {
272
+ currentRoute = previousRoute
273
+ to(beforeHookOutput)
274
+ return
263
275
  }
264
276
  }
265
- route = isObject(beforeHookOutput) ? beforeHookOutput : route
277
+ // If the resolved result is an object, assign it to the target route object
278
+ if (isObject(beforeHookOutput) === true) {
279
+ route = beforeHookOutput
280
+ }
266
281
  // add the previous route (technically still the current route at this point)
267
- // into the history stack, unless navigating back or inHistory flag of route is false
268
- if (navigatingBack === false && previousRoute && previousRoute.options.inHistory === true) {
282
+ // into the history stack when inHistory is true and we're not navigating back
283
+ if (
284
+ previousRoute !== undefined &&
285
+ previousRoute.options.inHistory === true &&
286
+ navigatingBack === false
287
+ ) {
269
288
  history.push(previousRoute)
270
289
  }
271
- // apply default transition if none specified
272
- if (!('transition' in route)) {
273
- /** @ts-ignore */
274
- route.transition = fadeInFadeOutTransition
275
- }
290
+
276
291
  // a transition can be a function returning a dynamic transition object
277
292
  // based on current and previous route
278
293
  if (typeof route.transition === 'function') {
@@ -282,8 +297,14 @@ export const navigate = async function () {
282
297
  /** @type {import('../engines/L3/element.js').BlitsElement} */
283
298
  let holder
284
299
 
285
- let { view, focus } = cacheMap.get(route.hash) || {}
286
-
300
+ let view
301
+ let focus
302
+ // when navigating back let's see if we're navigating back to a route that was kept alive
303
+ if (navigatingBack === true && navigatingBackTo !== undefined) {
304
+ view = navigatingBackTo.view
305
+ focus = navigatingBackTo.focus
306
+ navigatingBackTo = null
307
+ }
287
308
  // merge props with potential route params, navigation data and route data to be injected into the component instance
288
309
  const props = {
289
310
  ...this[symbols.props],
@@ -291,11 +312,13 @@ export const navigate = async function () {
291
312
  ...route.data,
292
313
  }
293
314
 
315
+ // see if the component of the previous route can be reused for the
316
+ // current route
294
317
  if (
295
318
  previousRoute &&
296
- route.component === previousRoute.component &&
297
319
  route.options.reuseComponent === true &&
298
- route.options.keepAlive !== true
320
+ route.options.keepAlive !== true &&
321
+ route.component === previousRoute.component
299
322
  ) {
300
323
  reuse = true
301
324
  view = this[symbols.children][this[symbols.children].length - 1]
@@ -314,6 +337,14 @@ export const navigate = async function () {
314
337
  Announcer.speak(route.announce.message, route.announce.politeness)
315
338
  }
316
339
 
340
+ // Update router state after announcements and final route resolution,
341
+ // right before initializing or restoring the view
342
+ state.path = route.path
343
+ state.params = route.params || {}
344
+ state.hash = route.hash
345
+ state.data = null
346
+ state.data = route.data || {}
347
+
317
348
  if (!view) {
318
349
  // create a holder element for the new view
319
350
  holder = stage.element({ parent: this[symbols.children][0] })
@@ -323,6 +354,7 @@ export const navigate = async function () {
323
354
 
324
355
  view = await route.component({ props }, holder, this)
325
356
 
357
+ // is the component a dynamic module?
326
358
  if (view[Symbol.toStringTag] === 'Module') {
327
359
  if (view.default && typeof view.default === 'function') {
328
360
  view = view.default({ props }, holder, this)
@@ -330,6 +362,7 @@ export const navigate = async function () {
330
362
  Log.error("Dynamic import doesn't have a default export or default is not a function")
331
363
  }
332
364
  }
365
+
333
366
  if (typeof view === 'function') {
334
367
  // had to inline this because the tscompiler does not like LHS reassignments
335
368
  // that also change the type of the variable in a variable union
@@ -358,6 +391,7 @@ export const navigate = async function () {
358
391
  }
359
392
  }
360
393
 
394
+ // store the new view as new child, only if we're not reusing the previous page component
361
395
  if (reuse === false) {
362
396
  this[symbols.children].push(view)
363
397
  }
@@ -369,7 +403,7 @@ export const navigate = async function () {
369
403
  this.activeView = children[children.length - 1]
370
404
 
371
405
  // set focus to the view that we're routing to (unless explicitly disabling passing focus)
372
- if (route.options === undefined || route.options.passFocus !== false) {
406
+ if (route.options.passFocus !== false) {
373
407
  focus ? focus.$focus() : /** @type {BlitsComponent} */ (view).$focus()
374
408
  }
375
409
 
@@ -386,8 +420,9 @@ export const navigate = async function () {
386
420
 
387
421
  let shouldAnimate = false
388
422
 
389
- // apply out out transition on previous view
390
- if (previousRoute && reuse === false) {
423
+ // apply out out transition on previous view if available, unless
424
+ // we're reusing the prvious page component
425
+ if (previousRoute !== undefined && reuse === false) {
391
426
  // only animate when there is a previous route
392
427
  shouldAnimate = true
393
428
  const oldView = this[symbols.children].splice(1, 1).pop()
@@ -396,11 +431,6 @@ export const navigate = async function () {
396
431
  }
397
432
  }
398
433
 
399
- state.path = route.path
400
- state.params = route.params
401
- state.hash = hash
402
- state.data = route.data
403
-
404
434
  // apply in transition
405
435
  if (route.transition.in) {
406
436
  if (Array.isArray(route.transition.in)) {
@@ -414,10 +444,10 @@ export const navigate = async function () {
414
444
  }
415
445
  }
416
446
  } else {
417
- Log.error(`Route ${hash} not found`)
447
+ Log.error(`Route ${route.hash} not found`)
418
448
  const routerHooks = this.parent[symbols.routerHooks]
419
449
  if (routerHooks && typeof routerHooks.error === 'function') {
420
- routerHooks.error.call(this.parent, `Route ${hash} not found`)
450
+ routerHooks.error.call(this.parent, `Route ${route.hash} not found`)
421
451
  }
422
452
  }
423
453
  }
@@ -449,13 +479,19 @@ const removeView = async (route, view, transition, navigatingBack) => {
449
479
  }
450
480
 
451
481
  // cache the page when it's as 'keepAlive' instead of destroying
452
- if (navigatingBack === false && route.options && route.options.keepAlive === true) {
453
- cacheMap.set(route.hash, { view: view, focus: previousFocus })
454
- } else if (navigatingBack === true) {
455
- // remove the previous route from the cache when navigating back
456
- // cacheMap.delete will not throw an error if the route is not in the cache
457
- cacheMap.delete(route.hash)
482
+ if (
483
+ navigatingBack === false &&
484
+ route.options &&
485
+ route.options.keepAlive === true &&
486
+ route.options.inHistory === true
487
+ ) {
488
+ const historyItem = history[history.length - 1]
489
+ if (historyItem !== undefined) {
490
+ historyItem.view = view
491
+ historyItem.focus = previousFocus
492
+ }
458
493
  }
494
+
459
495
  /* Destroy the view in the following cases:
460
496
  * 1. Navigating forward, and the previous route is not configured with "keep alive" set to true.
461
497
  * 2. Navigating back, and the previous route is configured with "keep alive" set to true.
@@ -467,6 +503,7 @@ const removeView = async (route, view, transition, navigatingBack) => {
467
503
  }
468
504
 
469
505
  previousFocus = null
506
+ route = null
470
507
  }
471
508
 
472
509
  const setOrAnimate = (node, transition, shouldAnimate = true) => {
@@ -502,7 +539,7 @@ export const back = function () {
502
539
  if (route && currentRoute !== route) {
503
540
  // set indicator that we are navigating back (to prevent adding page to history stack)
504
541
  navigatingBack = true
505
-
542
+ navigatingBackTo = route
506
543
  to(route.hash, route.data, route.options)
507
544
  return true
508
545
  }
@@ -531,10 +568,10 @@ export const back = function () {
531
568
  }
532
569
  // Construct new path to backtrack to
533
570
  path = path.replace(hashEnd, '')
534
- const route = matchHash(path, this.parent[symbols.routes])
571
+ const route = matchHash(getHash(path), this.parent[symbols.routes])
535
572
 
536
573
  if (route && backtrack) {
537
- to(route.path)
574
+ to(route.path, route.data, route.options)
538
575
  return true
539
576
  }
540
577
  }