@lightningjs/blits 1.45.1 → 1.46.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,23 @@
1
1
  # Changelog
2
2
 
3
+ ## v1.46.0
4
+
5
+ _22 jan 2026_
6
+
7
+ - Improved Announcer on Comcast devices
8
+ - Added new `afterEach` router hook
9
+ - Added router option to dynamically disable `back`-key handling by the router
10
+ - Added `this.$debounce`-method
11
+ - Added automatic inspector data items (`$componentType`, `$hasFocus` and `$isTransitioning`)
12
+ - Added functionality to auto size the holder node of a Component when the wrapper Element has dimensions
13
+ - Bumped Lightning renderer to v2.20.4 with correct handling of failed textures
14
+
15
+ ## v1.45.2
16
+
17
+ _13 jan 2026_
18
+
19
+ - Updated renderer to v2.20.2
20
+
3
21
  ## v1.45.1
4
22
 
5
23
  _29 dec 2025_
package/index.d.ts CHANGED
@@ -54,6 +54,12 @@ declare module '@lightningjs/blits' {
54
54
  * @default 1
55
55
  */
56
56
  volume?: number,
57
+ /**
58
+ * Whether to enable utterance keep-alive (prevents pausing on some platforms)
59
+ *
60
+ * @default undefined
61
+ */
62
+ enableUtteranceKeepAlive?: boolean
57
63
  }
58
64
 
