@lightningjs/blits 1.34.0 → 1.35.1

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,23 @@
1
1
  # Changelog
2
2
 
3
+ ## v1.35.1
4
+
5
+ _24 Jul 2025_
6
+
7
+ - (Re) added next tick when setting focus to prevent any potential race conditions
8
+
9
+ _22 Jul 2025_
10
+
11
+ ## v1.35.0
12
+
13
+ - Added `byReference` param to `this.$emit()` to allow passing data without passing it by reference
14
+ - Refactored focus handling
15
+ - more performant
16
+ - more robust for reassigning focus programatically (i.e. without direct key handling)
17
+ - fixed some cases where `$hasFocus` flag wasn't reset upon unfocus
18
+ - add logging of active focus chain upon focus changes
19
+ - Improved overall test coverage to almost 80% (Blits Element, Focus, Theme plugin, Storage plugin)
20
+
3
21
  _10 Jul 2025_
4
22
 
5
23
  ## v1.34.0
package/index.d.ts CHANGED
@@ -262,8 +262,12 @@ declare module '@lightningjs/blits' {
262
262
  * Emit events that other components can listen to
263
263
  * @param name - name of the event to be emitted
264
264
  * @param data - optional data to be passed along
265
+ * @param byReference - whether or not to pass the data by reference.
266
+ * The default behaviour is passing the data object by reference (`true`).
267
+ * When explicitely passing `false` the object will be recursively cloned
268
+ * and cleaned from any potential reactivity before emitting
265
269
  */
266
- $emit(name: string, data?: any): void;
270
+ $emit(name: string, data?: any, byReference?: boolean): void;
267
271
 
268
272
  /**
269
273
  * Set a timeout that is automatically cleaned upon component destroy
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightningjs/blits",
3
- "version": "1.34.0",
3
+ "version": "1.35.1",
4
4
  "description": "Blits: The Lightning 3 App Development Framework",
5
5
  "bin": "bin/index.js",
6
6
  "exports": {
@@ -45,6 +45,7 @@
45
45
  "jsdom": "24.0.0",
46
46
  "lint-staged": "^15.5.0",
47
47
  "prettier": "^2.5.1",
48
+ "sinon": "^21.0.0",
48
49
  "tap-diff": "^0.1.1",
49
50
  "tape": "^5.5.0"
50
51
  },
@@ -23,12 +23,16 @@ export default {
23
23
  *
24
24
  * @param {string} event - The name of the event to emit.
25
25
  * @param {any} params - The parameters to pass to the event listeners.
26
+ * @param byReference - whether or not to pass the data by reference.
27
+ * The default behaviour is passing the data object by reference (`true`).
28
+ * When explicitely passing `false` the object will be recursively cloned
29
+ * and cleaned from any potential reactivity before emitting
26
30
  * @returns {boolean} True if all listeners executed, false otherwise.
27
31
  */
28
32
  $emit: {
29
- value: function (event, params) {
33
+ value: function (event, params, byReference = true) {
30
34
  // returning if all listeners executed
31
- return eventListeners.executeListeners(event, params)
35
+ return eventListeners.executeListeners(event, params, byReference)
32
36
  },
33
37
  writable: false,
34
38
  enumerable: true,
@@ -40,8 +40,6 @@ export default {
40
40
  * @this {import('../../component').BlitsComponent}
41
41
  */
42
42
  value: function (e) {
43
- if (this.eol === true) return
44
- this[symbols.state].hasFocus = true
45
43
  Focus.set(this, e)
46
44
  },
47
45
  writable: false,
package/src/focus.js CHANGED
@@ -19,6 +19,7 @@ import symbols from './lib/symbols.js'
19
19
  import { state } from './router/router.js'
20
20
  import Settings from './settings.js'
21
21
  import { DEFAULT_HOLD_TIMEOUT_MS } from './constants.js'
22
+ import { Log } from './lib/log.js'
22
23
 
23
24
  let focusedComponent = null
24
25
  let focusChain = []
@@ -38,56 +39,115 @@ export default {
38
39
  return focusedComponent
39
40
  },
40
41
  set(component, event) {
41
- if (component === focusedComponent) return
42
42
  clearTimeout(setFocusTimeout)
43
- focusedComponent && focusedComponent !== component.parent && focusedComponent.unfocus()
44
- focusChain.reverse().forEach((cmp) => cmp.unfocus())
45
- if (component !== focusedComponent) {
46
- setFocusTimeout = setTimeout(
47
- () => {
48
- focusedComponent = component
49
- focusedComponent.lifecycle.state = 'focus'
50
- if (event instanceof KeyboardEvent) {
51
- const internalEvent = new KeyboardEvent('keydown', event)
52
- // @ts-ignore - this is an internal event
53
- internalEvent[symbols.internalEvent] = true
54
- document.dispatchEvent(internalEvent)
55
- } else {
56
- focusChain = []
57
- }
58
- },
59
- this.hold ? Settings.get('holdTimeout', DEFAULT_HOLD_TIMEOUT_MS) : 0
60
- )
43
+
44
+ // early return if already focused
45
+ if (component === focusedComponent) return
46
+
47
+ if (focusChain.length === 0) {
48
+ focusChain = getAncestors([component])
61
49
  }
50
+
51
+ // unfocus currently focused components
52
+ if (focusedComponent !== null) {
53
+ if (focusChain[focusChain.length - 1] === component.parent) {
54
+ focusChain.push(component)
55
+ } else {
56
+ const newFocusChain = getAncestors([component])
57
+ let i = focusChain.length
58
+ while (i--) {
59
+ // don't unfocus when part of the new focus chain
60
+ if (newFocusChain.indexOf(focusChain[i]) > -1) break
61
+ focusChain[i].lifecycle.state = 'unfocus'
62
+ }
63
+ focusChain = newFocusChain
64
+ }
65
+ }
66
+
67
+ // ensure that all components in the focus path have focus state
68
+ let i = 0
69
+ while (i < focusChain.length - 1) {
70
+ focusChain[i].lifecycle.state = 'focus'
71
+ i++
72
+ }
73
+
74
+ // and finally set focus to the leaf component
75
+ setFocusTimeout = setTimeout(
76
+ () => setFocus(component, event),
77
+ this.hold === true ? Settings.get('holdTimeout', DEFAULT_HOLD_TIMEOUT_MS) : 0
78
+ )
62
79
  },
63
80
  input(key, event) {
64
81
  if (state.navigating === true) return
65
- focusChain = walkChain([focusedComponent], key)
66
- const componentWithInputEvent = focusChain.shift()
67
-
68
- if (componentWithInputEvent) {
69
- let cb
70
- if (componentWithInputEvent[symbols.inputEvents][key]) {
71
- cb = componentWithInputEvent[symbols.inputEvents][key].call(componentWithInputEvent, event)
72
- } else if (componentWithInputEvent[symbols.inputEvents].any) {
73
- cb = componentWithInputEvent[symbols.inputEvents].any.call(componentWithInputEvent, event)
74
- }
75
- if (cb !== undefined) {
76
- keyUpCallbacks.set(event.code, cb)
77
- }
82
+
83
+ const componentWithInputEvent = getComponentWithInputEvent(focusedComponent, key)
84
+
85
+ if (componentWithInputEvent === null) return
86
+
87
+ const inputEvents = componentWithInputEvent[symbols.inputEvents]
88
+
89
+ let cb
90
+ if (inputEvents[key]) {
91
+ cb = inputEvents[key].call(componentWithInputEvent, event)
92
+ } else if (inputEvents.any) {
93
+ cb = inputEvents.any.call(componentWithInputEvent, event)
94
+ }
95
+
96
+ if (cb !== undefined) {
97
+ keyUpCallbacks.set(event.code, cb)
78
98
  }
79
99
  },
80
100
  }
81
101
 
82
- const walkChain = (components, key) => {
102
+ /**
103
+ * Recursive function that retrieves the ancestors of a component
104
+ * @param {Array} components
105
+ * @returns array components
106
+ */
107
+ const getAncestors = (components) => {
108
+ if (components[0].parent !== undefined) {
109
+ components.unshift(components[0].parent)
110
+ return getAncestors(components)
111
+ }
112
+ return components
113
+ }
114
+
115
+ /**
116
+ * Get the component in the ancestors chain that has a handler for a certain key code
117
+ * @param {Object} component
118
+ * @param {String} key
119
+ * @returns component
120
+ */
121
+ const getComponentWithInputEvent = (component, key) => {
83
122
  if (
84
- components[0][symbols.inputEvents] &&
85
- (typeof components[0][symbols.inputEvents][key] === 'function' ||
86
- typeof components[0][symbols.inputEvents].any === 'function')
123
+ component[symbols.inputEvents] &&
124
+ (typeof component[symbols.inputEvents][key] === 'function' ||
125
+ typeof component[symbols.inputEvents].any === 'function')
87
126
  ) {
88
- return components
89
- } else if (components[0].parent) {
90
- components.unshift(components[0].parent)
91
- return walkChain(components, key)
92
- } else return []
127
+ return component
128
+ } else if (component.parent !== undefined) {
129
+ return getComponentWithInputEvent(component.parent, key)
130
+ } else return null
131
+ }
132
+
133
+ /**
134
+ * Set the focus to the Component
135
+ * @param {Object} component - The component fo focus
136
+ * @param {KeyboardEvent} event - Keyboard event
137
+ */
138
+ const setFocus = (component, event) => {
139
+ Log.info(
140
+ '\nFocus chain:\n',
141
+ focusChain.map((c, index) => '\t'.repeat(index) + '↳ ' + c.componentId).join('\n')
142
+ )
143
+
144
+ focusedComponent = component
145
+ component.lifecycle.state = 'focus'
146
+
147
+ if (event instanceof KeyboardEvent) {
148
+ const internalEvent = new KeyboardEvent('keydown', event)
149
+ // @ts-ignore - this is an internal event
150
+ internalEvent[symbols.internalEvent] = true
151
+ document.dispatchEvent(internalEvent)
152
+ }
93
153
  }
@@ -15,9 +15,30 @@
15
15
  * SPDX-License-Identifier: Apache-2.0
16
16
  */
17
17
 
18
+ import symbols from './symbols.js'
19
+ import { getRaw } from './reactivity/reactive.js'
20
+
18
21
  const eventsMap = new Map()
19
22
  const callbackCache = new Map()
20
23
 
24
+ function isProxy(obj) {
25
+ return obj && typeof obj === 'object' && obj[symbols.isProxy] === true
26
+ }
27
+
28
+ function deepUnproxyClone(obj) {
29
+ if (obj === null || typeof obj !== 'object') return obj
30
+
31
+ const raw = isProxy(obj) ? getRaw(obj) : obj
32
+ const result = Array.isArray(raw) ? [] : {}
33
+
34
+ for (const key of Object.keys(raw)) {
35
+ const value = raw[key]
36
+ result[key] = value && typeof value === 'object' ? deepUnproxyClone(value) : value
37
+ }
38
+
39
+ return result
40
+ }
41
+
21
42
  export default {
22
43
  /**
23
44
  * Registers a listener callback for a specific event on a component.
@@ -70,9 +91,16 @@ export default {
70
91
  *
71
92
  * @param {string} event - The name of the event to emit.
72
93
  * @param {any} params - The parameters to pass to the event listeners.
94
+ * @param byReference - whether or not to pass the data by reference.
95
+ * The default behaviour is passing the data object by reference (`true`).
96
+ * When explicitely passing `false` the object will be recursively cloned
97
+ * and cleaned from any potential reactivity before emitting
73
98
  * @returns {boolean} True if all listeners executed without stopping propagation, false if any listener returned false.
74
99
  */
75
- executeListeners(event, params) {
100
+ executeListeners(event, params, byReference) {
101
+ if (byReference === false) {
102
+ params = deepUnproxyClone(params)
103
+ }
76
104
  const componentsMap = eventsMap.get(event)
77
105
  if (componentsMap === undefined || componentsMap.size === 0) {
78
106
  return true // No listeners, so execution can be considered successful
@@ -70,12 +70,14 @@ export default {
70
70
  * @param {string} v - The new lifecycle state.
71
71
  */
72
72
  set state(v) {
73
- if (states.indexOf(v) > -1 && (v !== this.current || v === 'focus')) {
73
+ if (states.indexOf(v) > -1 && v !== this.current) {
74
74
  Log.debug(
75
75
  `Setting lifecycle state from ${this.current} to ${v} for ${this.component.componentId}`
76
76
  )
77
77
  this.previous = this.current
78
78
  this.current = v
79
+ if (this.current === 'focus') this.component[symbols.state].hasFocus = true
80
+ if (this.current === 'unfocus') this.component[symbols.state].hasFocus = false
79
81
  // emit 'private' hook
80
82
  privateEmit(v, this.component[symbols.identifier], this.component)
81
83
  // emit 'public' hook
@@ -46,6 +46,10 @@ const reactiveProxy = (original, _parent = null, _key, global) => {
46
46
 
47
47
  const handler = {
48
48
  get(target, key, receiver) {
49
+ // return the original object instead of the proxied
50
+ if (key === symbols.isProxy) {
51
+ return true
52
+ }
49
53
  // return the original object instead of the proxied
50
54
  if (key === symbols.raw) {
51
55
  return original
@@ -16,6 +16,7 @@
16
16
  * @property {symbol} inputEvents
17
17
  * @property {symbol} internalEvent
18
18
  * @property {symbol} intervals
19
+ * @property {symbol} isProxy
19
20
  * @property {symbol} launched
20
21
  * @property {symbol} level
21
22
  * @property {symbol} methodKeys
@@ -67,6 +68,7 @@ export default {
67
68
  inputEvents: Symbol('inputEvents'),
68
69
  internalEvent: Symbol('internalEvent'),
69
70
  intervals: Symbol('intervals'),
71
+ isProxy: Symbol('isProxy'),
70
72
  launched: Symbol('launched'),
71
73
  level: Symbol('level'),
72
74
  methodKeys: Symbol('methodKeys'),
@@ -120,14 +120,14 @@ class localCookie {
120
120
  // What about cookies we got from the server? ¯\_(ツ)_/¯
121
121
  // They should have the HttpOnly flag, so we shouldn't be able to delete them
122
122
  document.cookie.split(";").forEach( (c) => {
123
- document.cookie = c.replace(/^ +/, "").replace(/=.*/, "=;expires=Max-Age=-99999999");
123
+ document.cookie = c.replace(/^ +/, "").replace(/=.*/, "=;expires=;Max-Age=-99999999");
124
124
  });
125
125
 
126
126
  return //return undefined
127
127
  }
128
128
  _getCookieKeys() {
129
- return document.cookie.split(";").map((item)=>item.split("=")[0])
129
+ return document.cookie.split(";").map((item)=>item.trim().split("=")[0])
130
130
  }
131
131
  }
132
132
 
133
- export default localCookie
133
+ export default localCookie
@@ -132,7 +132,7 @@ export const matchHash = (path, routes = []) => {
132
132
  let matchingRoute = false
133
133
  let i = 0
134
134
  while (!matchingRoute && i < routes.length) {
135
- const route = { ...routes[i] }
135
+ const route = routes[i]
136
136
  route.path = normalizePath(route.path)
137
137
  if (route.path === path) {
138
138
  route.params = {}
@@ -206,20 +206,20 @@ export const navigate = async function () {
206
206
  let previousRoute = currentRoute //? Object.assign({}, currentRoute) : undefined
207
207
  const { hash, path, queryParams } = getHash()
208
208
  let route = matchHash(path, this.parent[symbols.routes])
209
- const queryParamsData = {}
210
- const queryParamsEntries = [...queryParams.entries()]
211
- for (let i = 0; i < queryParamsEntries.length; i++) {
212
- queryParamsData[queryParamsEntries[i][0]] = queryParamsEntries[i][1]
213
- }
214
-
215
- route.data = {
216
- ...route.data,
217
- ...navigationData,
218
- ...queryParamsData,
219
- }
220
209
 
221
210
  currentRoute = route
222
211
  if (route) {
212
+ const queryParamsData = {}
213
+ const queryParamsEntries = [...queryParams.entries()]
214
+ for (let i = 0; i < queryParamsEntries.length; i++) {
215
+ queryParamsData[queryParamsEntries[i][0]] = queryParamsEntries[i][1]
216
+ }
217
+
218
+ route.data = {
219
+ ...navigationData,
220
+ ...route.data,
221
+ ...queryParamsData,
222
+ }
223
223
  // Adding the location hash to the route if it exists.
224
224
  if (hash !== null) {
225
225
  route.hash = hash