59
65
  export interface AnnouncerUtterance<T = any> extends Promise<T> {
@@ -321,6 +327,11 @@ declare module '@lightningjs/blits' {
321
327
  */
322
328
  back(): boolean;
323
329
 
330
+ /**
331
+ * Enable or disable RouterView history navigation on Back input
332
+ */
333
+ backNavigation: boolean;
334
+
324
335
  /**
325
336
  * Get the current route read-only
326
337
  */
@@ -328,17 +339,17 @@ declare module '@lightningjs/blits' {
328
339
 
329
340
  /**
330
341
  * Get the list of all routes
331
- */
342
+ */
332
343
  readonly routes: Route[];
333
344
 
334
345
  /**
335
346
  * Get navigating state
336
- */
347
+ */
337
348
  readonly navigating: boolean;
338
349
 
339
350
  /**
340
351
  * Reactive router state
341
- */
352
+ */
342
353
  state: {
343
354
  /**
344
355
  * Path of the current route
@@ -371,28 +382,6 @@ declare module '@lightningjs/blits' {
371
382
  // Empty by design: extend in your app via TypeScript module augmentation.
372
383
  }
373
384
 
374
- export interface LanguagePlugin {
375
- translate(key: string, ...replacements: any[]): string
376
- readonly language: string
377
- set(language: string): void
378
- translations(translationsObject: Record<string, unknown>): void
379
- load(file: string): Promise<void>
380
- }
381
-
382
- export interface ThemePlugin {
383
- get<T = unknown>(key: string): T | undefined
384
- get<T>(key: string, fallback: T): T
385
- set(theme: string): void
386
- }
387
-
388
- export interface StoragePlugin {
389
- get<T = unknown>(key: string): T | null
390
- set(key: string, value: unknown): boolean
391
- remove(key: string): void
392
- clear(): void
393
- }
394
-
395
- export type AppStatePlugin<TState extends Record<string, unknown> = Record<string, unknown>> = TState
396
385
 
397
386
  export interface ComponentBase extends CustomComponentProperties {
398
387
  /**
@@ -444,6 +433,26 @@ declare module '@lightningjs/blits' {
444
433
  */
445
434
  $clearTimeout: (id: ReturnType<typeof setTimeout>) => void
446
435
 
436
+ /**
437
+ * Debounce a function execution, preventing memory leaks and function re-allocation
438
+ * @param name - Unique identifier for this debounce instance (unique per component instance)
439
+ * @param callback - Function to debounce
440
+ * @param ms - Delay in milliseconds
441
+ * @param args - Arguments to pass to the callback
442
+ */
443
+ $debounce: (name: string, callback: (...args: any[]) => void, ms?: number, ...args: any[]) => ReturnType<typeof setTimeout>
444
+
445
+ /**
446
+ * Clear a specific debounce by name
447
+ * @param name - The name of the debounce to clear
448
+ */
449
+ $clearDebounce: (name: string) => void
450
+
451
+ /**
452
+ * Clear all debounces registered on the component (automatically called on component destroy)
453
+ */
454
+ $clearDebounces: () => void
455
+
447
456
  /**
448
457
  * Set an interval that is automatically cleaned upon component destroy
449
458
  */
@@ -658,6 +667,7 @@ declare module '@lightningjs/blits' {
658
667
  export interface RouterHooks {
659
668
  init?: () => Promise<void> | void;
660
669
  beforeEach?: (to: Route, from: Route) => string | Route | Promise<string | Route> | void;
670
+ afterEach?: (to: Route, from: Route) => string | Route | Promise<string | Route> | void;
661
671
  error?: (err: string) => string | Route | Promise<string | Route> | void;
662
672
  }
663
673
 
@@ -681,6 +691,25 @@ declare module '@lightningjs/blits' {
681
691
  * ```
682
692
  */
683
693
  routes?: Route[]
694
+
695
+ /**
696
+ * Enable or disable RouterView history navigation on Back input
697
+ *
698
+ * @default true
699
+ *
700
+ * @remarks
701
+ * This is an app-wide setting that affects all RouterView instances in your application.
702
+ * The router state is global and shared across all router instances.
703
+ *
704
+ * @example
705
+ * ```js
706
+ * router: {
707
+ * backNavigation: false, // Disable automatic back navigation
708
+ * routes: [...]
709
+ * }
710
+ * ```
711
+ */
712
+ backNavigation?: boolean
684
713
  }
685
714
 
686
715
  export type ApplicationConfig<P extends Props, S, M, C, W> = ComponentConfig<P, S, M, C, W> & (
@@ -781,6 +810,7 @@ declare module '@lightningjs/blits' {
781
810
 
782
811
  export interface RouteHooks {
783
812
  before?: (to: Route, from: Route) => string | Route | Promise<string | Route>;
813
+ after?: (to: Route, from: Route) => string | Route | Promise<string | Route>;
784
814
  }
785
815
 
786
816
  export type Route = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightningjs/blits",
3
- "version": "1.45.1",
3
+ "version": "1.46.0",
4
4
  "description": "Blits: The Lightning 3 App Development Framework",
5
5
  "bin": "bin/index.js",
6
6
  "exports": {
@@ -74,7 +74,7 @@
74
74
  },
75
75
  "dependencies": {
76
76
  "@lightningjs/msdf-generator": "^1.2.0",
77
- "@lightningjs/renderer": "^2.20.0",
77
+ "@lightningjs/renderer": "^2.20.4",
78
78
  "magic-string": "^0.30.21"
79
79
  },
80
80
  "repository": {
@@ -17,6 +17,7 @@
17
17
 
18
18
  import fs from 'fs'
19
19
  import path from 'path'
20
+ import { fileURLToPath } from 'url'
20
21
  import compiler from '../src/lib/precompiler/precompiler.js'
21
22
  import { exec } from 'child_process'
22
23
 
@@ -84,4 +85,9 @@ function formatFileWithESLint(filePath) {
84
85
  })
85
86
  }
86
87
 
87
- precompileComponents()
88
+ // Only run if this file is executed directly (not imported)
89
+ if (fileURLToPath(import.meta.url) === process.argv[1]) {
90
+ precompileComponents()
91
+ }
92
+
93
+ export { precompileComponents, processDirectory, processFile, formatFileWithESLint }
@@ -26,7 +26,9 @@ let currentId = null
26
26
  let debounce = null
27
27
 
28
28
  // Global default utterance options
29
- let globalDefaultOptions = {}
29
+ let globalDefaultOptions = {
30
+ enableUtteranceKeepAlive: !/android/i.test((window.navigator || {}).userAgent || ''),
31
+ }
30
32
 
31
33
  const noopAnnouncement = {
32
34
  then() {},
@@ -50,7 +52,6 @@ const toggle = (v) => {
50
52
 
51
53
  const speak = (message, politeness = 'off', options = {}) => {
52
54
  if (active === false) return noopAnnouncement
53
-
54
55
  return addToQueue(message, politeness, false, options)
55
56
  }
56
57
 
@@ -106,6 +107,8 @@ const addToQueue = (message, politeness, delay = false, options = {}) => {
106
107
  return done
107
108
  }
108
109
 
110
+ let currentResolveFn = null
111
+
109
112
  const processQueue = async () => {
110
113
  if (isProcessing === true || queue.length === 0) return
111
114
  isProcessing = true
@@ -113,11 +116,13 @@ const processQueue = async () => {
113
116
  const { message, resolveFn, delay, id, options = {} } = queue.shift()
114
117
 
115
118
  currentId = id
119
+ currentResolveFn = resolveFn
116
120
 
117
121
  if (delay) {
118
122
  setTimeout(() => {
119
123
  isProcessing = false
120
124
  currentId = null
125
+ currentResolveFn = null
121
126
  resolveFn('finished')
122
127
  processQueue()
123
128
  }, delay)
@@ -138,19 +143,21 @@ const processQueue = async () => {
138
143
  Log.debug(`Announcer - finished speaking: "${message}" (id: ${id})`)
139
144
 
140
145
  currentId = null
146
+ currentResolveFn = null
141
147
  isProcessing = false
142
148
  resolveFn('finished')
143
149
  processQueue()
144
150
  })
145
151
  .catch((e) => {
146
152
  currentId = null
153
+ currentResolveFn = null
147
154
  isProcessing = false
148
155
  Log.debug(`Announcer - error ("${e.error}") while speaking: "${message}" (id: ${id})`)
149
156
  resolveFn(e.error)
150
157
  processQueue()
151
158
  })
152
159
  debounce = null
153
- }, 200)
160
+ }, 300)
154
161
  }
155
162
  }
156
163
 
@@ -158,13 +165,52 @@ const polite = (message, options = {}) => speak(message, 'polite', options)
158
165
 
159
166
  const assertive = (message, options = {}) => speak(message, 'assertive', options)
160
167
 
168
+ // Clear debounce timer
169
+ const clearDebounceTimer = () => {
170
+ if (debounce !== null) {
171
+ clearTimeout(debounce)
172
+ debounce = null
173
+ }
174
+ }
175
+
161
176
  const stop = () => {
177
+ // Clear debounce timer if speech hasn't started yet
178
+ clearDebounceTimer()
179
+
180
+ // Always cancel speech synthesis to ensure clean state
162
181
  speechSynthesis.cancel()
182
+
183
+ // Store resolve function before resetting state
184
+ const resolveFn = currentResolveFn
185
+
186
+ // Reset state
187
+ currentId = null
188
+ currentResolveFn = null
189
+ isProcessing = false
190
+
191
+ // Resolve promise if there was an active utterance
192
+ if (resolveFn !== null) {
193
+ resolveFn('interrupted')
194
+ }
163
195
  }
164
196
 
165
197
  const clear = () => {
198
+ // Clear debounce timer
199
+ clearDebounceTimer()
200
+
201
+ // Resolve all pending items in queue
202
+ while (queue.length > 0) {
203
+ const item = queue.shift()
204
+ if (item.resolveFn) {
205
+ Log.debug(`Announcer - clearing queued item: "${item.message}" (id: ${item.id})`)
206
+ item.resolveFn('cleared')
207
+ }
208
+ }
209
+
210
+ // Reset state
211
+ currentId = null
212
+ currentResolveFn = null
166
213
  isProcessing = false
167
- queue.length = 0
168
214
  }
169
215
 
170
216
  const configure = (options = {}) => {
@@ -19,33 +19,61 @@ import { Log } from '../lib/log.js'
19
19
 
20
20
  const syn = window.speechSynthesis
21
21
 
22
- const isAndroid = /android/i.test((window.navigator || {}).userAgent || '')
23
-
24
- const utterances = new Map() // Strong references with unique keys
22
+ const utterances = new Map() // id -> { utterance, timer, ignoreResume }
25
23
 
26
24
  let initialized = false
27
- let infinityTimer = null
28
25
 
29
- const clear = () => {
30
- if (infinityTimer) {
31
- clearTimeout(infinityTimer)
32
- infinityTimer = null
26
+ const clear = (id) => {
27
+ const state = utterances.get(id)
28
+ if (state === undefined) {
29
+ return
30
+ }
31
+ if (state.timer !== null) {
32
+ clearTimeout(state.timer)
33
+ state.timer = null
33
34
  }
35
+ utterances.delete(id)
34
36
  }
35
37
 
36
- const resumeInfinity = (target) => {
37
- if (!target || infinityTimer) {
38
- return clear()
38
+ const startKeepAlive = (id) => {
39
+ const state = utterances.get(id)
40
+
41
+ // utterance status: utterance was removed (cancelled or finished)
42
+ if (state == undefined) {
43
+ return
44
+ }
45
+
46
+ // Clear existing timer for this specific utterance
47
+ if (state.timer !== null) {
48
+ clearTimeout(state.timer)
49
+ state.timer = null
50
+ }
51
+
52
+ // syn status: syn might be undefined or cancelled
53
+ if (!syn) {
54
+ clear(id)
55
+ return
39
56
  }
40
57
 
41
58
  syn.pause()
42
59
  setTimeout(() => {
43
- syn.resume()
60
+ // utterance status: utterance might have been removed during setTimeout
61
+ const currentState = utterances.get(id)
62
+ if (currentState) {
63
+ currentState.ignoreResume = true
64
+ syn.resume()
65
+ }
44
66
  }, 0)
45
67
 
46
- infinityTimer = setTimeout(() => {
47
- resumeInfinity(target)
48
- }, 5000)
68
+ // Check if utterance still exists before scheduling next cycle
69
+ if (utterances.has(id) === true) {
70
+ state.timer = setTimeout(() => {
71
+ // Double-check utterance still exists before resuming
72
+ if (utterances.has(id) === true) {
73
+ startKeepAlive(id)
74
+ }
75
+ }, 5000)
76
+ }
49
77
  }
50
78
 
51
79
  const defaultUtteranceProps = {
@@ -57,45 +85,124 @@ const defaultUtteranceProps = {
57
85
  }
58
86
 
59
87
  const initialize = () => {
88
+ // syn api check: syn might not have getVoices method
89
+ if (!syn || typeof syn.getVoices !== 'function') {
90
+ initialized = false
91
+ return
92
+ }
93
+
60
94
  const voices = syn.getVoices()
61
95
  defaultUtteranceProps.voice = voices[0] || null
62
96
  initialized = true
63
97
  }
64
98
 
65
- const speak = (options) => {
66
- const utterance = new SpeechSynthesisUtterance(options.message)
99
+ const waitForSynthReady = (timeoutMs = 2000, checkIntervalMs = 100) => {
100
+ return new Promise((resolve) => {
101
+ if (!syn) {
102
+ Log.debug('SpeechSynthesis - syn unavailable')
103
+ resolve()
104
+ return
105
+ }
106
+
107
+ if (!syn.speaking && !syn.pending) {
108
+ Log.debug('SpeechSynthesis - ready immediately')
109
+ resolve()
110
+ return
111
+ }
112
+
113
+ Log.debug('SpeechSynthesis - waiting for ready state...')
114
+
115
+ const startTime = Date.now()
116
+
117
+ const intervalId = setInterval(() => {
118
+ const elapsed = Date.now() - startTime
119
+ const isReady = !syn.speaking && !syn.pending
120
+
121
+ if (isReady) {
122
+ Log.debug(`SpeechSynthesis - ready after ${elapsed}ms`)
123
+ clearInterval(intervalId)
124
+ resolve()
125
+ } else if (elapsed >= timeoutMs) {
126
+ Log.debug(`SpeechSynthesis - timeout after ${elapsed}ms, forcing ready`, {
127
+ speaking: syn.speaking,
128
+ pending: syn.pending,
129
+ })
130
+ clearInterval(intervalId)
131
+ resolve()
132
+ }
133
+ }, checkIntervalMs)
134
+ })
135
+ }
136
+
137
+ const speak = async (options) => {
138
+ // options check: missing required options
139
+ if (!options || !options.message) {
140
+ return Promise.reject({ error: 'Missing message' })
141
+ }
142
+
143
+ // options check: missing or invalid id
67
144
  const id = options.id
145
+ if (id === undefined || id === null) {
146
+ return Promise.reject({ error: 'Missing id' })
147
+ }
148
+
149
+ // utterance status: utterance with same id already exists
150
+ if (utterances.has(id)) {
151
+ clear(id)
152
+ }
153
+
154
+ // Wait for engine to be ready
155
+ await waitForSynthReady()
156
+
157
+ const utterance = new SpeechSynthesisUtterance(options.message)
68
158
  utterance.lang = options.lang || defaultUtteranceProps.lang
69
159
  utterance.pitch = options.pitch || defaultUtteranceProps.pitch
70
160
  utterance.rate = options.rate || defaultUtteranceProps.rate
71
161
  utterance.voice = options.voice || defaultUtteranceProps.voice
72
162
  utterance.volume = options.volume || defaultUtteranceProps.volume
73
- utterances.set(id, utterance) // Strong reference
74
-
75
- if (isAndroid === false) {
76
- utterance.onstart = () => {
77
- resumeInfinity(utterance)
78
- }
79
163
 
80
- utterance.onresume = () => {
81
- resumeInfinity(utterance)
82
- }
83
- }
164
+ utterances.set(id, { utterance, timer: null, ignoreResume: false })
84
165
 
85
166
  return new Promise((resolve, reject) => {
86
- utterance.onend = () => {
87
- clear()
88
- utterances.delete(id) // Cleanup
89
- resolve()
167
+ utterance.onend = (result) => {
168
+ clear(id)
169
+ resolve(result)
90
170
  }
91
171
 
92
172
  utterance.onerror = (e) => {
93
- clear()
94
- utterances.delete(id) // Cleanup
95
- reject(e)
173
+ Log.warn('SpeechSynthesisUtterance error:', e)
174
+ clear(id)
175
+ resolve()
96
176
  }
97
177
 
98
- syn.speak(utterance)
178
+ if (options.enableUtteranceKeepAlive === true) {
179
+ utterance.onstart = () => {
180
+ // utterances status: check if utterance still exists
181
+ if (utterances.has(id)) {
182
+ startKeepAlive(id)
183
+ }
184
+ }
185
+
186
+ utterance.onresume = () => {
187
+ const state = utterances.get(id)
188
+ // utterance status: utterance might have been removed
189
+ if (!state) return
190
+
191
+ if (state.ignoreResume === true) {
192
+ state.ignoreResume = false
193
+ return
194
+ }
195
+
196
+ startKeepAlive(id)
197
+ }
198
+ }
199
+ // handle error: syn.speak might throw
200
+ try {
201
+ syn.speak(utterance)
202
+ } catch (error) {
203
+ clear(id)
204
+ reject(error)
205
+ }
99
206
  })
100
207
  }
101
208
 
@@ -113,8 +220,20 @@ export default {
113
220
  },
114
221
  cancel() {
115
222
  if (syn !== undefined) {
116
- syn.cancel()
117
- clear()
223
+ // timers: clear all timers before cancelling
224
+ for (const id of utterances.keys()) {
225
+ clear(id)
226
+ }
227
+
228
+ // handle errors: syn.cancel might throw
229
+ try {
230
+ syn.cancel()
231
+ } catch (error) {
232
+ Log.error('Error cancelling speech synthesis:', error)
233
+ }
234
+
235
+ // utterances status: ensure all utterances are cleaned up
236
+ utterances.clear()
118
237
  }
119
238
  },
120
239
  // @todo
@@ -120,6 +120,8 @@ export default {
120
120
 
121
121
  this.$clearTimeouts()
122
122
  this.$clearIntervals()
123
+ this.$clearDebounces()
124
+
123
125
  eventListeners.removeListeners(this)
124
126
 
125
127
  const rendererEventListenersLength = this[symbols.rendererEventListeners].length
@@ -23,6 +23,12 @@ export default {
23
23
  value: {
24
24
  to,
25
25
  back,
26
+ get backNavigation() {
27
+ return state.backNavigation !== false
28
+ },
29
+ set backNavigation(enabled) {
30
+ state.backNavigation = enabled !== false
31
+ },
26
32
  get currentRoute() {
27
33
  return currentRoute
28
34
  },
@@ -96,4 +96,60 @@ export default {
96
96
  enumerable: true,
97
97
  configurable: false,
98
98
  },
99
+ $debounce: {
100
+ value: function (name, fn, ms, ...params) {
101
+ // early exit when component is marked as end of life
102
+ if (this.eol === true) return
103
+
104
+ // clear existing debounce for this name if it exists
105
+ const existing = this[symbols.debounces].get(name)
106
+ if (existing !== undefined) {
107
+ this.$clearTimeout(existing)
108
+ this[symbols.debounces].delete(name)
109
+ }
110
+
111
+ // create new timeout
112
+ const timeoutId = setTimeout(() => {
113
+ this[symbols.debounces].delete(name)
114
+ this.$clearTimeout(timeoutId)
115
+ fn.apply(this, params)
116
+ }, ms)
117
+
118
+ // track timeout in timeouts array for automatic cleanup
119
+ this[symbols.timeouts].push(timeoutId)
120
+
121
+ // store timeoutId per name to enable replace behavior and lifecycle cleanup
122
+ this[symbols.debounces].set(name, timeoutId)
123
+
124
+ return timeoutId
125
+ },
126
+ writable: false,
127
+ enumerable: true,
128
+ configurable: false,
129
+ },
130
+ $clearDebounce: {
131
+ value: function (name) {
132
+ const existing = this[symbols.debounces].get(name)
133
+ if (existing !== undefined) {
134
+ this.$clearTimeout(existing)
135
+ this[symbols.debounces].delete(name)
136
+ }
137
+ },
138
+ writable: false,
139
+ enumerable: true,
140
+ configurable: false,
141
+ },
142
+ $clearDebounces: {
143
+ value: function () {
144
+ // clear all timeouts associated with debounces
145
+ const timeoutIds = Array.from(this[symbols.debounces].values())
146
+ for (let i = 0; i < timeoutIds.length; i++) {
147
+ this.$clearTimeout(timeoutIds[i])
148
+ }
149
+ this[symbols.debounces].clear()
150
+ },
151
+ writable: false,
152
+ enumerable: true,
153
+ configurable: false,
154
+ },
99
155
  }
@@ -16,12 +16,17 @@
16
16
  */
17
17
 
18
18
  import symbols from '../../lib/symbols.js'
19
+ import { state as routerState } from '../../router/router.js'
19
20
 
20
21
  export default (component, data) => {
21
22
  let routes = data
22
23
  if (Array.isArray(data) === false) {
23
24
  component[symbols.routerHooks] = data.hooks
24
25
  routes = data.routes
26
+ // Set initial backNavigation value if provided in router config
27
+ if (data.backNavigation !== undefined) {
28
+ routerState.backNavigation = data.backNavigation !== false
29
+ }
25
30
  }
26
31
  component[symbols.routes] = []
27
32
 
package/src/component.js CHANGED
@@ -228,6 +228,9 @@ const Component = (name = required('name'), config = required('config')) => {
228
228
  // create an empty array for storing intervals created by this component (via this.$setInterval)
229
229
  this[symbols.intervals] = []
230
230
 
231
+ // create a Map for storing debounced functions (via this.$debounce)
232
+ this[symbols.debounces] = new Map()
233
+
231
234
  // apply the state function (passing in the this reference to utilize configured props)
232
235
  // and store a reference to this original state
233
236
  this[symbols.originalState] =
@@ -37,6 +37,7 @@ export default () =>
37
37
  let slotChildCounter = 0
38
38
  let cmps = []
39
39
  elementConfigs[0] = {}
40
+ elementConfigs[0]['___wrapper'] = true
40
41
 
41
42
  elms[0] = this.element(
42
43
  { parent: parent || 'root' },
@@ -39,6 +39,7 @@ export default () =>
39
39
  let slotChildCounter = 0
40
40
  let cmps = []
41
41
  elementConfigs[0] = {}
42
+ elementConfigs[0]['___wrapper'] = true
42
43
 
43
44
  elms[0] = this.element(
44
45
  { parent: parent || 'root' },
@@ -16,96 +16,106 @@
16
16
  */
17
17
 
18
18
  import Component from '../component.js'
19
- import Router from '../router/router.js'
19
+ import Router, { state as routerState } from '../router/router.js'
20
20
  import symbols from '../lib/symbols.js'
21
21
  import Focus from '../focus.js'
22
22
 
23
23
  let hashchangeHandler = null
24
24
 
25
+ /** @typedef {{ $input?: (event: any) => boolean, $focus?: (event: any) => void }} RouterViewParent */
26
+
25
27
  export default () =>
26
- Component('RouterView', {
27
- /* eslint-disable no-unused-vars */
28
- code: {
29
- render: function anonymous(parent, component, context, components, effect, getRaw, Log) {
30
- const elms = []
31
- const elementConfigs = []
32
- const forloops = []
33
- const props = []
34
- const created = []
35
- const effects = {}
36
- const skips = []
37
- let componentType
38
- let rootComponent = component
39
- let propData
40
- let slotComponent
41
- let inSlot = false
42
- let slotChildCounter = 0
43
- let cmps = []
44
- elementConfigs[0] = {}
28
+ Component(
29
+ 'RouterView',
30
+ /** @type {any} */ ({
31
+ /* eslint-disable no-unused-vars */
32
+ code: {
33
+ render: function anonymous(parent, component, context, components, effect, getRaw, Log) {
34
+ const elms = []
35
+ const elementConfigs = []
36
+ const forloops = []
37
+ const props = []
38
+ const created = []
39
+ const effects = {}
40
+ const skips = []
41
+ let componentType
42
+ let rootComponent = component
43
+ let propData
44
+ let slotComponent
45
+ let inSlot = false
46
+ let slotChildCounter = 0
47
+ let cmps = []
48
+ elementConfigs[0] = {}
49
+ elementConfigs[0]['___wrapper'] = true
45
50
 
46
- elms[0] = this.element(
47
- { parent: parent || 'root' },
48
- inSlot === true ? slotComponent : component
49
- )
51
+ elms[0] = this.element(
52
+ { parent: parent || 'root' },
53
+ inSlot === true ? slotComponent : component
54
+ )
50
55
 
51
- elementConfigs[0]['w'] = parent.node.width * (100 / 100)
52
- elementConfigs[0]['height'] = parent.node.height * (100 / 100)
53
- elms[0].populate(elementConfigs[0])
56
+ elementConfigs[0]['w'] = parent.node.width * (100 / 100)
57
+ elementConfigs[0]['height'] = parent.node.height * (100 / 100)
58
+ elms[0].populate(elementConfigs[0])
54
59
 
55
- if (inSlot === true) {
56
- slotChildCounter -= 1
57
- }
60
+ if (inSlot === true) {
61
+ slotChildCounter -= 1
62
+ }
58
63
 
59
- return {
60
- elms,
61
- cleanup: () => {
62
- rootComponent = null
63
- propData = null
64
- slotComponent = null
65
- parent = null
66
- component = null
67
- cmps.length = 0
68
- elms.length = 0
69
- components.length = 0
70
- elementConfigs.length = 0
71
- forloops.length = 0
72
- props.length = 0
73
- skips.length = 0
74
- },
75
- }
64
+ return {
65
+ elms,
66
+ cleanup: () => {
67
+ rootComponent = null
68
+ propData = null
69
+ slotComponent = null
70
+ parent = null
71
+ component = null
72
+ cmps.length = 0
73
+ elms.length = 0
74
+ components.length = 0
75
+ elementConfigs.length = 0
76
+ forloops.length = 0
77
+ props.length = 0
78
+ skips.length = 0
79
+ },
80
+ }
81
+ },
82
+ effects: [],
83
+ context: {},
76
84
  },
77
- effects: [],
78
- context: {},
79
- },
80
- state() {
81
- return {
82
- activeView: null,
83
- }
84
- },
85
- hooks: {
86
- async ready() {
87
- if (this.parent[symbols.routerHooks] && this.parent[symbols.routerHooks].init) {
88
- await this.parent[symbols.routerHooks].init.apply(this.parent)
85
+ state() {
86
+ return {
87
+ activeView: null,
89
88
  }
90
- hashchangeHandler = () => Router.navigate.apply(this)
91
- Router.navigate.apply(this)
92
- window.addEventListener('hashchange', hashchangeHandler)
93
- },
94
- destroy() {
95
- window.removeEventListener('hashchange', hashchangeHandler, false)
96
89
  },
97
- focus() {
98
- if (this.activeView && Focus.get() === this) {
99
- this.activeView.$focus()
100
- }
90
+ hooks: {
91
+ async ready() {
92
+ if (this.parent[symbols.routerHooks] && this.parent[symbols.routerHooks].init) {
93
+ await this.parent[symbols.routerHooks].init.apply(this.parent)
94
+ }
95
+ hashchangeHandler = () => Router.navigate.apply(this)
96
+ Router.navigate.apply(this)
97
+ window.addEventListener('hashchange', hashchangeHandler)
98
+ },
99
+ destroy() {
100
+ window.removeEventListener('hashchange', hashchangeHandler, false)
101
+ },
102
+ focus() {
103
+ if (this.activeView && Focus.get() === this) {
104
+ this.activeView.$focus()
105
+ }
106
+ },
101
107
  },
102
- },
103
- input: {
104
- back(e) {
105
- const navigating = Router.back.call(this)
106
- if (navigating === false) {
107
- this.parent.$focus(e)
108
- }
108
+ input: {
109
+ back(e) {
110
+ if (routerState.backNavigation === false) {
111
+ this.parent.$input(e)
112
+ return
113
+ }
114
+ const navigating = Router.back.call(this)
115
+ if (navigating === false) {
116
+ this.parent.$focus(e)
117
+ }
118
+ },
109
119
  },
110
- },
111
- })
120
+ })
121
+ )
@@ -39,6 +39,7 @@ export default () =>
39
39
  let slotChildCounter = 0
40
40
  let cmps = []
41
41
  elementConfigs[0] = {}
42
+ elementConfigs[0]['___wrapper'] = true
42
43
 
43
44
  elms[0] = this.element(
44
45
  { parent: parent || 'root' },
@@ -243,15 +243,21 @@ const propsTransformer = {
243
243
  },
244
244
  set w(v) {
245
245
  this.props['width'] = parsePercentage.call(this, v, 'width')
246
+ if (this.___wrapper === true && this.element.component[symbols.holder] !== undefined) {
247
+ this.element.component[symbols.holder].set('w', this.props['width'])
248
+ }
246
249
  },
247
250
  set width(v) {
248
- this.props['width'] = parsePercentage.call(this, v, 'width')
251
+ this.w = v
249
252
  },
250
253
  set h(v) {
251
254
  this.props['height'] = parsePercentage.call(this, v, 'height')
255
+ if (this.___wrapper === true && this.element.component[symbols.holder] !== undefined) {
256
+ this.element.component[symbols.holder].set('h', this.props['height'])
257
+ }
252
258
  },
253
259
  set height(v) {
254
- this.props['height'] = parsePercentage.call(this, v, 'height')
260
+ this.h = v
255
261
  },
256
262
  set x(v) {
257
263
  this.props['x'] = parsePercentage.call(this, v, 'width')
@@ -564,6 +570,39 @@ const Element = {
564
570
  })
565
571
  }
566
572
  },
573
+ /**
574
+ * Sets framework-provided inspector metadata
575
+ * Only sets if inspector is enabled and in dev mode
576
+ * @param {Object} data - Framework inspector metadata to merge
577
+ */
578
+ setInspectorMetadata(data) {
579
+ // Early return if inspector not enabled (performance optimization)
580
+ if (inspectorEnabled !== true) {
581
+ return
582
+ }
583
+
584
+ // Early return if element is destroyed (props.props is null)
585
+ if (this.props.props === undefined || this.props.props === null) {
586
+ return
587
+ }
588
+
589
+ // Initialize data object if it doesn't exist
590
+ if (this.props['data'] === undefined) {
591
+ this.props['data'] = {}
592
+ }
593
+ if (this.props.props['data'] === undefined) {
594
+ this.props.props['data'] = {}
595
+ }
596
+
597
+ // Merge framework data (with $ prefix to prevent collisions)
598
+ Object.assign(this.props['data'], data)
599
+ Object.assign(this.props.props['data'], data)
600
+
601
+ // Sync to renderer node so inspector can see it
602
+ if (this.node !== undefined && this.node !== null) {
603
+ this.node.data = { ...this.props.props['data'] }
604
+ }
605
+ },
567
606
  /**
568
607
  * Set an individual property on the node
569
608
  *
@@ -651,6 +690,11 @@ const Element = {
651
690
  f,
652
691
  }
653
692
 
693
+ // Update inspector metadata when transition starts
694
+ if (inspectorEnabled === true) {
695
+ this.setInspectorMetadata({ $isTransitioning: true })
696
+ }
697
+
654
698
  if (transition.start !== undefined && typeof transition.start === 'function') {
655
699
  // fire transition start callback when animation really starts (depending on specified delay)
656
700
  f.once('animating', () => {
@@ -685,6 +729,12 @@ const Element = {
685
729
  }
686
730
  // remove the prop from scheduled transitions
687
731
  delete this.scheduledTransitions[prop]
732
+ // Update inspector metadata when transition ends
733
+ if (inspectorEnabled === true) {
734
+ this.setInspectorMetadata({
735
+ $isTransitioning: Object.keys(this.scheduledTransitions).length > 0,
736
+ })
737
+ }
688
738
  })
689
739
 
690
740
  // start animation
@@ -151,6 +151,10 @@ const generateElementCode = function (
151
151
 
152
152
  renderCode.push(`elementConfigs[${counter}] = {}`)
153
153
 
154
+ if (counter === 0) {
155
+ renderCode.push(`elementConfigs[${counter}]['___wrapper'] = true `)
156
+ }
157
+
154
158
  if (options.forloop) {
155
159
  renderCode.push(`if(${elm} === undefined) {`)
156
160
  }
@@ -283,9 +287,11 @@ const generateComponentCode = function (
283
287
 
284
288
  const children = templateObject.children
285
289
  delete templateObject.children
290
+ // Capture holder counter before generating element code (which may process children and increment counter)
291
+ const holderCounter = counter
286
292
  generateElementCode.call(this, templateObject, parent, { ...options, ...{ holder: true } })
287
293
 
288
- parent = options.key ? `elms[${counter}][${options.key}]` : `elms[${counter}]`
294
+ parent = options.key ? `elms[${holderCounter}][${options.key}]` : `elms[${holderCounter}]`
289
295
 
290
296
  counter++
291
297
 
@@ -354,6 +360,21 @@ const generateComponentCode = function (
354
360
  }
355
361
  `)
356
362
 
363
+ // For forloops, this code runs per instance, setting metadata for each component instance
364
+ if (isDev === true) {
365
+ const componentType = templateObject[Symbol.for('componentType')]
366
+ const holderElm = options.key
367
+ ? `elms[${holderCounter}][${options.key}]`
368
+ : `elms[${holderCounter}]`
369
+ renderCode.push(`
370
+ if (${holderElm} !== undefined && typeof ${holderElm}.setInspectorMetadata === 'function') {
371
+ ${holderElm}.setInspectorMetadata({
372
+ $componentType: '${componentType}'
373
+ })
374
+ }
375
+ `)
376
+ }
377
+
357
378
  this.cleanupCode.push(`components[${counter}] = null`)
358
379
 
359
380
  if (options.forloop) {
@@ -18,6 +18,9 @@
18
18
  import { Log } from './log.js'
19
19
  import { emit, privateEmit } from './hooks.js'
20
20
  import symbols from './symbols.js'
21
+ import Settings from '../settings.js'
22
+
23
+ let inspectorEnabled = null
21
24
 
22
25
  /**
23
26
  * List of valid lifecycle states for a component.
@@ -82,8 +85,31 @@ export default {
82
85
  // emit 'public' hook
83
86
  emit(v, this.component[symbols.identifier], this.component)
84
87
  // update the built-in hasFocus state variable
85
- if (v === 'focus') this.component[symbols.state].hasFocus = true
86
- if (v === 'unfocus') this.component[symbols.state].hasFocus = false
88
+ if (v === 'focus' || v === 'unfocus') {
89
+ if (inspectorEnabled === null) {
90
+ inspectorEnabled = Settings.get('inspector', false)
91
+ }
92
+ }
93
+ if (v === 'focus') {
94
+ this.component[symbols.state].hasFocus = true
95
+ if (
96
+ inspectorEnabled === true &&
97
+ this.component[symbols.holder] &&
98
+ typeof this.component[symbols.holder].setInspectorMetadata === 'function'
99
+ ) {
100
+ this.component[symbols.holder].setInspectorMetadata({ $hasFocus: true })
101
+ }
102
+ }
103
+ if (v === 'unfocus') {
104
+ this.component[symbols.state].hasFocus = false
105
+ if (
106
+ inspectorEnabled === true &&
107
+ this.component[symbols.holder] &&
108
+ typeof this.component[symbols.holder].setInspectorMetadata === 'function'
109
+ ) {
110
+ this.component[symbols.holder].setInspectorMetadata({ $hasFocus: false })
111
+ }
112
+ }
87
113
  }
88
114
  },
89
115
  }
@@ -17,6 +17,7 @@
17
17
  * @property {symbol} inputEvents
18
18
  * @property {symbol} internalEvent
19
19
  * @property {symbol} intervals
20
+ * @property {symbol} debounces
20
21
  * @property {symbol} isProxy
21
22
  * @property {symbol} launched
22
23
  * @property {symbol} level
@@ -70,6 +71,7 @@ export default {
70
71
  inputEvents: Symbol('inputEvents'),
71
72
  internalEvent: Symbol('internalEvent'),
72
73
  intervals: Symbol('intervals'),
74
+ debounces: Symbol('debounces'),
73
75
  isProxy: Symbol('isProxy'),
74
76
  launched: Symbol('launched'),
75
77
  level: Symbol('level'),
@@ -15,10 +15,7 @@
15
15
  * SPDX-License-Identifier: Apache-2.0
16
16
  */
17
17
 
18
- import type { AppStatePlugin } from '@lightningjs/blits'
19
-
20
- // Re-export AppStatePlugin for direct imports
21
- export type { AppStatePlugin }
18
+ export type AppStatePlugin<TState extends Record<string, unknown> = Record<string, unknown>> = TState
22
19
 
23
20
  declare const appState: {
24
21
  readonly name: 'appState'
@@ -21,9 +21,8 @@ export { default as appState } from './appstate.js'
21
21
  export { default as storage } from './storage/storage.js'
22
22
 
23
23
  // Re-export plugin interfaces for convenience
24
- export type { LanguagePlugin, ThemePlugin, StoragePlugin, AppStatePlugin } from '@lightningjs/blits'
25
-
26
- // Re-export option types
27
- export type { LanguagePluginOptions } from './language.js'
28
- export type { ThemePluginConfig } from './theme.js'
24
+ export type { LanguagePlugin, LanguagePluginOptions } from './language.js'
25
+ export type { ThemePlugin, ThemePluginConfig } from './theme.js'
26
+ export type { StoragePlugin } from './storage/storage.js'
27
+ export type { AppStatePlugin } from './appstate.js'
29
28
 
@@ -15,16 +15,19 @@
15
15
  * SPDX-License-Identifier: Apache-2.0
16
16
  */
17
17
 
18
- import type { LanguagePlugin } from '@lightningjs/blits'
18
+ export interface LanguagePlugin {
19
+ translate(key: string, ...replacements: any[]): string
20
+ readonly language: string
21
+ set(language: string): void
22
+ translations(translationsObject: Record<string, unknown>): void
23
+ load(file: string): Promise<void>
24
+ }
19
25
 
20
26
  export interface LanguagePluginOptions {
21
27
  file?: string
22
28
  language?: string
23
29
  }
24
30
 
25
- // Re-export LanguagePlugin for direct imports
26
- export type { LanguagePlugin }
27
-
28
31
  declare const language: {
29
32
  readonly name: 'language'
30
33
  plugin: (options?: LanguagePluginOptions) => LanguagePlugin
@@ -15,10 +15,12 @@
15
15
  * SPDX-License-Identifier: Apache-2.0
16
16
  */
17
17
 
18
- import type { StoragePlugin } from '@lightningjs/blits'
19
-
20
- // Re-export StoragePlugin for direct imports
21
- export type { StoragePlugin }
18
+ export interface StoragePlugin {
19
+ get<T = unknown>(key: string): T | null
20
+ set(key: string, value: unknown): boolean
21
+ remove(key: string): void
22
+ clear(): void
23
+ }
22
24
 
23
25
  declare const storage: {
24
26
  readonly name: 'storage'
@@ -15,7 +15,11 @@
15
15
  * SPDX-License-Identifier: Apache-2.0
16
16
  */
17
17
 
18
- import type { ThemePlugin } from '@lightningjs/blits'
18
+ export interface ThemePlugin {
19
+ get<T = unknown>(key: string): T | undefined
20
+ get<T>(key: string, fallback: T): T
21
+ set(theme: string): void
22
+ }
19
23
 
20
24
  export interface ThemePluginConfig {
21
25
  themes?: Record<string, Record<string, unknown>>
@@ -23,9 +27,6 @@ export interface ThemePluginConfig {
23
27
  base?: string
24
28
  }
25
29
 
26
- // Re-export ThemePlugin for direct imports
27
- export type { ThemePlugin }
28
-
29
30
  declare const theme: {
30
31
  readonly name: 'theme'
31
32
  plugin: (config?: ThemePluginConfig | Record<string, unknown>) => ThemePlugin
@@ -30,6 +30,9 @@ import Settings from '../settings.js'
30
30
  * @typedef {import('../component.js').BlitsComponent} BlitsComponent - The element of the route
31
31
  * @typedef {import('../engines/L3/element.js').BlitsElement} BlitsElement - The element of the route
32
32
  *
33
+ * @typedef {BlitsComponent|BlitsComponentFactory} RouteView
34
+ * @typedef {RouteView & { default?: BlitsComponentFactory }} RouteViewWithOptionalDefault
35
+ *
33
36
  * @typedef {Object} Route
34
37
  * @property {string} path - The path of the route
35
38
  * @property {string} hash - The hash of the route
@@ -57,6 +60,7 @@ export const state = reactive(
57
60
  data: null,
58
61
  params: null,
59
62
  hash: '',
63
+ backNavigation: true,
60
64
  },
61
65
  Settings.get('reactivityMode'),
62
66
  true
@@ -335,6 +339,7 @@ export const navigate = async function () {
335
339
  return
336
340
  }
337
341
  }
342
+
338
343
  // add the previous route (technically still the current route at this point)
339
344
  // into the history stack when inHistory is true and we're not navigating back
340
345
  if (
@@ -354,6 +359,7 @@ export const navigate = async function () {
354
359
  /** @type {import('../engines/L3/element.js').BlitsElement} */
355
360
  let holder
356
361
 
362
+ /** @type {RouteViewWithOptionalDefault|undefined|null} */
357
363
  let view
358
364
  let focus
359
365
  // when navigating back let's see if we're navigating back to a route that was kept alive
@@ -476,15 +482,17 @@ export const navigate = async function () {
476
482
  }
477
483
 
478
484
  let shouldAnimate = false
485
+ // Declare oldView in broader scope so it can be used in hooks below
486
+ let oldView = null
479
487
 
480
488
  // apply out out transition on previous view if available, unless
481
489
  // we're reusing the prvious page component
482
490
  if (previousRoute !== undefined && reuse === false) {
483
491
  // only animate when there is a previous route
484
492
  shouldAnimate = true
485
- const oldView = this[symbols.children].splice(1, 1).pop()
493
+ oldView = this[symbols.children].splice(1, 1).pop()
486
494
  if (oldView) {
487
- removeView(previousRoute, oldView, route.transition.out, navigatingBack)
495
+ await removeView(previousRoute, oldView, route.transition.out, navigatingBack)
488
496
  }
489
497
  }
490
498
 
@@ -492,7 +500,7 @@ export const navigate = async function () {
492
500
  if (route.transition.in) {
493
501
  if (Array.isArray(route.transition.in)) {
494
502
  for (let i = 0; i < route.transition.in.length; i++) {
495
- i === route.transition.length - 1
503
+ i === route.transition.in.length - 1
496
504
  ? await setOrAnimate(holder, route.transition.in[i], shouldAnimate)
497
505
  : setOrAnimate(holder, route.transition.in[i], shouldAnimate)
498
506
  }
@@ -500,6 +508,33 @@ export const navigate = async function () {
500
508
  await setOrAnimate(holder, route.transition.in, shouldAnimate)
501
509
  }
502
510
  }
511
+
512
+ if (this.parent[symbols.routerHooks]) {
513
+ const hooks = this.parent[symbols.routerHooks]
514
+ if (hooks.afterEach) {
515
+ try {
516
+ await hooks.afterEach.call(
517
+ this.parent,
518
+ route, // to
519
+ previousRoute // from
520
+ )
521
+ } catch (error) {
522
+ Log.error('Error in "AfterEach" Hook', error)
523
+ }
524
+ }
525
+ }
526
+
527
+ if (route.hooks.after) {
528
+ try {
529
+ await route.hooks.after.call(
530
+ this.parent,
531
+ route, // to
532
+ previousRoute // from
533
+ )
534
+ } catch (error) {
535
+ Log.error('Error or Rejected Promise in "After" Hook', error)
536
+ }
537
+ }
503
538
  } else {
504
539
  Log.error(`Route ${route.hash} not found`)
505
540
  const routerHooks = this.parent[symbols.routerHooks]