From 3170c736e9e7a7c842c19d39a19e306dd194ddf8 Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Wed, 26 Nov 2025 15:51:34 +0000 Subject: [PATCH 01/66] fix: resumeInfinity/onresume interaction --- src/announcer/speechSynthesis.js | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/announcer/speechSynthesis.js b/src/announcer/speechSynthesis.js index 5c454ba4..494ea358 100644 --- a/src/announcer/speechSynthesis.js +++ b/src/announcer/speechSynthesis.js @@ -33,13 +33,24 @@ const clear = () => { } } +let resumeFromKeepAlive = false + const resumeInfinity = (target) => { - if (!target || infinityTimer) { + // If the utterance is gone, just stop the keep-alive loop. + if (!target) { return clear() } + // We only ever want ONE keep-alive timer running per utterance. + // If there's an existing timer, cancel it and start a fresh one below. + if (infinityTimer) { + clearTimeout(infinityTimer) + infinityTimer = null + } + syn.pause() setTimeout(() => { + resumeFromKeepAlive = true syn.resume() }, 0) @@ -78,6 +89,13 @@ const speak = (options) => { } utterance.onresume = () => { + // Ignore resume events that we *know* came from our own keep-alive (the pause()/resume() in resumeInfinity). + if (resumeFromKeepAlive) { + resumeFromKeepAlive = false + return + } + + // For any other real resume event (e.g. user or platform resuming a previously paused utterance). resumeInfinity(utterance) } } From 15614f14278aa8d1bfa93f3185cc951e4b3ab710 Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Tue, 2 Dec 2025 10:22:54 +0000 Subject: [PATCH 02/66] chore: ensure target is a SpeechSynthesisUtterance in resumeInfinity function --- src/announcer/speechSynthesis.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/announcer/speechSynthesis.js b/src/announcer/speechSynthesis.js index 494ea358..bebf5d7a 100644 --- a/src/announcer/speechSynthesis.js +++ b/src/announcer/speechSynthesis.js @@ -37,7 +37,7 @@ let resumeFromKeepAlive = false const resumeInfinity = (target) => { // If the utterance is gone, just stop the keep-alive loop. - if (!target) { + if (!(target instanceof SpeechSynthesisUtterance)) { return clear() } From f35ab5e5f769670d37d38f89d283c89ed1a16b10 Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Tue, 2 Dec 2025 10:26:02 +0000 Subject: [PATCH 03/66] fix: ensure infinityTimer is checked against null in clear and resumeInfinity functions --- src/announcer/speechSynthesis.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/announcer/speechSynthesis.js b/src/announcer/speechSynthesis.js index bebf5d7a..a7db0bfe 100644 --- a/src/announcer/speechSynthesis.js +++ b/src/announcer/speechSynthesis.js @@ -27,7 +27,7 @@ let initialized = false let infinityTimer = null const clear = () => { - if (infinityTimer) { + if (infinityTimer !== null) { clearTimeout(infinityTimer) infinityTimer = null } @@ -43,7 +43,7 @@ const resumeInfinity = (target) => { // We only ever want ONE keep-alive timer running per utterance. // If there's an existing timer, cancel it and start a fresh one below. - if (infinityTimer) { + if (infinityTimer !== null) { clearTimeout(infinityTimer) infinityTimer = null } From 910df0c6fb47f04ae2640f57baa0636ac4a282d2 Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Tue, 2 Dec 2025 10:27:30 +0000 Subject: [PATCH 04/66] fix: ensure resumeFromKeepAlive is strictly checked for true in onresume handler --- src/announcer/speechSynthesis.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/announcer/speechSynthesis.js b/src/announcer/speechSynthesis.js index a7db0bfe..fe70960b 100644 --- a/src/announcer/speechSynthesis.js +++ b/src/announcer/speechSynthesis.js @@ -90,7 +90,7 @@ const speak = (options) => { utterance.onresume = () => { // Ignore resume events that we *know* came from our own keep-alive (the pause()/resume() in resumeInfinity). - if (resumeFromKeepAlive) { + if (resumeFromKeepAlive === true) { resumeFromKeepAlive = false return } From 85b01b5c8d89eaaeb29e3867a8156e7faa1587fc Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Tue, 2 Dec 2025 12:29:33 +0000 Subject: [PATCH 05/66] feat(router): add afterEach hook to RouterHooks for post-navigation handling --- index.d.ts | 1 + src/router/router.js | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/index.d.ts b/index.d.ts index 0bd00395..df472dca 100644 --- a/index.d.ts +++ b/index.d.ts @@ -626,6 +626,7 @@ declare module '@lightningjs/blits' { export interface RouterHooks { init?: () => Promise<> | void; beforeEach?: (to: Route, from: Route) => string | Route | Promise | void; + afterEach?: (to: Route, toComponent: ComponentBase | null, from: Route | undefined, fromComponent: ComponentBase | null) => Promise | void; error?: (err: string) => string | Route | Promise | void; } diff --git a/src/router/router.js b/src/router/router.js index ec639eef..faf25ce1 100644 --- a/src/router/router.js +++ b/src/router/router.js @@ -459,6 +459,26 @@ export const navigate = async function () { const children = this[symbols.children] this.activeView = children[children.length - 1] + if (this.parent[symbols.routerHooks]) { + const hooks = this.parent[symbols.routerHooks] + if (hooks.afterEach) { + try { + // Get the previous view before it's removed + const previousView = + !reuse && children.length > 1 ? children[children.length - 2] : null + + await hooks.afterEach.call(this.parent, { + to: route, + toComponent: view, + from: previousRoute, + fromComponent: previousView, + }) + } catch (error) { + Log.error('Error in "AfterEach" Hook', error) + } + } + } + // set focus to the view that we're routing to (unless explicitly disabling passing focus) if (route.options.passFocus !== false) { focus ? focus.$focus() : /** @type {BlitsComponent} */ (view).$focus() From ecb2ddc7bf8991d5d6f7c2608208dee982a1e367 Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Wed, 3 Dec 2025 12:17:06 +0000 Subject: [PATCH 06/66] fix: improve utterance management and error handling in speech synthesis --- src/announcer/speechSynthesis.js | 149 ++++++++++++++++++++++--------- 1 file changed, 109 insertions(+), 40 deletions(-) diff --git a/src/announcer/speechSynthesis.js b/src/announcer/speechSynthesis.js index fe70960b..fdc722fe 100644 --- a/src/announcer/speechSynthesis.js +++ b/src/announcer/speechSynthesis.js @@ -21,41 +21,60 @@ const syn = window.speechSynthesis const isAndroid = /android/i.test((window.navigator || {}).userAgent || '') -const utterances = new Map() // Strong references with unique keys +const utterances = new Map() // id -> { utterance, timer, ignoreResume } let initialized = false -let infinityTimer = null -const clear = () => { - if (infinityTimer !== null) { - clearTimeout(infinityTimer) - infinityTimer = null +const clear = (id) => { + const state = utterances.get(id) + if (state?.timer !== null) { + clearTimeout(state.timer) + state.timer = null } } -let resumeFromKeepAlive = false +const resumeInfinity = (id) => { + const state = utterances.get(id) -const resumeInfinity = (target) => { - // If the utterance is gone, just stop the keep-alive loop. - if (!(target instanceof SpeechSynthesisUtterance)) { - return clear() + // utterance status: utterance was removed (cancelled or finished) + if (!state) { + return } - // We only ever want ONE keep-alive timer running per utterance. - // If there's an existing timer, cancel it and start a fresh one below. - if (infinityTimer !== null) { - clearTimeout(infinityTimer) - infinityTimer = null + const { utterance } = state + + // utterance check: utterance instance is invalid + if (!(utterance instanceof SpeechSynthesisUtterance)) { + clear(id) + utterances.delete(id) + return + } + + // Clear existing timer for this specific utterance + if (state.timer !== null) { + clearTimeout(state.timer) + state.timer = null + } + + // syn status: syn might be undefined or cancelled + if (!syn) { + clear(id) + utterances.delete(id) + return } syn.pause() setTimeout(() => { - resumeFromKeepAlive = true - syn.resume() + // utterance status: utterance might have been removed during setTimeout + const currentState = utterances.get(id) + if (currentState) { + currentState.ignoreResume = true + syn.resume() + } }, 0) - infinityTimer = setTimeout(() => { - resumeInfinity(target) + state.timer = setTimeout(() => { + resumeInfinity(id) }, 5000) } @@ -68,52 +87,94 @@ const defaultUtteranceProps = { } const initialize = () => { + // syn api check: syn might not have getVoices method + if (!syn || typeof syn.getVoices !== 'function') { + initialized = true + return + } + const voices = syn.getVoices() defaultUtteranceProps.voice = voices[0] || null initialized = true } const speak = (options) => { - const utterance = new SpeechSynthesisUtterance(options.message) + // options check: missing required options + if (!options || !options.message) { + return Promise.reject({ error: 'Missing message' }) + } + + // options check: missing or invalid id const id = options.id + if (id === undefined || id === null) { + return Promise.reject({ error: 'Missing id' }) + } + + // utterance status: utterance with same id already exists + if (utterances.has(id)) { + clear(id) + utterances.delete(id) + } + + const utterance = new SpeechSynthesisUtterance(options.message) utterance.lang = options.lang || defaultUtteranceProps.lang utterance.pitch = options.pitch || defaultUtteranceProps.pitch utterance.rate = options.rate || defaultUtteranceProps.rate utterance.voice = options.voice || defaultUtteranceProps.voice utterance.volume = options.volume || defaultUtteranceProps.volume - utterances.set(id, utterance) // Strong reference + + utterances.set(id, { utterance, timer: null, ignoreResume: false }) if (isAndroid === false) { utterance.onstart = () => { - resumeInfinity(utterance) + // utterances status: check if utterance still exists + if (utterances.has(id)) { + resumeInfinity(id) + } } utterance.onresume = () => { - // Ignore resume events that we *know* came from our own keep-alive (the pause()/resume() in resumeInfinity). - if (resumeFromKeepAlive === true) { - resumeFromKeepAlive = false + const state = utterances.get(id) + // utterance status: utterance might have been removed + if (!state) return + + if (state.ignoreResume === true) { + state.ignoreResume = false return } - // For any other real resume event (e.g. user or platform resuming a previously paused utterance). - resumeInfinity(utterance) + resumeInfinity(id) + } + + // pause events: handle pause events + utterance.onpause = () => { + // Stop keep-alive when manually paused + clear(id) } } return new Promise((resolve, reject) => { utterance.onend = () => { - clear() - utterances.delete(id) // Cleanup + clear(id) + utterances.delete(id) resolve() } utterance.onerror = (e) => { - clear() - utterances.delete(id) // Cleanup - reject(e) + clear(id) + utterances.delete(id) + // handle error: provide more context in error + reject(e || { error: 'Speech synthesis error' }) } - syn.speak(utterance) + // handle error: syn.speak might throw + try { + syn.speak(utterance) + } catch (error) { + clear(id) + utterances.delete(id) + reject(error) + } }) } @@ -131,12 +192,20 @@ export default { }, cancel() { if (syn !== undefined) { - syn.cancel() - clear() + // timers: clear all timers before cancelling + for (const id of utterances.keys()) { + clear(id) + } + + // handle errors: syn.cancel might throw + try { + syn.cancel() + } catch (error) { + Log.error('Error cancelling speech synthesis:', error) + } + + // utterances status: ensure all utterances are cleaned up + utterances.clear() } }, - // @todo - // getVoices() { - // return syn.getVoices() - // }, } From d43ee7223a37c85547b535e6bea926a1372025f2 Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Wed, 3 Dec 2025 12:19:55 +0000 Subject: [PATCH 07/66] chore: add placeholder for getVoices method in speech synthesis --- src/announcer/speechSynthesis.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/announcer/speechSynthesis.js b/src/announcer/speechSynthesis.js index fdc722fe..518baad5 100644 --- a/src/announcer/speechSynthesis.js +++ b/src/announcer/speechSynthesis.js @@ -208,4 +208,8 @@ export default { utterances.clear() } }, + // @todo + // getVoices() { + // return syn.getVoices() + // }, } From 3e56c9df9d7f34ffa3dc8df6ea57da97c9afb773 Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Wed, 3 Dec 2025 14:37:32 +0000 Subject: [PATCH 08/66] chore: improve error handling in speak function of speech synthesis --- src/announcer/speechSynthesis.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/announcer/speechSynthesis.js b/src/announcer/speechSynthesis.js index 518baad5..a8f4a5c1 100644 --- a/src/announcer/speechSynthesis.js +++ b/src/announcer/speechSynthesis.js @@ -161,10 +161,10 @@ const speak = (options) => { } utterance.onerror = (e) => { + Log.warn('SpeechSynthesisUtterance error:', e) clear(id) utterances.delete(id) - // handle error: provide more context in error - reject(e || { error: 'Speech synthesis error' }) + resolve() } // handle error: syn.speak might throw From d2721b6490c6ea2cf8f6ec5b526c55e0ec494749 Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Wed, 3 Dec 2025 14:37:45 +0000 Subject: [PATCH 09/66] chore: enhance queue management and debounce handling in announcer --- src/announcer/announcer.js | 43 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/src/announcer/announcer.js b/src/announcer/announcer.js index 11d2f26f..a13d5ad5 100644 --- a/src/announcer/announcer.js +++ b/src/announcer/announcer.js @@ -106,6 +106,8 @@ const addToQueue = (message, politeness, delay = false, options = {}) => { return done } +let currentResolveFn = null + const processQueue = async () => { if (isProcessing === true || queue.length === 0) return isProcessing = true @@ -113,11 +115,13 @@ const processQueue = async () => { const { message, resolveFn, delay, id, options = {} } = queue.shift() currentId = id + currentResolveFn = resolveFn if (delay) { setTimeout(() => { isProcessing = false currentId = null + currentResolveFn = null resolveFn('finished') processQueue() }, delay) @@ -138,12 +142,14 @@ const processQueue = async () => { Log.debug(`Announcer - finished speaking: "${message}" (id: ${id})`) currentId = null + currentResolveFn = null isProcessing = false resolveFn('finished') processQueue() }) .catch((e) => { currentId = null + currentResolveFn = null isProcessing = false Log.debug(`Announcer - error ("${e.error}") while speaking: "${message}" (id: ${id})`) resolveFn(e.error) @@ -158,13 +164,46 @@ const polite = (message, options = {}) => speak(message, 'polite', options) const assertive = (message, options = {}) => speak(message, 'assertive', options) +// Clear debounce timer +const clearDebounceTimer = () => { + if (debounce !== null) { + clearTimeout(debounce) + debounce = null + } +} + const stop = () => { - speechSynthesis.cancel() + Log.debug('Announcer - stop() called') + + // Clear debounce timer if speech hasn't started yet + clearDebounceTimer() + + if (currentId !== null && currentResolveFn) { + speechSynthesis.cancel() + const resolveFn = currentResolveFn + currentId = null + currentResolveFn = null + isProcessing = false + resolveFn('interrupted') + } } const clear = () => { + Log.debug('Announcer - clear() called') + + // Clear debounce timer + clearDebounceTimer() + + // Resolve all pending items in queue + while (queue.length > 0) { + const item = queue.shift() + if (item.resolveFn) { + Log.debug(`Announcer - clearing queued item: "${item.message}" (id: ${item.id})`) + item.resolveFn('cleared') + } + } + isProcessing = false - queue.length = 0 } const configure = (options = {}) => { From 6aa4a1958cdaa52d310350b2b2840c1d8abe268e Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Wed, 3 Dec 2025 15:05:29 +0000 Subject: [PATCH 10/66] fix: add optional chaining to safely check timer state in resumeInfinity function --- src/announcer/speechSynthesis.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/announcer/speechSynthesis.js b/src/announcer/speechSynthesis.js index a8f4a5c1..9752e6f7 100644 --- a/src/announcer/speechSynthesis.js +++ b/src/announcer/speechSynthesis.js @@ -51,7 +51,7 @@ const resumeInfinity = (id) => { } // Clear existing timer for this specific utterance - if (state.timer !== null) { + if (state?.timer !== null) { clearTimeout(state.timer) state.timer = null } From a1ebc9430839ed1f6470257a7020d705f49b29f9 Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Wed, 3 Dec 2025 17:02:13 +0000 Subject: [PATCH 11/66] fix: enhance stop and clear functions to ensure clean state in speech synthesis --- src/announcer/announcer.js | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/announcer/announcer.js b/src/announcer/announcer.js index a13d5ad5..d3cbd123 100644 --- a/src/announcer/announcer.js +++ b/src/announcer/announcer.js @@ -178,13 +178,20 @@ const stop = () => { // Clear debounce timer if speech hasn't started yet clearDebounceTimer() + // Always cancel speech synthesis to ensure clean state + speechSynthesis.cancel() + if (currentId !== null && currentResolveFn) { - speechSynthesis.cancel() const resolveFn = currentResolveFn currentId = null currentResolveFn = null isProcessing = false resolveFn('interrupted') + } else { + // Reset state even if no current utterance + currentId = null + currentResolveFn = null + isProcessing = false } } @@ -194,6 +201,9 @@ const clear = () => { // Clear debounce timer clearDebounceTimer() + // Cancel any active speech synthesis + speechSynthesis.cancel() + // Resolve all pending items in queue while (queue.length > 0) { const item = queue.shift() @@ -203,6 +213,9 @@ const clear = () => { } } + // Reset state + currentId = null + currentResolveFn = null isProcessing = false } From 9910a08c2ea356f743e1d20f6c76ac29af271fc9 Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Wed, 3 Dec 2025 17:13:34 +0000 Subject: [PATCH 12/66] fix: add double-check for utterance existence before resuming in resumeInfinity function --- src/announcer/speechSynthesis.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/announcer/speechSynthesis.js b/src/announcer/speechSynthesis.js index 9752e6f7..5934ac57 100644 --- a/src/announcer/speechSynthesis.js +++ b/src/announcer/speechSynthesis.js @@ -73,9 +73,15 @@ const resumeInfinity = (id) => { } }, 0) - state.timer = setTimeout(() => { - resumeInfinity(id) - }, 5000) + // Check if utterance still exists before scheduling next cycle + if (utterances.has(id)) { + state.timer = setTimeout(() => { + // Double-check utterance still exists before resuming + if (utterances.has(id)) { + resumeInfinity(id) + } + }, 5000) + } } const defaultUtteranceProps = { From 624604fa18e6a1cca6c964e8ce2c0647af58c9dc Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Thu, 4 Dec 2025 01:00:17 +0000 Subject: [PATCH 13/66] fix: add early return in clear function to handle non-existent state --- src/announcer/speechSynthesis.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/announcer/speechSynthesis.js b/src/announcer/speechSynthesis.js index 5934ac57..5fc673cd 100644 --- a/src/announcer/speechSynthesis.js +++ b/src/announcer/speechSynthesis.js @@ -27,6 +27,9 @@ let initialized = false const clear = (id) => { const state = utterances.get(id) + if (!state) { + return + } if (state?.timer !== null) { clearTimeout(state.timer) state.timer = null @@ -153,10 +156,10 @@ const speak = (options) => { } // pause events: handle pause events - utterance.onpause = () => { - // Stop keep-alive when manually paused - clear(id) - } + // utterance.onpause = () => { + // // Stop keep-alive when manually paused + // clear(id) + // } } return new Promise((resolve, reject) => { From fa88731eb7d870e66cc8538b7f50dad03e534123 Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Thu, 4 Dec 2025 09:00:05 +0000 Subject: [PATCH 14/66] chore: remove unused pause event handling in speak function --- src/announcer/speechSynthesis.js | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/announcer/speechSynthesis.js b/src/announcer/speechSynthesis.js index 5fc673cd..0158508e 100644 --- a/src/announcer/speechSynthesis.js +++ b/src/announcer/speechSynthesis.js @@ -154,12 +154,6 @@ const speak = (options) => { resumeInfinity(id) } - - // pause events: handle pause events - // utterance.onpause = () => { - // // Stop keep-alive when manually paused - // clear(id) - // } } return new Promise((resolve, reject) => { From 4fb38de2216d512f01ac7697a9defee3dbc3b28c Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Thu, 4 Dec 2025 09:00:27 +0000 Subject: [PATCH 15/66] fix: add cancelPrevious option to AnnouncerUtterance for managing speech queue --- index.d.ts | 6 ++++++ src/announcer/announcer.js | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/index.d.ts b/index.d.ts index 0bd00395..acce8946 100644 --- a/index.d.ts +++ b/index.d.ts @@ -54,6 +54,12 @@ declare module '@lightningjs/blits' { * @default 1 */ volume?: number, + /** + * Whether to cancel previous announcements when adding this one + * + * @default false + */ + cancelPrevious?: boolean } export interface AnnouncerUtterance extends Promise { diff --git a/src/announcer/announcer.js b/src/announcer/announcer.js index d3cbd123..11692fce 100644 --- a/src/announcer/announcer.js +++ b/src/announcer/announcer.js @@ -51,6 +51,11 @@ const toggle = (v) => { const speak = (message, politeness = 'off', options = {}) => { if (active === false) return noopAnnouncement + // if cancelPrevious option is set, clear the queue and stop current speech + if (options.cancelPrevious === true) { + clear() + } + return addToQueue(message, politeness, false, options) } From cc30b5e6bb660590d33ed6bafbb93eaa98e481f2 Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Thu, 4 Dec 2025 09:46:18 +0000 Subject: [PATCH 16/66] chore: simplify stop api --- src/announcer/announcer.js | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/announcer/announcer.js b/src/announcer/announcer.js index 11692fce..6a8e3393 100644 --- a/src/announcer/announcer.js +++ b/src/announcer/announcer.js @@ -186,17 +186,17 @@ const stop = () => { // Always cancel speech synthesis to ensure clean state speechSynthesis.cancel() - if (currentId !== null && currentResolveFn) { - const resolveFn = currentResolveFn - currentId = null - currentResolveFn = null - isProcessing = false - resolveFn('interrupted') - } else { - // Reset state even if no current utterance - currentId = null - currentResolveFn = null - isProcessing = false + // Store resolve function before resetting state + const prevResolveFn = currentResolveFn + + // Reset state + currentId = null + currentResolveFn = null + isProcessing = false + + // Resolve promise if there was an active utterance + if (prevResolveFn) { + prevResolveFn('interrupted') } } From a2a86aed60ba899eb4cc212f75f97971a09aeb88 Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Thu, 4 Dec 2025 11:51:39 +0000 Subject: [PATCH 17/66] feat(router): add after hook to RouteHooks for post-navigation handling --- index.d.ts | 1 + src/router/router.js | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/index.d.ts b/index.d.ts index df472dca..49b75f10 100644 --- a/index.d.ts +++ b/index.d.ts @@ -750,6 +750,7 @@ declare module '@lightningjs/blits' { export interface RouteHooks { before?: (to: Route, from: Route) => string | Route | Promise; + after?: (to: Route, from: Route) => string | Route | Promise; } export type Route = { diff --git a/src/router/router.js b/src/router/router.js index faf25ce1..4f1d80b5 100644 --- a/src/router/router.js +++ b/src/router/router.js @@ -335,6 +335,15 @@ export const navigate = async function () { return } } + + if (route.hooks.after) { + try { + await route.hooks.after.call(this.parent, route, previousRoute) + } catch (error) { + Log.error('Error or Rejected Promise in "After" Hook', error) + } + } + // add the previous route (technically still the current route at this point) // into the history stack when inHistory is true and we're not navigating back if ( From ba64e4c3dcdc2856336b0774e1540daf870a8e3d Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Thu, 4 Dec 2025 14:01:58 +0000 Subject: [PATCH 18/66] feat(router): enhance after hooks in RouterHooks for improved navigation handling --- index.d.ts | 4 ++-- src/router/router.js | 23 +++++++++++++++-------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/index.d.ts b/index.d.ts index 49b75f10..a52d35c3 100644 --- a/index.d.ts +++ b/index.d.ts @@ -626,7 +626,7 @@ declare module '@lightningjs/blits' { export interface RouterHooks { init?: () => Promise<> | void; beforeEach?: (to: Route, from: Route) => string | Route | Promise | void; - afterEach?: (to: Route, toComponent: ComponentBase | null, from: Route | undefined, fromComponent: ComponentBase | null) => Promise | void; + afterEach?: (to: Route, toComponent: ComponentBase, from: Route, fromComponent: ComponentBase) => string | Route | Promise | void; error?: (err: string) => string | Route | Promise | void; } @@ -750,7 +750,7 @@ declare module '@lightningjs/blits' { export interface RouteHooks { before?: (to: Route, from: Route) => string | Route | Promise; - after?: (to: Route, from: Route) => string | Route | Promise; + after?: (to: Route, toComponent: ComponentBase, from: Route, fromComponent: ComponentBase) => string | Route | Promise; } export type Route = { diff --git a/src/router/router.js b/src/router/router.js index 4f1d80b5..a99c4177 100644 --- a/src/router/router.js +++ b/src/router/router.js @@ -336,14 +336,6 @@ export const navigate = async function () { } } - if (route.hooks.after) { - try { - await route.hooks.after.call(this.parent, route, previousRoute) - } catch (error) { - Log.error('Error or Rejected Promise in "After" Hook', error) - } - } - // add the previous route (technically still the current route at this point) // into the history stack when inHistory is true and we're not navigating back if ( @@ -488,6 +480,21 @@ export const navigate = async function () { } } + if (route.hooks.after) { + try { + // Get the previous view before it's removed + const previousView = !reuse && children.length > 1 ? children[children.length - 2] : null + await route.hooks.after.call(this.parent, { + to: route, + toComponent: view, + from: previousRoute, + fromComponent: previousView, + }) + } catch (error) { + Log.error('Error or Rejected Promise in "After" Hook', error) + } + } + // set focus to the view that we're routing to (unless explicitly disabling passing focus) if (route.options.passFocus !== false) { focus ? focus.$focus() : /** @type {BlitsComponent} */ (view).$focus() From 3a1a66ede22a333871a1e9bcea1d400db1044bd4 Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Fri, 5 Dec 2025 10:55:26 +0000 Subject: [PATCH 19/66] fix: increase debounce duration in processQueue to improve performance --- src/announcer/announcer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/announcer/announcer.js b/src/announcer/announcer.js index 6a8e3393..7e387e05 100644 --- a/src/announcer/announcer.js +++ b/src/announcer/announcer.js @@ -161,7 +161,7 @@ const processQueue = async () => { processQueue() }) debounce = null - }, 200) + }, 300) } } From 7d842ee75ccd0b224f2718809a1a63b6d58f5193 Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Fri, 5 Dec 2025 10:56:02 +0000 Subject: [PATCH 20/66] fix: add waitForSynthReady function to ensure speech synthesis engine is ready before speaking --- src/announcer/speechSynthesis.js | 86 +++++++++++++++++++++++--------- 1 file changed, 63 insertions(+), 23 deletions(-) diff --git a/src/announcer/speechSynthesis.js b/src/announcer/speechSynthesis.js index 0158508e..2e172593 100644 --- a/src/announcer/speechSynthesis.js +++ b/src/announcer/speechSynthesis.js @@ -107,7 +107,45 @@ const initialize = () => { initialized = true } -const speak = (options) => { +const waitForSynthReady = (timeoutMs = 2000, checkIntervalMs = 100) => { + return new Promise((resolve) => { + if (!syn) { + Log.warn('SpeechSynthesis - syn unavailable') + resolve() + return + } + + if (!syn.speaking && !syn.pending) { + Log.warn('SpeechSynthesis - ready immediately') + resolve() + return + } + + Log.warn('SpeechSynthesis - waiting for ready state...') + + const startTime = Date.now() + + const intervalId = window.setInterval(() => { + const elapsed = Date.now() - startTime + const isReady = !syn.speaking && !syn.pending + + if (isReady) { + Log.warn(`SpeechSynthesis - ready after ${elapsed}ms`) + window.clearInterval(intervalId) + resolve() + } else if (elapsed >= timeoutMs) { + Log.warn(`SpeechSynthesis - timeout after ${elapsed}ms, forcing ready`, { + speaking: syn.speaking, + pending: syn.pending, + }) + window.clearInterval(intervalId) + resolve() + } + }, checkIntervalMs) + }) +} + +const speak = async (options) => { // options check: missing required options if (!options || !options.message) { return Promise.reject({ error: 'Missing message' }) @@ -125,6 +163,9 @@ const speak = (options) => { utterances.delete(id) } + // Wait for engine to be ready + await waitForSynthReady() + const utterance = new SpeechSynthesisUtterance(options.message) utterance.lang = options.lang || defaultUtteranceProps.lang utterance.pitch = options.pitch || defaultUtteranceProps.pitch @@ -134,28 +175,6 @@ const speak = (options) => { utterances.set(id, { utterance, timer: null, ignoreResume: false }) - if (isAndroid === false) { - utterance.onstart = () => { - // utterances status: check if utterance still exists - if (utterances.has(id)) { - resumeInfinity(id) - } - } - - utterance.onresume = () => { - const state = utterances.get(id) - // utterance status: utterance might have been removed - if (!state) return - - if (state.ignoreResume === true) { - state.ignoreResume = false - return - } - - resumeInfinity(id) - } - } - return new Promise((resolve, reject) => { utterance.onend = () => { clear(id) @@ -170,6 +189,27 @@ const speak = (options) => { resolve() } + if (isAndroid === false) { + utterance.onstart = () => { + // utterances status: check if utterance still exists + if (utterances.has(id)) { + resumeInfinity(id) + } + } + + utterance.onresume = () => { + const state = utterances.get(id) + // utterance status: utterance might have been removed + if (!state) return + + if (state.ignoreResume === true) { + state.ignoreResume = false + return + } + + resumeInfinity(id) + } + } // handle error: syn.speak might throw try { syn.speak(utterance) From 41aa28995a14ccc94c711c3450575be7459a4d39 Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Fri, 5 Dec 2025 17:23:14 +0000 Subject: [PATCH 21/66] fix: add enableUtteranceKeepAlive option to improve speech synthesis handling --- index.d.ts | 8 +++++++- src/announcer/announcer.js | 13 ++++++++++++- src/announcer/speechSynthesis.js | 12 +++++------- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/index.d.ts b/index.d.ts index acce8946..d34d943e 100644 --- a/index.d.ts +++ b/index.d.ts @@ -59,7 +59,13 @@ declare module '@lightningjs/blits' { * * @default false */ - cancelPrevious?: boolean + cancelPrevious?: boolean, + /** + * Whether to enable utterance keep-alive (prevents pausing on some platforms) + * + * @default undefined + */ + enableUtteranceKeepAlive?: boolean } export interface AnnouncerUtterance extends Promise { diff --git a/src/announcer/announcer.js b/src/announcer/announcer.js index 7e387e05..f0a6114c 100644 --- a/src/announcer/announcer.js +++ b/src/announcer/announcer.js @@ -25,8 +25,13 @@ let isProcessing = false let currentId = null let debounce = null +const isAndroid = /android/i.test((window.navigator || {}).userAgent || '') +const defaultUtteranceKeepAlive = !isAndroid + // Global default utterance options -let globalDefaultOptions = {} +let globalDefaultOptions = { + enableUtteranceKeepAlive: defaultUtteranceKeepAlive, +} const noopAnnouncement = { then() {}, @@ -206,6 +211,8 @@ const clear = () => { // Clear debounce timer clearDebounceTimer() + const prevResolveFn = currentResolveFn + // Cancel any active speech synthesis speechSynthesis.cancel() @@ -222,6 +229,10 @@ const clear = () => { currentId = null currentResolveFn = null isProcessing = false + + if (prevResolveFn) { + prevResolveFn('cleared') + } } const configure = (options = {}) => { diff --git a/src/announcer/speechSynthesis.js b/src/announcer/speechSynthesis.js index 2e172593..4411dab4 100644 --- a/src/announcer/speechSynthesis.js +++ b/src/announcer/speechSynthesis.js @@ -19,8 +19,6 @@ import { Log } from '../lib/log.js' const syn = window.speechSynthesis -const isAndroid = /android/i.test((window.navigator || {}).userAgent || '') - const utterances = new Map() // id -> { utterance, timer, ignoreResume } let initialized = false @@ -36,7 +34,7 @@ const clear = (id) => { } } -const resumeInfinity = (id) => { +const startKeepAlive = (id) => { const state = utterances.get(id) // utterance status: utterance was removed (cancelled or finished) @@ -81,7 +79,7 @@ const resumeInfinity = (id) => { state.timer = setTimeout(() => { // Double-check utterance still exists before resuming if (utterances.has(id)) { - resumeInfinity(id) + startKeepAlive(id) } }, 5000) } @@ -189,11 +187,11 @@ const speak = async (options) => { resolve() } - if (isAndroid === false) { + if (options.enableUtteranceKeepAlive === true) { utterance.onstart = () => { // utterances status: check if utterance still exists if (utterances.has(id)) { - resumeInfinity(id) + startKeepAlive(id) } } @@ -207,7 +205,7 @@ const speak = async (options) => { return } - resumeInfinity(id) + startKeepAlive(id) } } // handle error: syn.speak might throw From 6f3a1aea637d5f94cb79d297dd30a10e933c00c8 Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Mon, 8 Dec 2025 14:13:53 +0000 Subject: [PATCH 22/66] fix: remove previous resolve function in clear method --- src/announcer/announcer.js | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/announcer/announcer.js b/src/announcer/announcer.js index f0a6114c..88329bdd 100644 --- a/src/announcer/announcer.js +++ b/src/announcer/announcer.js @@ -211,8 +211,6 @@ const clear = () => { // Clear debounce timer clearDebounceTimer() - const prevResolveFn = currentResolveFn - // Cancel any active speech synthesis speechSynthesis.cancel() @@ -229,10 +227,6 @@ const clear = () => { currentId = null currentResolveFn = null isProcessing = false - - if (prevResolveFn) { - prevResolveFn('cleared') - } } const configure = (options = {}) => { From 2c5255f22f1cd9cb8280676b2a6a4fddbc597540 Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Mon, 8 Dec 2025 14:35:34 +0000 Subject: [PATCH 23/66] fix: remove redundant utterances.delete calls in clear and startKeepAlive functions --- src/announcer/speechSynthesis.js | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/announcer/speechSynthesis.js b/src/announcer/speechSynthesis.js index 4411dab4..d308d10c 100644 --- a/src/announcer/speechSynthesis.js +++ b/src/announcer/speechSynthesis.js @@ -32,6 +32,7 @@ const clear = (id) => { clearTimeout(state.timer) state.timer = null } + utterances.delete(id) } const startKeepAlive = (id) => { @@ -47,7 +48,6 @@ const startKeepAlive = (id) => { // utterance check: utterance instance is invalid if (!(utterance instanceof SpeechSynthesisUtterance)) { clear(id) - utterances.delete(id) return } @@ -60,7 +60,6 @@ const startKeepAlive = (id) => { // syn status: syn might be undefined or cancelled if (!syn) { clear(id) - utterances.delete(id) return } @@ -158,7 +157,6 @@ const speak = async (options) => { // utterance status: utterance with same id already exists if (utterances.has(id)) { clear(id) - utterances.delete(id) } // Wait for engine to be ready @@ -176,14 +174,12 @@ const speak = async (options) => { return new Promise((resolve, reject) => { utterance.onend = () => { clear(id) - utterances.delete(id) resolve() } utterance.onerror = (e) => { Log.warn('SpeechSynthesisUtterance error:', e) clear(id) - utterances.delete(id) resolve() } @@ -213,7 +209,6 @@ const speak = async (options) => { syn.speak(utterance) } catch (error) { clear(id) - utterances.delete(id) reject(error) } }) From 8e9d10955b008cb5da42afc45a0142349dce0e70 Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Mon, 8 Dec 2025 14:37:19 +0000 Subject: [PATCH 24/66] fix: log result of speaking in processQueue for better debugging --- src/announcer/announcer.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/announcer/announcer.js b/src/announcer/announcer.js index 88329bdd..51babfca 100644 --- a/src/announcer/announcer.js +++ b/src/announcer/announcer.js @@ -148,8 +148,9 @@ const processQueue = async () => { ...globalDefaultOptions, ...options, }) - .then(() => { + .then((result) => { Log.debug(`Announcer - finished speaking: "${message}" (id: ${id})`) + Log.debug('Announcer - finished result: ', result) currentId = null currentResolveFn = null From 4dc14df47add7aa3e0bf3aa917d944c47165627f Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Mon, 8 Dec 2025 14:52:02 +0000 Subject: [PATCH 25/66] fix: include result in onend callback of speak function for better handling --- src/announcer/speechSynthesis.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/announcer/speechSynthesis.js b/src/announcer/speechSynthesis.js index d308d10c..e464675a 100644 --- a/src/announcer/speechSynthesis.js +++ b/src/announcer/speechSynthesis.js @@ -172,9 +172,9 @@ const speak = async (options) => { utterances.set(id, { utterance, timer: null, ignoreResume: false }) return new Promise((resolve, reject) => { - utterance.onend = () => { + utterance.onend = (result) => { clear(id) - resolve() + resolve(result) } utterance.onerror = (e) => { From fd776761c072b456693b4090456d76bfbed5c99a Mon Sep 17 00:00:00 2001 From: sairamg Date: Tue, 16 Dec 2025 19:17:28 +0530 Subject: [PATCH 26/66] feat(router): add runtime navigateHistory toggle and forward Back to parent when disabled --- src/component/base/router.js | 8 +++- src/components/RouterView.js | 74 ++++++++++++++++++++---------------- src/router/router.js | 15 ++++++++ 3 files changed, 63 insertions(+), 34 deletions(-) diff --git a/src/component/base/router.js b/src/component/base/router.js index 9fe021bd..bd586250 100644 --- a/src/component/base/router.js +++ b/src/component/base/router.js @@ -16,13 +16,19 @@ */ import symbols from '../../lib/symbols.js' -import { to, currentRoute, back, state } from '../../router/router.js' +import { to, currentRoute, back, state, setNavigateHistory } from '../../router/router.js' export default { $router: { value: { to, back, + navigateHistory(enabled = true) { + setNavigateHistory(enabled) + }, + get navigateHistoryEnabled() { + return state.navigateHistory !== false + }, get currentRoute() { return currentRoute }, diff --git a/src/components/RouterView.js b/src/components/RouterView.js index f6a3d97a..e11259d2 100644 --- a/src/components/RouterView.js +++ b/src/components/RouterView.js @@ -16,46 +16,54 @@ */ import Component from '../component.js' -import Router from '../router/router.js' +import Router, { state as routerState } from '../router/router.js' import symbols from '../lib/symbols.js' import Focus from '../focus.js' let hashchangeHandler = null +/** @typedef {{ $input?: (event: any) => boolean, $focus?: (event: any) => void }} RouterViewParent */ + export default () => - Component('RouterView', { - template: ` - - `, - state() { - return { - activeView: null, - } - }, - hooks: { - async ready() { - if (this.parent[symbols.routerHooks] && this.parent[symbols.routerHooks].init) { - await this.parent[symbols.routerHooks].init.apply(this.parent) + Component( + 'RouterView', + /** @type {any} */ ({ + template: ` + + `, + state() { + return { + activeView: null, } - hashchangeHandler = () => Router.navigate.apply(this) - Router.navigate.apply(this) - window.addEventListener('hashchange', hashchangeHandler) - }, - destroy() { - window.removeEventListener('hashchange', hashchangeHandler, false) }, - focus() { - if (this.activeView && Focus.get() === this) { - this.activeView.$focus() - } + hooks: { + async ready() { + if (this.parent[symbols.routerHooks] && this.parent[symbols.routerHooks].init) { + await this.parent[symbols.routerHooks].init.apply(this.parent) + } + hashchangeHandler = () => Router.navigate.apply(this) + Router.navigate.apply(this) + window.addEventListener('hashchange', hashchangeHandler) + }, + destroy() { + window.removeEventListener('hashchange', hashchangeHandler, false) + }, + focus() { + if (this.activeView && Focus.get() === this) { + this.activeView.$focus() + } + }, }, - }, - input: { - back(e) { - const navigating = Router.back.call(this) - if (navigating === false) { - this.parent.$focus(e) - } + input: { + back(e) { + if (routerState.navigateHistory !== false && Router.back.call(this)) return + + // If history navigation is disabled or fails, forward Back to parent + /** @type {RouterViewParent | undefined | null} */ + const parent = this.parent + const handledByParent = parent?.$input?.(e) ?? false + if (handledByParent === false) parent?.$focus?.(e) + }, }, - }, - }) + }) + ) diff --git a/src/router/router.js b/src/router/router.js index ec639eef..1525203b 100644 --- a/src/router/router.js +++ b/src/router/router.js @@ -30,6 +30,9 @@ import Settings from '../settings.js' * @typedef {import('../component.js').BlitsComponent} BlitsComponent - The element of the route * @typedef {import('../engines/L3/element.js').BlitsElement} BlitsElement - The element of the route * + * @typedef {BlitsComponent|BlitsComponentFactory} RouteView + * @typedef {RouteView & { default?: BlitsComponentFactory }} RouteViewWithOptionalDefault + * * @typedef {Object} Route * @property {string} path - The path of the route * @property {string} hash - The hash of the route @@ -57,6 +60,7 @@ export const state = reactive( data: null, params: null, hash: '', + navigateHistory: true, }, Settings.get('reactivityMode'), true @@ -354,6 +358,7 @@ export const navigate = async function () { /** @type {import('../engines/L3/element.js').BlitsElement} */ let holder + /** @type {RouteViewWithOptionalDefault|undefined|null} */ let view let focus // when navigating back let's see if we're navigating back to a route that was kept alive @@ -637,8 +642,18 @@ export const back = function () { return false } +/** + * Enable or disable RouterView history navigation on Back input. + * When disabled, RouterView will not call Router.back(). + * @param {boolean} enabled + */ +export const setNavigateHistory = (enabled = true) => { + state.navigateHistory = enabled !== false +} + export default { navigate, to, back, + setNavigateHistory, } From 806ccc5cc8e180aa43bc1f769263fefb002a3d71 Mon Sep 17 00:00:00 2001 From: sairamg Date: Tue, 16 Dec 2025 19:39:05 +0530 Subject: [PATCH 27/66] Updated document --- docs/router/basics.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/router/basics.md b/docs/router/basics.md index b14580a9..80e68125 100644 --- a/docs/router/basics.md +++ b/docs/router/basics.md @@ -107,6 +107,8 @@ export default Blits.Component('Poster', { Whenever you navigate to a new page, the URL hash will automatically be updated. Unless specified otherwise, navigating to a new page, will add that route to the history stack. The `back` input action is automatically wired up to navigate back down the history stack. +If you want to disable this automatic history navigation on Back (for example, to let a top-level navigation component handle Back), call `this.$router.navigateHistory(false)`. Call `this.$router.navigateHistory(true)` to restore the default behavior. + By default, every time you navigate to a new route, the application focus will be automatically passed to the newly loaded page. If you instead want to maintain the current focus (for example in a widget that sits above your RouterView), you can use `passFocus: false` as part of the router options. ## Deeplinking From 6acc761b0db8bc936d69cd696cca56b3fd488f34 Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Thu, 18 Dec 2025 14:14:08 +0000 Subject: [PATCH 28/66] feat(router): refactor afterEach and after hooks for improved parameter handling --- src/router/router.js | 34 +++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/src/router/router.js b/src/router/router.js index a99c4177..8368da58 100644 --- a/src/router/router.js +++ b/src/router/router.js @@ -466,14 +466,15 @@ export const navigate = async function () { try { // Get the previous view before it's removed const previousView = - !reuse && children.length > 1 ? children[children.length - 2] : null - - await hooks.afterEach.call(this.parent, { - to: route, - toComponent: view, - from: previousRoute, - fromComponent: previousView, - }) + !reuse && previousRoute && children.length > 1 ? children[children.length - 2] : null + + await hooks.afterEach.call( + this.parent, + route, // to + view, // toComponent + previousRoute, // from + previousView // fromComponent + ) } catch (error) { Log.error('Error in "AfterEach" Hook', error) } @@ -483,13 +484,16 @@ export const navigate = async function () { if (route.hooks.after) { try { // Get the previous view before it's removed - const previousView = !reuse && children.length > 1 ? children[children.length - 2] : null - await route.hooks.after.call(this.parent, { - to: route, - toComponent: view, - from: previousRoute, - fromComponent: previousView, - }) + const previousView = + !reuse && previousRoute && children.length > 1 ? children[children.length - 2] : null + + await route.hooks.after.call( + this.parent, + route, // to + view, // toComponent + previousRoute, // from + previousView // fromComponent + ) } catch (error) { Log.error('Error or Rejected Promise in "After" Hook', error) } From 78d6698b7e30a0709a2091372a0b7232f00dbed3 Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Thu, 18 Dec 2025 14:22:26 +0000 Subject: [PATCH 29/66] feat(router): move afterEach and after hooks to the end of the transition --- src/router/router.js | 78 ++++++++++++++++++++++---------------------- 1 file changed, 39 insertions(+), 39 deletions(-) diff --git a/src/router/router.js b/src/router/router.js index 8368da58..251dec40 100644 --- a/src/router/router.js +++ b/src/router/router.js @@ -460,45 +460,6 @@ export const navigate = async function () { const children = this[symbols.children] this.activeView = children[children.length - 1] - if (this.parent[symbols.routerHooks]) { - const hooks = this.parent[symbols.routerHooks] - if (hooks.afterEach) { - try { - // Get the previous view before it's removed - const previousView = - !reuse && previousRoute && children.length > 1 ? children[children.length - 2] : null - - await hooks.afterEach.call( - this.parent, - route, // to - view, // toComponent - previousRoute, // from - previousView // fromComponent - ) - } catch (error) { - Log.error('Error in "AfterEach" Hook', error) - } - } - } - - if (route.hooks.after) { - try { - // Get the previous view before it's removed - const previousView = - !reuse && previousRoute && children.length > 1 ? children[children.length - 2] : null - - await route.hooks.after.call( - this.parent, - route, // to - view, // toComponent - previousRoute, // from - previousView // fromComponent - ) - } catch (error) { - Log.error('Error or Rejected Promise in "After" Hook', error) - } - } - // set focus to the view that we're routing to (unless explicitly disabling passing focus) if (route.options.passFocus !== false) { focus ? focus.$focus() : /** @type {BlitsComponent} */ (view).$focus() @@ -540,6 +501,45 @@ export const navigate = async function () { await setOrAnimate(holder, route.transition.in, shouldAnimate) } } + + if (this.parent[symbols.routerHooks]) { + const hooks = this.parent[symbols.routerHooks] + if (hooks.afterEach) { + try { + // Get the previous view before it's removed + const previousView = + !reuse && previousRoute && children.length > 1 ? children[children.length - 2] : null + + await hooks.afterEach.call( + this.parent, + route, // to + view, // toComponent + previousRoute, // from + previousView // fromComponent + ) + } catch (error) { + Log.error('Error in "AfterEach" Hook', error) + } + } + } + + if (route.hooks.after) { + try { + // Get the previous view before it's removed + const previousView = + !reuse && previousRoute && children.length > 1 ? children[children.length - 2] : null + + await route.hooks.after.call( + this.parent, + route, // to + view, // toComponent + previousRoute, // from + previousView // fromComponent + ) + } catch (error) { + Log.error('Error or Rejected Promise in "After" Hook', error) + } + } } else { Log.error(`Route ${route.hash} not found`) const routerHooks = this.parent[symbols.routerHooks] From 737950ad05643936ba12f84681383bd3927dc59f Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Fri, 19 Dec 2025 17:42:13 +0000 Subject: [PATCH 30/66] feat(router): refactor navigate function to use broader scope for oldView in hooks --- src/router/router.js | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/src/router/router.js b/src/router/router.js index 251dec40..93f0ebe1 100644 --- a/src/router/router.js +++ b/src/router/router.js @@ -477,13 +477,15 @@ export const navigate = async function () { } let shouldAnimate = false + // Declare oldView in broader scope so it can be used in hooks below + let oldView = null // apply out out transition on previous view if available, unless // we're reusing the prvious page component if (previousRoute !== undefined && reuse === false) { // only animate when there is a previous route shouldAnimate = true - const oldView = this[symbols.children].splice(1, 1).pop() + oldView = this[symbols.children].splice(1, 1).pop() if (oldView) { removeView(previousRoute, oldView, route.transition.out, navigatingBack) } @@ -506,16 +508,12 @@ export const navigate = async function () { const hooks = this.parent[symbols.routerHooks] if (hooks.afterEach) { try { - // Get the previous view before it's removed - const previousView = - !reuse && previousRoute && children.length > 1 ? children[children.length - 2] : null - await hooks.afterEach.call( this.parent, route, // to view, // toComponent previousRoute, // from - previousView // fromComponent + oldView // fromComponent ) } catch (error) { Log.error('Error in "AfterEach" Hook', error) @@ -525,16 +523,12 @@ export const navigate = async function () { if (route.hooks.after) { try { - // Get the previous view before it's removed - const previousView = - !reuse && previousRoute && children.length > 1 ? children[children.length - 2] : null - await route.hooks.after.call( this.parent, route, // to view, // toComponent previousRoute, // from - previousView // fromComponent + oldView // fromComponent ) } catch (error) { Log.error('Error or Rejected Promise in "After" Hook', error) From d2078d328b5d6b8f3bdc4b06258ddf722ef413ff Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Fri, 19 Dec 2025 17:47:24 +0000 Subject: [PATCH 31/66] feat(router): update afterEach and after hooks to allow undefined and null for from parameters --- index.d.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/index.d.ts b/index.d.ts index a52d35c3..fe88227d 100644 --- a/index.d.ts +++ b/index.d.ts @@ -626,7 +626,7 @@ declare module '@lightningjs/blits' { export interface RouterHooks { init?: () => Promise<> | void; beforeEach?: (to: Route, from: Route) => string | Route | Promise | void; - afterEach?: (to: Route, toComponent: ComponentBase, from: Route, fromComponent: ComponentBase) => string | Route | Promise | void; + afterEach?: (to: Route, toComponent: ComponentBase, from: Route | undefined, fromComponent: ComponentBase | null) => string | Route | Promise | void; error?: (err: string) => string | Route | Promise | void; } @@ -750,7 +750,7 @@ declare module '@lightningjs/blits' { export interface RouteHooks { before?: (to: Route, from: Route) => string | Route | Promise; - after?: (to: Route, toComponent: ComponentBase, from: Route, fromComponent: ComponentBase) => string | Route | Promise; + after?: (to: Route, toComponent: ComponentBase, from: Route | undefined, fromComponent: ComponentBase | null) => string | Route | Promise; } export type Route = { From 3559bdf889b7812eec2c5fbb979d7f32c287904c Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Fri, 19 Dec 2025 17:51:48 +0000 Subject: [PATCH 32/66] feat(router): await removeView in navigate function for proper asynchronous handling --- src/router/router.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/router/router.js b/src/router/router.js index 93f0ebe1..7c82222f 100644 --- a/src/router/router.js +++ b/src/router/router.js @@ -487,7 +487,7 @@ export const navigate = async function () { shouldAnimate = true oldView = this[symbols.children].splice(1, 1).pop() if (oldView) { - removeView(previousRoute, oldView, route.transition.out, navigatingBack) + await removeView(previousRoute, oldView, route.transition.out, navigatingBack) } } From 84419449b7ad322ba66ef2ac0877de328badce21 Mon Sep 17 00:00:00 2001 From: Michiel van der Geest Date: Tue, 23 Dec 2025 21:15:59 +0100 Subject: [PATCH 33/66] Removed unneeded variable assignments. --- src/announcer/announcer.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/announcer/announcer.js b/src/announcer/announcer.js index 51babfca..e45e0dd4 100644 --- a/src/announcer/announcer.js +++ b/src/announcer/announcer.js @@ -25,12 +25,9 @@ let isProcessing = false let currentId = null let debounce = null -const isAndroid = /android/i.test((window.navigator || {}).userAgent || '') -const defaultUtteranceKeepAlive = !isAndroid - // Global default utterance options let globalDefaultOptions = { - enableUtteranceKeepAlive: defaultUtteranceKeepAlive, + enableUtteranceKeepAlive: /android/i.test((window.navigator || {}).userAgent || ''), } const noopAnnouncement = { From 9959d923afd6a65687045561f8afce4588cffb1c Mon Sep 17 00:00:00 2001 From: Michiel van der Geest Date: Tue, 23 Dec 2025 21:21:49 +0100 Subject: [PATCH 34/66] Removed debug log. --- src/announcer/announcer.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/announcer/announcer.js b/src/announcer/announcer.js index e45e0dd4..a79b8807 100644 --- a/src/announcer/announcer.js +++ b/src/announcer/announcer.js @@ -145,9 +145,8 @@ const processQueue = async () => { ...globalDefaultOptions, ...options, }) - .then((result) => { + .then(() => { Log.debug(`Announcer - finished speaking: "${message}" (id: ${id})`) - Log.debug('Announcer - finished result: ', result) currentId = null currentResolveFn = null From 1d4884e6dc008cccac644249cfc48d6c74d76ce1 Mon Sep 17 00:00:00 2001 From: Michiel van der Geest Date: Tue, 23 Dec 2025 21:36:08 +0100 Subject: [PATCH 35/66] Removed debug log and renamed variable. --- src/announcer/announcer.js | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/announcer/announcer.js b/src/announcer/announcer.js index a79b8807..3b41078f 100644 --- a/src/announcer/announcer.js +++ b/src/announcer/announcer.js @@ -180,8 +180,6 @@ const clearDebounceTimer = () => { } const stop = () => { - Log.debug('Announcer - stop() called') - // Clear debounce timer if speech hasn't started yet clearDebounceTimer() @@ -189,7 +187,7 @@ const stop = () => { speechSynthesis.cancel() // Store resolve function before resetting state - const prevResolveFn = currentResolveFn + const resolveFn = currentResolveFn // Reset state currentId = null @@ -197,14 +195,12 @@ const stop = () => { isProcessing = false // Resolve promise if there was an active utterance - if (prevResolveFn) { - prevResolveFn('interrupted') + if (resolveFn !== null) { + resolveFn('interrupted') } } const clear = () => { - Log.debug('Announcer - clear() called') - // Clear debounce timer clearDebounceTimer() From 3ca78b20722801b9aa3a49bcb909683a277ecc9a Mon Sep 17 00:00:00 2001 From: Michiel van der Geest Date: Tue, 30 Dec 2025 11:26:16 +0100 Subject: [PATCH 36/66] Updated package lock. --- package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 39dc19e1..151a229b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@lightningjs/blits", - "version": "1.45.0", + "version": "1.45.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@lightningjs/blits", - "version": "1.45.0", + "version": "1.45.1", "license": "Apache-2.0", "dependencies": { "@lightningjs/msdf-generator": "^1.2.0", From e3816b255ccda4d2a92f0ede6e7d1f100087fdb9 Mon Sep 17 00:00:00 2001 From: Suresh Kumar Gangumalla Date: Tue, 30 Dec 2025 16:19:28 +0530 Subject: [PATCH 37/66] Added test cases for prepublishOnly script 1. resovled issue with including .test.js files inside node-modules directory in windows OS 2. Exporting pre publish functions to add unit test cases 3. Using Sinon Stubs to mimic different functions returns instead of depending on original functions implementation which results to reading, copying, writing .org.js files of src/components. With stubs mimic the behaviour to match the scope of test case Signed-off-by: Suresh Kumar Gangumalla --- scripts/prepublishOnly.js | 7 +- scripts/prepublishOnly.test.js | 427 +++++++++++++++++++++++++++++++++ scripts/runTests.js | 4 +- 3 files changed, 435 insertions(+), 3 deletions(-) create mode 100644 scripts/prepublishOnly.test.js diff --git a/scripts/prepublishOnly.js b/scripts/prepublishOnly.js index f3d45c1e..0f89b5ea 100644 --- a/scripts/prepublishOnly.js +++ b/scripts/prepublishOnly.js @@ -84,4 +84,9 @@ function formatFileWithESLint(filePath) { }) } -precompileComponents() +// Only run if this file is executed directly (not imported) +if (import.meta.url === `file:///${process.argv[1].replace(/\\/g, '/')}`) { + precompileComponents() +} + +export { precompileComponents, processDirectory, processFile, formatFileWithESLint } diff --git a/scripts/prepublishOnly.test.js b/scripts/prepublishOnly.test.js new file mode 100644 index 00000000..823fa8a0 --- /dev/null +++ b/scripts/prepublishOnly.test.js @@ -0,0 +1,427 @@ +/* + * Copyright 2023 Comcast Cable Communications Management, LLC + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import test from 'tape' +import sinon from 'sinon' +import path from 'path' +import fs from 'fs' + +// Import functions after setting up module structure +let precompileComponents, processDirectory, processFile, formatFileWithESLint + +// Setup before running tests +test('Setup', async (assert) => { + // Now import prepublishOnly functions + const prepublishModule = await import('./prepublishOnly.js') + precompileComponents = prepublishModule.precompileComponents + processDirectory = prepublishModule.processDirectory + processFile = prepublishModule.processFile + formatFileWithESLint = prepublishModule.formatFileWithESLint + + assert.ok(precompileComponents, 'precompileComponents function loaded') + assert.ok(processDirectory, 'processDirectory function loaded') + assert.ok(processFile, 'processFile function loaded') + assert.ok(formatFileWithESLint, 'formatFileWithESLint function loaded') + assert.end() +}) + +test('precompileComponents - should log start and end messages', (assert) => { + const readdirStub = sinon.stub(fs, 'readdirSync').returns([]) + const consoleLogStub = sinon.stub(console, 'log') + + precompileComponents() + + assert.ok( + consoleLogStub.firstCall && consoleLogStub.firstCall.args[0].includes('Checking files'), + 'Should log checking message' + ) + assert.ok( + consoleLogStub.calledWith('Finished processing files suitable for precompilation'), + 'Should log completion message' + ) + + readdirStub.restore() + consoleLogStub.restore() + assert.end() +}) + +test('processDirectory - should process JS files in directory', (assert) => { + const testDir = path.resolve(process.cwd(), 'test-components') + const readdirStub = sinon.stub(fs, 'readdirSync').returns(['Component.js']) + const statStub = sinon.stub(fs, 'statSync').returns({ isDirectory: () => false }) + const readStub = sinon.stub(fs, 'readFileSync').returns('const test = "code"') + const writeStub = sinon.stub(fs, 'writeFileSync') + const copyStub = sinon.stub(fs, 'copyFileSync') + const consoleLogStub = sinon.stub(console, 'log') + + processDirectory(testDir) + + assert.ok(readdirStub.calledWith(testDir), 'Should read directory') + assert.ok(statStub.called, 'Should check file stats') + assert.ok(readStub.called, 'Should read file') + assert.ok(writeStub.called, 'Should write compiled file') + assert.ok(copyStub.called, 'Should create backup') + + readdirStub.restore() + statStub.restore() + readStub.restore() + writeStub.restore() + copyStub.restore() + consoleLogStub.restore() + assert.end() +}) + +test('processDirectory - should process TS files in directory', (assert) => { + const testDir = path.resolve(process.cwd(), 'test-components') + const readdirStub = sinon.stub(fs, 'readdirSync').returns(['Component.ts']) + const statStub = sinon.stub(fs, 'statSync').returns({ isDirectory: () => false }) + const readStub = sinon.stub(fs, 'readFileSync').returns('const test: string = "code"') + const writeStub = sinon.stub(fs, 'writeFileSync') + const copyStub = sinon.stub(fs, 'copyFileSync') + const consoleLogStub = sinon.stub(console, 'log') + + processDirectory(testDir) + + assert.ok(readStub.called, 'Should read TS file') + + readdirStub.restore() + statStub.restore() + readStub.restore() + writeStub.restore() + copyStub.restore() + consoleLogStub.restore() + assert.end() +}) + +test('processDirectory - should skip .orig.js files', (assert) => { + const testDir = path.resolve(process.cwd(), 'test-components') + const readdirStub = sinon.stub(fs, 'readdirSync').returns(['Component.orig.js', 'Helper.js']) + const statStub = sinon.stub(fs, 'statSync').returns({ isDirectory: () => false }) + const readStub = sinon.stub(fs, 'readFileSync').returns('const test = "code"') + const writeStub = sinon.stub(fs, 'writeFileSync') + const copyStub = sinon.stub(fs, 'copyFileSync') + const consoleLogStub = sinon.stub(console, 'log') + + processDirectory(testDir) + + // Should only process Helper.js + assert.equal(readStub.callCount, 1, 'Should only read one file') + assert.ok(consoleLogStub.calledWith(sinon.match(/Helper\.js/)), 'Should process Helper.js') + assert.notOk( + consoleLogStub.calledWith(sinon.match(/Component\.orig\.js/)), + 'Should not process .orig.js' + ) + + readdirStub.restore() + statStub.restore() + readStub.restore() + writeStub.restore() + copyStub.restore() + consoleLogStub.restore() + assert.end() +}) + +test('processDirectory - should skip .orig.ts files', (assert) => { + const testDir = path.resolve(process.cwd(), 'test-components') + const readdirStub = sinon.stub(fs, 'readdirSync').returns(['Component.orig.ts', 'Helper.ts']) + const statStub = sinon.stub(fs, 'statSync').returns({ isDirectory: () => false }) + const readStub = sinon.stub(fs, 'readFileSync').returns('const test = "code"') + const writeStub = sinon.stub(fs, 'writeFileSync') + const copyStub = sinon.stub(fs, 'copyFileSync') + const consoleLogStub = sinon.stub(console, 'log') + + processDirectory(testDir) + + assert.equal(readStub.callCount, 1, 'Should only read one file') + assert.notOk( + consoleLogStub.calledWith(sinon.match(/Component\.orig\.ts/)), + 'Should not process .orig.ts' + ) + + readdirStub.restore() + statStub.restore() + readStub.restore() + writeStub.restore() + copyStub.restore() + consoleLogStub.restore() + assert.end() +}) + +test('processDirectory - should skip non-JS/TS files', (assert) => { + const testDir = path.resolve(process.cwd(), 'test-components') + const readdirStub = sinon + .stub(fs, 'readdirSync') + .returns(['README.md', 'config.json', 'Component.js']) + const statStub = sinon.stub(fs, 'statSync').returns({ isDirectory: () => false }) + const readStub = sinon.stub(fs, 'readFileSync').returns('const test = "code"') + const writeStub = sinon.stub(fs, 'writeFileSync') + const copyStub = sinon.stub(fs, 'copyFileSync') + const consoleLogStub = sinon.stub(console, 'log') + + processDirectory(testDir) + + assert.equal(readStub.callCount, 1, 'Should only process JS file') + assert.ok(consoleLogStub.calledWith(sinon.match(/Component\.js/)), 'Should process Component.js') + + readdirStub.restore() + statStub.restore() + readStub.restore() + writeStub.restore() + copyStub.restore() + consoleLogStub.restore() + assert.end() +}) + +test('processDirectory - should recursively process subdirectories', (assert) => { + const testDir = path.resolve(process.cwd(), 'test-components') + const readdirStub = sinon.stub(fs, 'readdirSync') + const statStub = sinon.stub(fs, 'statSync') + const readStub = sinon.stub(fs, 'readFileSync').returns('const test = "code"') + const writeStub = sinon.stub(fs, 'writeFileSync') + const copyStub = sinon.stub(fs, 'copyFileSync') + const consoleLogStub = sinon.stub(console, 'log') + + readdirStub.onFirstCall().returns(['subdir', 'Component.js']) + statStub.onCall(0).returns({ isDirectory: () => true }) + statStub.onCall(1).returns({ isDirectory: () => false }) + + readdirStub.onSecondCall().returns(['SubComponent.js']) + statStub.onCall(2).returns({ isDirectory: () => false }) + + processDirectory(testDir) + + assert.equal(readdirStub.callCount, 2, 'Should read 2 directories') + assert.equal(readStub.callCount, 2, 'Should process 2 files') + + readdirStub.restore() + statStub.restore() + readStub.restore() + writeStub.restore() + copyStub.restore() + consoleLogStub.restore() + assert.end() +}) + +test('processDirectory - should handle empty directory', (assert) => { + const testDir = path.resolve(process.cwd(), 'test-components') + const readdirStub = sinon.stub(fs, 'readdirSync').returns([]) + const statStub = sinon.stub(fs, 'statSync') + + processDirectory(testDir) + + assert.ok(readdirStub.calledOnce, 'Should read directory once') + assert.notOk(statStub.called, 'Should not check any stats') + + readdirStub.restore() + statStub.restore() + assert.end() +}) + +test('processFile - should create backup with .orig.js extension', (assert) => { + const filePath = path.resolve(process.cwd(), 'Component.js') + const expectedBackup = path.resolve(process.cwd(), 'Component.orig.js') + const readStub = sinon.stub(fs, 'readFileSync').returns('const test = "code"') + const writeStub = sinon.stub(fs, 'writeFileSync') + const copyStub = sinon.stub(fs, 'copyFileSync') + const consoleLogStub = sinon.stub(console, 'log') + + processFile(filePath) + + assert.ok(copyStub.calledWith(filePath, expectedBackup), 'Should create .orig.js backup') + + readStub.restore() + writeStub.restore() + copyStub.restore() + consoleLogStub.restore() + assert.end() +}) + +test('processFile - should create backup with .orig.ts extension', (assert) => { + const filePath = path.resolve(process.cwd(), 'Component.ts') + const expectedBackup = path.resolve(process.cwd(), 'Component.orig.ts') + const readStub = sinon.stub(fs, 'readFileSync').returns('const test = "code"') + const writeStub = sinon.stub(fs, 'writeFileSync') + const copyStub = sinon.stub(fs, 'copyFileSync') + const consoleLogStub = sinon.stub(console, 'log') + + processFile(filePath) + + assert.ok(copyStub.calledWith(filePath, expectedBackup), 'Should create .orig.ts backup') + + readStub.restore() + writeStub.restore() + copyStub.restore() + consoleLogStub.restore() + assert.end() +}) + +test('processFile - should read and compile file', (assert) => { + const filePath = path.resolve(process.cwd(), 'Component.js') + const originalCode = 'const original = "code"' + const readStub = sinon.stub(fs, 'readFileSync').returns(originalCode) + const writeStub = sinon.stub(fs, 'writeFileSync') + const copyStub = sinon.stub(fs, 'copyFileSync') + const consoleLogStub = sinon.stub(console, 'log') + + processFile(filePath) + + assert.ok(readStub.calledWith(filePath, 'utf-8'), 'Should read file with utf-8') + + readStub.restore() + writeStub.restore() + copyStub.restore() + consoleLogStub.restore() + assert.end() +}) + +test('processFile - should write compiled result', (assert) => { + const filePath = path.resolve(process.cwd(), 'Component.js') + const readStub = sinon.stub(fs, 'readFileSync').returns('const original = "code"') + const writeStub = sinon.stub(fs, 'writeFileSync') + const copyStub = sinon.stub(fs, 'copyFileSync') + const consoleLogStub = sinon.stub(console, 'log') + + processFile(filePath) + + assert.ok(writeStub.calledWith(filePath, sinon.match.string), 'Should write compiled code') + + readStub.restore() + writeStub.restore() + copyStub.restore() + consoleLogStub.restore() + assert.end() +}) + +test('processFile - should handle compiler returning object with code property', (assert) => { + const filePath = path.resolve(process.cwd(), 'Component.js') + const readStub = sinon.stub(fs, 'readFileSync').returns('const original = "code"') + const writeStub = sinon.stub(fs, 'writeFileSync') + const copyStub = sinon.stub(fs, 'copyFileSync') + const consoleLogStub = sinon.stub(console, 'log') + + processFile(filePath) + + assert.ok(writeStub.calledWith(filePath, sinon.match.string), 'Should extract code from object') + + readStub.restore() + writeStub.restore() + copyStub.restore() + consoleLogStub.restore() + assert.end() +}) + +test('processFile - should format file when source changes', (assert) => { + const filePath = path.resolve(process.cwd(), 'Component.js') + const readStub = sinon.stub(fs, 'readFileSync').returns('const original = "code"') + const writeStub = sinon.stub(fs, 'writeFileSync') + const copyStub = sinon.stub(fs, 'copyFileSync') + const consoleLogStub = sinon.stub(console, 'log') + + processFile(filePath) + + assert.ok(writeStub.called, 'Should write compiled file') + assert.ok(copyStub.called, 'Should create backup') + + readStub.restore() + writeStub.restore() + copyStub.restore() + consoleLogStub.restore() + assert.end() +}) + +test('processFile - should NOT format file when source unchanged', (assert) => { + const filePath = path.resolve(process.cwd(), 'Component.js') + const sameCode = 'const same = "code"' + const readStub = sinon.stub(fs, 'readFileSync').returns(sameCode) + const writeStub = sinon.stub(fs, 'writeFileSync') + const copyStub = sinon.stub(fs, 'copyFileSync') + const consoleLogStub = sinon.stub(console, 'log') + + processFile(filePath) + + assert.ok(copyStub.called, 'Should still create backup') + + readStub.restore() + writeStub.restore() + copyStub.restore() + consoleLogStub.restore() + assert.end() +}) + +test('processFile - should log precompiling message', (assert) => { + const filePath = path.resolve(process.cwd(), 'Component.js') + const readStub = sinon.stub(fs, 'readFileSync').returns('const test = "code"') + const writeStub = sinon.stub(fs, 'writeFileSync') + const copyStub = sinon.stub(fs, 'copyFileSync') + const consoleLogStub = sinon.stub(console, 'log') + + processFile(filePath) + + assert.ok( + consoleLogStub.calledWith(`Precompiling ${filePath}`), + 'Should log precompiling message' + ) + + readStub.restore() + writeStub.restore() + copyStub.restore() + consoleLogStub.restore() + assert.end() +}) + +test('formatFileWithESLint - should accept file path', (assert) => { + const filePath = path.resolve(process.cwd(), 'Component.js') + + // Just verify the function can be called without errors + assert.doesNotThrow(() => { + formatFileWithESLint(filePath) + }, 'Should accept file path without throwing') + + assert.end() +}) + +test('formatFileWithESLint - should handle file path parameter', (assert) => { + const filePath = path.resolve(process.cwd(), 'Component.js') + + // Verify function accepts path parameter + assert.doesNotThrow(() => { + formatFileWithESLint(filePath) + }, 'Should handle file path parameter') + + assert.end() +}) + +test('formatFileWithESLint - should be callable', (assert) => { + const filePath = path.resolve(process.cwd(), 'Component.js') + + // Verify function is callable + assert.equal(typeof formatFileWithESLint, 'function', 'Should be a function') + formatFileWithESLint(filePath) + + assert.end() +}) + +test('formatFileWithESLint - should execute without errors', (assert) => { + const filePath = path.resolve(process.cwd(), 'Component.js') + + // Verify function executes without throwing + assert.doesNotThrow(() => { + formatFileWithESLint(filePath) + }, 'Should execute without errors') + + assert.end() +}) diff --git a/scripts/runTests.js b/scripts/runTests.js index bda83bb4..f3c8a694 100644 --- a/scripts/runTests.js +++ b/scripts/runTests.js @@ -1,4 +1,4 @@ -/* +/* * Copyright 2023 Comcast Cable Communications Management, LLC * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -25,7 +25,7 @@ const __dirname = resolve(__filename, '../..') try { // Find all *.test.js files excluding node_modules and packages - const testFiles = await fg(['**/*.test.js', '!node_modules/**', '!packages/**'], { + const testFiles = await fg(['**/*.test.js', '!**/node_modules/**', '!**/packages/**'], { cwd: __dirname, }) From 6e128c384e272c2831f0cb4b0c8c59181aada3f9 Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Fri, 2 Jan 2026 14:43:38 +0000 Subject: [PATCH 38/66] fix: update clear function to accept cancelPrevious option for better speech control --- src/announcer/announcer.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/announcer/announcer.js b/src/announcer/announcer.js index 3b41078f..f230aba8 100644 --- a/src/announcer/announcer.js +++ b/src/announcer/announcer.js @@ -55,7 +55,7 @@ const speak = (message, politeness = 'off', options = {}) => { // if cancelPrevious option is set, clear the queue and stop current speech if (options.cancelPrevious === true) { - clear() + clear(true) } return addToQueue(message, politeness, false, options) @@ -200,12 +200,14 @@ const stop = () => { } } -const clear = () => { +const clear = (cancelPrevious = false) => { // Clear debounce timer clearDebounceTimer() // Cancel any active speech synthesis - speechSynthesis.cancel() + if (cancelPrevious === true) { + speechSynthesis.cancel() + } // Resolve all pending items in queue while (queue.length > 0) { From 0be466ca1f7848292c391be923bf1fc086849b4e Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Fri, 2 Jan 2026 14:46:51 +0000 Subject: [PATCH 39/66] fix: replace optional chaining with explicit null checks for timer in clear and startKeepAlive functions --- src/announcer/speechSynthesis.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/announcer/speechSynthesis.js b/src/announcer/speechSynthesis.js index e464675a..c8b7a2ac 100644 --- a/src/announcer/speechSynthesis.js +++ b/src/announcer/speechSynthesis.js @@ -28,7 +28,7 @@ const clear = (id) => { if (!state) { return } - if (state?.timer !== null) { + if (state && state.timer !== null) { clearTimeout(state.timer) state.timer = null } @@ -52,7 +52,7 @@ const startKeepAlive = (id) => { } // Clear existing timer for this specific utterance - if (state?.timer !== null) { + if (state && state.timer !== null) { clearTimeout(state.timer) state.timer = null } From eea2d60ba0477d56655aa85ce16c58762331699a Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Fri, 2 Jan 2026 14:51:14 +0000 Subject: [PATCH 40/66] fix: remove invalid utterance check in startKeepAlive function --- src/announcer/speechSynthesis.js | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/announcer/speechSynthesis.js b/src/announcer/speechSynthesis.js index c8b7a2ac..9529ff94 100644 --- a/src/announcer/speechSynthesis.js +++ b/src/announcer/speechSynthesis.js @@ -43,14 +43,6 @@ const startKeepAlive = (id) => { return } - const { utterance } = state - - // utterance check: utterance instance is invalid - if (!(utterance instanceof SpeechSynthesisUtterance)) { - clear(id) - return - } - // Clear existing timer for this specific utterance if (state && state.timer !== null) { clearTimeout(state.timer) From ab06eaddb9d29f84186bc27bb97842cbef5363d0 Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Fri, 2 Jan 2026 14:54:45 +0000 Subject: [PATCH 41/66] fix: ensure initialized is set to false when speech synthesis API is unavailable --- src/announcer/speechSynthesis.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/announcer/speechSynthesis.js b/src/announcer/speechSynthesis.js index 9529ff94..511ee3a3 100644 --- a/src/announcer/speechSynthesis.js +++ b/src/announcer/speechSynthesis.js @@ -87,7 +87,7 @@ const defaultUtteranceProps = { const initialize = () => { // syn api check: syn might not have getVoices method if (!syn || typeof syn.getVoices !== 'function') { - initialized = true + initialized = false return } From 3cc5d28efcf06d962df4ebb101b987138ffee5b8 Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Fri, 2 Jan 2026 14:56:36 +0000 Subject: [PATCH 42/66] fix: change log level from warn to debug for speech synthesis readiness --- src/announcer/speechSynthesis.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/announcer/speechSynthesis.js b/src/announcer/speechSynthesis.js index 511ee3a3..36f726ec 100644 --- a/src/announcer/speechSynthesis.js +++ b/src/announcer/speechSynthesis.js @@ -119,7 +119,7 @@ const waitForSynthReady = (timeoutMs = 2000, checkIntervalMs = 100) => { const isReady = !syn.speaking && !syn.pending if (isReady) { - Log.warn(`SpeechSynthesis - ready after ${elapsed}ms`) + Log.debug(`SpeechSynthesis - ready after ${elapsed}ms`) window.clearInterval(intervalId) resolve() } else if (elapsed >= timeoutMs) { From 76898541da7d8a6a8a71f93cf71e3fdcda272db9 Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Mon, 5 Jan 2026 13:18:51 +0000 Subject: [PATCH 43/66] feat(router): fix RouterHooks init type to ensure proper Promise handling --- index.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.d.ts b/index.d.ts index fe88227d..5b269007 100644 --- a/index.d.ts +++ b/index.d.ts @@ -624,7 +624,7 @@ declare module '@lightningjs/blits' { } export interface RouterHooks { - init?: () => Promise<> | void; + init?: () => Promise | void; beforeEach?: (to: Route, from: Route) => string | Route | Promise | void; afterEach?: (to: Route, toComponent: ComponentBase, from: Route | undefined, fromComponent: ComponentBase | null) => string | Route | Promise | void; error?: (err: string) => string | Route | Promise | void; From 71f68e51dd2b1df8dd7caa3c4639f05003d70950 Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Mon, 5 Jan 2026 13:22:44 +0000 Subject: [PATCH 44/66] fix(router): correct index reference in navigate function for transition handling --- src/router/router.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/router/router.js b/src/router/router.js index 7c82222f..3af3a9e1 100644 --- a/src/router/router.js +++ b/src/router/router.js @@ -495,7 +495,7 @@ export const navigate = async function () { if (route.transition.in) { if (Array.isArray(route.transition.in)) { for (let i = 0; i < route.transition.in.length; i++) { - i === route.transition.length - 1 + i === route.transition.in.length - 1 ? await setOrAnimate(holder, route.transition.in[i], shouldAnimate) : setOrAnimate(holder, route.transition.in[i], shouldAnimate) } From d5e41be04068f8d51a184a2438f1572a37bc47f0 Mon Sep 17 00:00:00 2001 From: Suresh Kumar Gangumalla Date: Tue, 6 Jan 2026 12:17:34 +0530 Subject: [PATCH 45/66] updated fileURL path check Signed-off-by: Suresh Kumar Gangumalla --- scripts/prepublishOnly.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/prepublishOnly.js b/scripts/prepublishOnly.js index 0f89b5ea..e0806195 100644 --- a/scripts/prepublishOnly.js +++ b/scripts/prepublishOnly.js @@ -17,6 +17,7 @@ import fs from 'fs' import path from 'path' +import { fileURLToPath } from 'url' import compiler from '../src/lib/precompiler/precompiler.js' import { exec } from 'child_process' @@ -85,7 +86,7 @@ function formatFileWithESLint(filePath) { } // Only run if this file is executed directly (not imported) -if (import.meta.url === `file:///${process.argv[1].replace(/\\/g, '/')}`) { +if (fileURLToPath(import.meta.url) === process.argv[1]) { precompileComponents() } From 915d5e05d63d22cc4da205b3a20c684dce316ca1 Mon Sep 17 00:00:00 2001 From: sairamg Date: Tue, 6 Jan 2026 12:46:28 +0530 Subject: [PATCH 46/66] fix(types): remove plugin interfaces from index.d.ts and co-locate with implementations --- index.d.ts | 22 ---------------------- src/plugins/appstate.d.ts | 5 +---- src/plugins/index.d.ts | 9 ++++----- src/plugins/language.d.ts | 11 +++++++---- src/plugins/storage/storage.d.ts | 10 ++++++---- src/plugins/theme.d.ts | 9 +++++---- 6 files changed, 23 insertions(+), 43 deletions(-) diff --git a/index.d.ts b/index.d.ts index e56935ff..258c2115 100644 --- a/index.d.ts +++ b/index.d.ts @@ -371,28 +371,6 @@ declare module '@lightningjs/blits' { // Empty by design: extend in your app via TypeScript module augmentation. } - export interface LanguagePlugin { - translate(key: string, ...replacements: any[]): string - readonly language: string - set(language: string): void - translations(translationsObject: Record): void - load(file: string): Promise - } - - export interface ThemePlugin { - get(key: string): T | undefined - get(key: string, fallback: T): T - set(theme: string): void - } - - export interface StoragePlugin { - get(key: string): T | null - set(key: string, value: unknown): boolean - remove(key: string): void - clear(): void - } - - export type AppStatePlugin = Record> = TState export interface ComponentBase extends CustomComponentProperties { /** diff --git a/src/plugins/appstate.d.ts b/src/plugins/appstate.d.ts index 9145c943..28aa1f09 100644 --- a/src/plugins/appstate.d.ts +++ b/src/plugins/appstate.d.ts @@ -15,10 +15,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { AppStatePlugin } from '@lightningjs/blits' - -// Re-export AppStatePlugin for direct imports -export type { AppStatePlugin } +export type AppStatePlugin = Record> = TState declare const appState: { readonly name: 'appState' diff --git a/src/plugins/index.d.ts b/src/plugins/index.d.ts index c49975e8..a053bcdd 100644 --- a/src/plugins/index.d.ts +++ b/src/plugins/index.d.ts @@ -21,9 +21,8 @@ export { default as appState } from './appstate.js' export { default as storage } from './storage/storage.js' // Re-export plugin interfaces for convenience -export type { LanguagePlugin, ThemePlugin, StoragePlugin, AppStatePlugin } from '@lightningjs/blits' - -// Re-export option types -export type { LanguagePluginOptions } from './language.js' -export type { ThemePluginConfig } from './theme.js' +export type { LanguagePlugin, LanguagePluginOptions } from './language.js' +export type { ThemePlugin, ThemePluginConfig } from './theme.js' +export type { StoragePlugin } from './storage/storage.js' +export type { AppStatePlugin } from './appstate.js' diff --git a/src/plugins/language.d.ts b/src/plugins/language.d.ts index 70ba2fa3..bcff044b 100644 --- a/src/plugins/language.d.ts +++ b/src/plugins/language.d.ts @@ -15,16 +15,19 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { LanguagePlugin } from '@lightningjs/blits' +export interface LanguagePlugin { + translate(key: string, ...replacements: any[]): string + readonly language: string + set(language: string): void + translations(translationsObject: Record): void + load(file: string): Promise +} export interface LanguagePluginOptions { file?: string language?: string } -// Re-export LanguagePlugin for direct imports -export type { LanguagePlugin } - declare const language: { readonly name: 'language' plugin: (options?: LanguagePluginOptions) => LanguagePlugin diff --git a/src/plugins/storage/storage.d.ts b/src/plugins/storage/storage.d.ts index b29d20e0..d62e8953 100644 --- a/src/plugins/storage/storage.d.ts +++ b/src/plugins/storage/storage.d.ts @@ -15,10 +15,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { StoragePlugin } from '@lightningjs/blits' - -// Re-export StoragePlugin for direct imports -export type { StoragePlugin } +export interface StoragePlugin { + get(key: string): T | null + set(key: string, value: unknown): boolean + remove(key: string): void + clear(): void +} declare const storage: { readonly name: 'storage' diff --git a/src/plugins/theme.d.ts b/src/plugins/theme.d.ts index 6e5a850f..9ae1bc1f 100644 --- a/src/plugins/theme.d.ts +++ b/src/plugins/theme.d.ts @@ -15,7 +15,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { ThemePlugin } from '@lightningjs/blits' +export interface ThemePlugin { + get(key: string): T | undefined + get(key: string, fallback: T): T + set(theme: string): void +} export interface ThemePluginConfig { themes?: Record> @@ -23,9 +27,6 @@ export interface ThemePluginConfig { base?: string } -// Re-export ThemePlugin for direct imports -export type { ThemePlugin } - declare const theme: { readonly name: 'theme' plugin: (config?: ThemePluginConfig | Record) => ThemePlugin From e6e09af70304d00b4530756d0ffc7249fdff4fe0 Mon Sep 17 00:00:00 2001 From: sairamg Date: Tue, 6 Jan 2026 15:01:10 +0530 Subject: [PATCH 47/66] rename navigateHistory to backNavigation and use getter/setter --- docs/router/basics.md | 2 +- index.d.ts | 11 ++++++++--- src/component/base/router.js | 10 +++++----- src/components/RouterView.js | 15 ++++++++------- src/router/router.js | 12 +----------- 5 files changed, 23 insertions(+), 27 deletions(-) diff --git a/docs/router/basics.md b/docs/router/basics.md index 80e68125..d94197f7 100644 --- a/docs/router/basics.md +++ b/docs/router/basics.md @@ -107,7 +107,7 @@ export default Blits.Component('Poster', { Whenever you navigate to a new page, the URL hash will automatically be updated. Unless specified otherwise, navigating to a new page, will add that route to the history stack. The `back` input action is automatically wired up to navigate back down the history stack. -If you want to disable this automatic history navigation on Back (for example, to let a top-level navigation component handle Back), call `this.$router.navigateHistory(false)`. Call `this.$router.navigateHistory(true)` to restore the default behavior. +If you want to disable this automatic history navigation on Back (for example, to let a top-level navigation component handle Back), set `this.$router.backNavigation = false`. Set `this.$router.backNavigation = true` to restore the default behavior. By default, every time you navigate to a new route, the application focus will be automatically passed to the newly loaded page. If you instead want to maintain the current focus (for example in a widget that sits above your RouterView), you can use `passFocus: false` as part of the router options. diff --git a/index.d.ts b/index.d.ts index b4a95786..970be007 100644 --- a/index.d.ts +++ b/index.d.ts @@ -321,6 +321,11 @@ declare module '@lightningjs/blits' { */ back(): boolean; + /** + * Enable or disable RouterView history navigation on Back input + */ + backNavigation: boolean; + /** * Get the current route read-only */ @@ -328,17 +333,17 @@ declare module '@lightningjs/blits' { /** * Get the list of all routes - */ + */ readonly routes: Route[]; /** * Get navigating state - */ + */ readonly navigating: boolean; /** * Reactive router state - */ + */ state: { /** * Path of the current route diff --git a/src/component/base/router.js b/src/component/base/router.js index bd586250..3d8d59df 100644 --- a/src/component/base/router.js +++ b/src/component/base/router.js @@ -16,18 +16,18 @@ */ import symbols from '../../lib/symbols.js' -import { to, currentRoute, back, state, setNavigateHistory } from '../../router/router.js' +import { to, currentRoute, back, state } from '../../router/router.js' export default { $router: { value: { to, back, - navigateHistory(enabled = true) { - setNavigateHistory(enabled) + get backNavigation() { + return state.backNavigation !== false }, - get navigateHistoryEnabled() { - return state.navigateHistory !== false + set backNavigation(enabled) { + state.backNavigation = enabled !== false }, get currentRoute() { return currentRoute diff --git a/src/components/RouterView.js b/src/components/RouterView.js index e11259d2..1c6cb033 100644 --- a/src/components/RouterView.js +++ b/src/components/RouterView.js @@ -56,13 +56,14 @@ export default () => }, input: { back(e) { - if (routerState.navigateHistory !== false && Router.back.call(this)) return - - // If history navigation is disabled or fails, forward Back to parent - /** @type {RouterViewParent | undefined | null} */ - const parent = this.parent - const handledByParent = parent?.$input?.(e) ?? false - if (handledByParent === false) parent?.$focus?.(e) + if (routerState.backNavigation === false) { + this.parent?.$input?.(e) + return + } + const navigating = Router.back.call(this) + if (navigating === false) { + this.parent?.$focus?.(e) + } }, }, }) diff --git a/src/router/router.js b/src/router/router.js index 1525203b..45f17a8a 100644 --- a/src/router/router.js +++ b/src/router/router.js @@ -60,7 +60,7 @@ export const state = reactive( data: null, params: null, hash: '', - navigateHistory: true, + backNavigation: true, }, Settings.get('reactivityMode'), true @@ -642,18 +642,8 @@ export const back = function () { return false } -/** - * Enable or disable RouterView history navigation on Back input. - * When disabled, RouterView will not call Router.back(). - * @param {boolean} enabled - */ -export const setNavigateHistory = (enabled = true) => { - state.navigateHistory = enabled !== false -} - export default { navigate, to, back, - setNavigateHistory, } From 1a36fa0309f3d338fb7ca50064af48ddc34734f6 Mon Sep 17 00:00:00 2001 From: sairamg Date: Tue, 6 Jan 2026 17:45:13 +0530 Subject: [PATCH 48/66] Updated document --- docs/router/hooks.md | 23 +++++++++++++++++++++++ index.d.ts | 19 +++++++++++++++++++ src/component/setup/routes.js | 5 +++++ src/components/RouterView.js | 4 ++-- 4 files changed, 49 insertions(+), 2 deletions(-) diff --git a/docs/router/hooks.md b/docs/router/hooks.md index 6f2bcac7..c4e297c0 100644 --- a/docs/router/hooks.md +++ b/docs/router/hooks.md @@ -77,6 +77,29 @@ In order to configure global router hooks the `routes` key in the Application co Alongside the `router.routes` key we can now define a `router.hooks` object, which can have any of the following pre-defined hook functions: +### Router Settings + +The router configuration also supports router settings alongside `router.routes` and `router.hooks`: + +- `backNavigation` - Enable or disable RouterView history navigation on Back input (default: `true`). When set to `false`, the Back input will be passed to the parent component instead of navigating back through the history stack. This setting can be configured in the router config or changed at runtime via `this.$router.backNavigation`. + +> **Note:** `backNavigation` is an app-wide setting that affects all `RouterView` instances in your application, as the router state is global and shared. + +```js +export default Blits.Application({ + router: { + backNavigation: false, + routes: [ + { path: '/', component: Home }, + { path: '/details', component: Details }, + ], + hooks: { + // router hooks can be defined here + } + } +}) +``` + ### `beforeEach()` Similar to the `before`-hook, the `beforeEach`-hook will be execute for every router navigation. This can be useful if you find yourself repeating the same functionality for many routes, for example an _authentication check_ or sending _telemetry_. diff --git a/index.d.ts b/index.d.ts index 970be007..116334d3 100644 --- a/index.d.ts +++ b/index.d.ts @@ -663,6 +663,25 @@ declare module '@lightningjs/blits' { * ``` */ routes?: Route[] + + /** + * Enable or disable RouterView history navigation on Back input + * + * @default true + * + * @remarks + * This is an app-wide setting that affects all RouterView instances in your application. + * The router state is global and shared across all router instances. + * + * @example + * ```js + * router: { + * backNavigation: false, // Disable automatic back navigation + * routes: [...] + * } + * ``` + */ + backNavigation?: boolean } export type ApplicationConfig

= ComponentConfig & ( diff --git a/src/component/setup/routes.js b/src/component/setup/routes.js index e0f3d450..30a6ae3a 100644 --- a/src/component/setup/routes.js +++ b/src/component/setup/routes.js @@ -16,12 +16,17 @@ */ import symbols from '../../lib/symbols.js' +import { state as routerState } from '../../router/router.js' export default (component, data) => { let routes = data if (Array.isArray(data) === false) { component[symbols.routerHooks] = data.hooks routes = data.routes + // Set initial backNavigation value if provided in router config + if (data.backNavigation !== undefined) { + routerState.backNavigation = data.backNavigation !== false + } } component[symbols.routes] = [] diff --git a/src/components/RouterView.js b/src/components/RouterView.js index 1c6cb033..eb06c58b 100644 --- a/src/components/RouterView.js +++ b/src/components/RouterView.js @@ -57,12 +57,12 @@ export default () => input: { back(e) { if (routerState.backNavigation === false) { - this.parent?.$input?.(e) + this.parent.$input(e) return } const navigating = Router.back.call(this) if (navigating === false) { - this.parent?.$focus?.(e) + this.parent.$focus(e) } }, }, From 560067e3afc84dd17e0228f55856ce4c3c14d43d Mon Sep 17 00:00:00 2001 From: Tuncay YILDIRTAN Date: Wed, 7 Jan 2026 11:31:32 +0000 Subject: [PATCH 49/66] fix: refactor enableUtteranceKeepAlive option to use a constant for better readability --- src/announcer/announcer.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/announcer/announcer.js b/src/announcer/announcer.js index f230aba8..461d5053 100644 --- a/src/announcer/announcer.js +++ b/src/announcer/announcer.js @@ -25,9 +25,12 @@ let isProcessing = false let currentId = null let debounce = null +const isAndroid = /android/i.test((window.navigator || {}).userAgent || '') +const defaultUtteranceKeepAlive = !isAndroid + // Global default utterance options let globalDefaultOptions = { - enableUtteranceKeepAlive: /android/i.test((window.navigator || {}).userAgent || ''), + enableUtteranceKeepAlive: defaultUtteranceKeepAlive, } const noopAnnouncement = { From f2d7329fb73d80b1f8b5a7c8a2ff995cffba0279 Mon Sep 17 00:00:00 2001 From: sairamg Date: Wed, 7 Jan 2026 17:13:29 +0530 Subject: [PATCH 50/66] feat: add method for component-scoped debouncing --- docs/components/utility_methods.md | 70 ++++++++++++++++++++++++ index.d.ts | 20 +++++++ src/component.js | 3 + src/component/base/methods.js | 2 + src/component/base/methods.test.js | 1 + src/component/base/timeouts_intervals.js | 55 +++++++++++++++++++ src/lib/symbols.js | 2 + 7 files changed, 153 insertions(+) diff --git a/docs/components/utility_methods.md b/docs/components/utility_methods.md index bc5e21ce..47894e53 100644 --- a/docs/components/utility_methods.md +++ b/docs/components/utility_methods.md @@ -428,3 +428,73 @@ export default Blits.Component('MyComponent', { } }) ``` + +## Debounce + +Debouncing is a technique to limit the rate at which a function executes. This is particularly useful when navigating through lists or handling rapid user input, where you want to execute a method only after the user has stopped the action for a specified delay. + +Similar to timeouts and intervals, debounced functions can cause memory leaks if not properly cleaned up. Blits provides built-in debounce methods that automatically handle cleanup when a component is destroyed. + +### $debounce + +The `this.$debounce()`-method creates a debounced function that delays execution until after a specified delay has passed since the last invocation. If the same key is debounced again before the delay completes, the previous debounce is cancelled and a new one is created. + +The first argument is a `key` (string) that uniquely identifies this debounce instance. The second argument is the `callback` function to execute. The third argument is the `delay` in milliseconds. Additional arguments can be passed and will be forwarded to the callback function. + +The method returns a `timeout id`, which can be used to manually clear the debounce. + +**Key Features:** +- **Key-based**: Each debounce is identified by a unique key +- **Automatic replacement**: Calling `$debounce` with the same key replaces the previous debounce +- **Memory efficient**: Only stores timeout IDs, function is captured in closure +- **Automatic cleanup**: All debounces are cleared when component is destroyed + +### $clearDebounce + +The `this.$clearDebounce()`-method clears a specific debounce by its key. This prevents the debounced function from executing. + +### $clearDebounces + +The `this.$clearDebounces()`-method clears all debounces created via `this.$debounce()` in one go. This method is automatically called when destroying a Component, preventing memory leaks due to dangling debounce timers. + +```js +export default Blits.Component('ListComponent', { + state() { + return { + items: [], + currentIndex: 0 + } + }, + input: { + down() { + // Debounce navigation - only load data after 300ms of no navigation + this.$debounce('navigate', () => { + this.loadCurrentItem() + }, 300) + }, + up() { + // Different debounce for different operation + this.$debounce('update', () => { + this.updateUI() + }, 200) + } + }, + methods: { + loadCurrentItem() { + // This will only execute after 300ms of no navigation + const item = this.items[this.currentIndex] + // ... load item data + }, + updateUI() { + // This will only execute after 200ms of no update calls + // ... update UI + } + }, + hooks: { + unfocus() { + // Optionally clear all debounces when component loses focus + this.$clearDebounces() + } + } +}) +``` \ No newline at end of file diff --git a/index.d.ts b/index.d.ts index 7cffcfdd..9eca1b04 100644 --- a/index.d.ts +++ b/index.d.ts @@ -427,6 +427,26 @@ declare module '@lightningjs/blits' { */ $clearTimeout: (id: ReturnType) => void + /** + * Debounce a function execution, preventing memory leaks and function re-allocation + * @param key - Unique identifier for this debounce instance + * @param callback - Function to debounce + * @param ms - Delay in milliseconds + * @param args - Arguments to pass to the callback + */ + $debounce: (key: string, callback: (...args: any[]) => void, ms?: number, ...args: any[]) => ReturnType + + /** + * Clear a specific debounce by key + * @param key - The key of the debounce to clear + */ + $clearDebounce: (key: string) => void + + /** + * Clear all debounces (automatically called on component destroy) + */ + $clearDebounces: () => void + /** * Set an interval that is automatically cleaned upon component destroy */ diff --git a/src/component.js b/src/component.js index b0a2178b..2910a024 100644 --- a/src/component.js +++ b/src/component.js @@ -228,6 +228,9 @@ const Component = (name = required('name'), config = required('config')) => { // create an empty array for storing intervals created by this component (via this.$setInterval) this[symbols.intervals] = [] + // create a Map for storing debounced functions (via this.$debounce) + this[symbols.debounces] = new Map() + // apply the state function (passing in the this reference to utilize configured props) // and store a reference to this original state this[symbols.originalState] = diff --git a/src/component/base/methods.js b/src/component/base/methods.js index 0f7dabaa..6e14853a 100644 --- a/src/component/base/methods.js +++ b/src/component/base/methods.js @@ -120,6 +120,8 @@ export default { this.$clearTimeouts() this.$clearIntervals() + this.$clearDebounces() + eventListeners.removeListeners(this) const rendererEventListenersLength = this[symbols.rendererEventListeners].length diff --git a/src/component/base/methods.test.js b/src/component/base/methods.test.js index 68cf7944..db4d7094 100644 --- a/src/component/base/methods.test.js +++ b/src/component/base/methods.test.js @@ -386,6 +386,7 @@ export const getTestComponent = () => { // not required by default but getting into error without this [symbols.timeouts]: [], [symbols.intervals]: [], + [symbols.debounces]: new Map(), }, { ...methods, ...timeouts_intervals } ) diff --git a/src/component/base/timeouts_intervals.js b/src/component/base/timeouts_intervals.js index b77e612e..fbb21536 100644 --- a/src/component/base/timeouts_intervals.js +++ b/src/component/base/timeouts_intervals.js @@ -96,4 +96,59 @@ export default { enumerable: true, configurable: false, }, + $debounce: { + value: function (key, fn, ms, ...params) { + // early exit when component is marked as end of life + if (this.eol === true) return + + // clear existing debounce for this key if it exists + const existing = this[symbols.debounces].get(key) + if (existing) { + this.$clearTimeout(existing) + this[symbols.debounces].delete(key) + } + + // create new timeout + const timeoutId = setTimeout(() => { + this[symbols.debounces].delete(key) + this.$clearTimeout(timeoutId) + fn.apply(this, params) + }, ms) + + // track timeout in timeouts array for automatic cleanup + this[symbols.timeouts].push(timeoutId) + + // store timeoutId per key to enable replace behavior and lifecycle cleanup + this[symbols.debounces].set(key, timeoutId) + + return timeoutId + }, + writable: false, + enumerable: true, + configurable: false, + }, + $clearDebounce: { + value: function (key) { + const existing = this[symbols.debounces].get(key) + if (existing) { + this.$clearTimeout(existing) + this[symbols.debounces].delete(key) + } + }, + writable: false, + enumerable: true, + configurable: false, + }, + $clearDebounces: { + value: function () { + // clear all timeouts associated with debounces + for (const timeoutId of this[symbols.debounces].values()) { + this.$clearTimeout(timeoutId) + } + this[symbols.debounces].clear() + }, + writable: false, + enumerable: true, + configurable: false, + }, } diff --git a/src/lib/symbols.js b/src/lib/symbols.js index 7c4481bd..8a03dd21 100644 --- a/src/lib/symbols.js +++ b/src/lib/symbols.js @@ -17,6 +17,7 @@ * @property {symbol} inputEvents * @property {symbol} internalEvent * @property {symbol} intervals + * @property {symbol} debounces * @property {symbol} isProxy * @property {symbol} launched * @property {symbol} level @@ -70,6 +71,7 @@ export default { inputEvents: Symbol('inputEvents'), internalEvent: Symbol('internalEvent'), intervals: Symbol('intervals'), + debounces: Symbol('debounces'), isProxy: Symbol('isProxy'), launched: Symbol('launched'), level: Symbol('level'), From 0c9143a09e6debf5da420d748e1097f0dc605d80 Mon Sep 17 00:00:00 2001 From: sairamg Date: Wed, 7 Jan 2026 19:59:44 +0530 Subject: [PATCH 51/66] Updated documentation --- docs/components/utility_methods.md | 14 +++++++------- index.d.ts | 12 ++++++------ src/component/base/timeouts_intervals.js | 9 +++++---- 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/docs/components/utility_methods.md b/docs/components/utility_methods.md index 47894e53..294856f6 100644 --- a/docs/components/utility_methods.md +++ b/docs/components/utility_methods.md @@ -437,25 +437,25 @@ Similar to timeouts and intervals, debounced functions can cause memory leaks if ### $debounce -The `this.$debounce()`-method creates a debounced function that delays execution until after a specified delay has passed since the last invocation. If the same key is debounced again before the delay completes, the previous debounce is cancelled and a new one is created. +The `this.$debounce()`-method creates a debounced function that delays execution until after a specified delay has passed since the last invocation. If the same name is debounced again before the delay completes, the previous debounce is cancelled and a new one is created. -The first argument is a `key` (string) that uniquely identifies this debounce instance. The second argument is the `callback` function to execute. The third argument is the `delay` in milliseconds. Additional arguments can be passed and will be forwarded to the callback function. +The first argument is a `name` (string) that uniquely identifies this debounce instance within the component. The second argument is the `callback` function to execute. The third argument is the `delay` in milliseconds. Additional arguments can be passed and will be forwarded to the callback function. -The method returns a `timeout id`, which can be used to manually clear the debounce. +The method returns a `debounce id`, which can be used to manually clear the debounce. **Key Features:** -- **Key-based**: Each debounce is identified by a unique key -- **Automatic replacement**: Calling `$debounce` with the same key replaces the previous debounce +- **Name-based**: Each debounce is identified by a unique name (unique per component instance) +- **Automatic replacement**: Calling `$debounce` with the same name replaces the previous debounce - **Memory efficient**: Only stores timeout IDs, function is captured in closure - **Automatic cleanup**: All debounces are cleared when component is destroyed ### $clearDebounce -The `this.$clearDebounce()`-method clears a specific debounce by its key. This prevents the debounced function from executing. +The `this.$clearDebounce()`-method clears a specific debounce by its name. This prevents the debounced function from executing. ### $clearDebounces -The `this.$clearDebounces()`-method clears all debounces created via `this.$debounce()` in one go. This method is automatically called when destroying a Component, preventing memory leaks due to dangling debounce timers. +The `this.$clearDebounces()`-method clears all debounces registered on the component in one go. This method is automatically called when destroying a Component, preventing memory leaks due to dangling debounce timers. ```js export default Blits.Component('ListComponent', { diff --git a/index.d.ts b/index.d.ts index 9eca1b04..420d72d8 100644 --- a/index.d.ts +++ b/index.d.ts @@ -429,21 +429,21 @@ declare module '@lightningjs/blits' { /** * Debounce a function execution, preventing memory leaks and function re-allocation - * @param key - Unique identifier for this debounce instance + * @param name - Unique identifier for this debounce instance (unique per component instance) * @param callback - Function to debounce * @param ms - Delay in milliseconds * @param args - Arguments to pass to the callback */ - $debounce: (key: string, callback: (...args: any[]) => void, ms?: number, ...args: any[]) => ReturnType + $debounce: (name: string, callback: (...args: any[]) => void, ms?: number, ...args: any[]) => ReturnType /** - * Clear a specific debounce by key - * @param key - The key of the debounce to clear + * Clear a specific debounce by name + * @param name - The name of the debounce to clear */ - $clearDebounce: (key: string) => void + $clearDebounce: (name: string) => void /** - * Clear all debounces (automatically called on component destroy) + * Clear all debounces registered on the component (automatically called on component destroy) */ $clearDebounces: () => void diff --git a/src/component/base/timeouts_intervals.js b/src/component/base/timeouts_intervals.js index fbb21536..8ede01e3 100644 --- a/src/component/base/timeouts_intervals.js +++ b/src/component/base/timeouts_intervals.js @@ -103,7 +103,7 @@ export default { // clear existing debounce for this key if it exists const existing = this[symbols.debounces].get(key) - if (existing) { + if (existing !== undefined) { this.$clearTimeout(existing) this[symbols.debounces].delete(key) } @@ -130,7 +130,7 @@ export default { $clearDebounce: { value: function (key) { const existing = this[symbols.debounces].get(key) - if (existing) { + if (existing !== undefined) { this.$clearTimeout(existing) this[symbols.debounces].delete(key) } @@ -142,8 +142,9 @@ export default { $clearDebounces: { value: function () { // clear all timeouts associated with debounces - for (const timeoutId of this[symbols.debounces].values()) { - this.$clearTimeout(timeoutId) + const timeoutIds = Array.from(this[symbols.debounces].values()) + for (let i = 0; i < timeoutIds.length; i++) { + this.$clearTimeout(timeoutIds[i]) } this[symbols.debounces].clear() }, From 56b887e9c1189ec051c87bc2354c6475a30a9d50 Mon Sep 17 00:00:00 2001 From: sairamg Date: Wed, 7 Jan 2026 20:05:59 +0530 Subject: [PATCH 52/66] Upadted name --- src/component/base/timeouts_intervals.js | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/component/base/timeouts_intervals.js b/src/component/base/timeouts_intervals.js index 8ede01e3..dabfc275 100644 --- a/src/component/base/timeouts_intervals.js +++ b/src/component/base/timeouts_intervals.js @@ -97,20 +97,20 @@ export default { configurable: false, }, $debounce: { - value: function (key, fn, ms, ...params) { + value: function (name, fn, ms, ...params) { // early exit when component is marked as end of life if (this.eol === true) return - // clear existing debounce for this key if it exists - const existing = this[symbols.debounces].get(key) + // clear existing debounce for this name if it exists + const existing = this[symbols.debounces].get(name) if (existing !== undefined) { this.$clearTimeout(existing) - this[symbols.debounces].delete(key) + this[symbols.debounces].delete(name) } // create new timeout const timeoutId = setTimeout(() => { - this[symbols.debounces].delete(key) + this[symbols.debounces].delete(name) this.$clearTimeout(timeoutId) fn.apply(this, params) }, ms) @@ -118,8 +118,8 @@ export default { // track timeout in timeouts array for automatic cleanup this[symbols.timeouts].push(timeoutId) - // store timeoutId per key to enable replace behavior and lifecycle cleanup - this[symbols.debounces].set(key, timeoutId) + // store timeoutId per name to enable replace behavior and lifecycle cleanup + this[symbols.debounces].set(name, timeoutId) return timeoutId }, @@ -128,11 +128,11 @@ export default { configurable: false, }, $clearDebounce: { - value: function (key) { - const existing = this[symbols.debounces].get(key) + value: function (name) { + const existing = this[symbols.debounces].get(name) if (existing !== undefined) { this.$clearTimeout(existing) - this[symbols.debounces].delete(key) + this[symbols.debounces].delete(name) } }, writable: false, From d1f66025c6aa19141bf28d11030787dbfc069057 Mon Sep 17 00:00:00 2001 From: sairamg Date: Thu, 8 Jan 2026 11:08:37 +0530 Subject: [PATCH 53/66] Updated documentation --- docs/components/utility_methods.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/components/utility_methods.md b/docs/components/utility_methods.md index 294856f6..cef9d4a8 100644 --- a/docs/components/utility_methods.md +++ b/docs/components/utility_methods.md @@ -446,7 +446,7 @@ The method returns a `debounce id`, which can be used to manually clear the debo **Key Features:** - **Name-based**: Each debounce is identified by a unique name (unique per component instance) - **Automatic replacement**: Calling `$debounce` with the same name replaces the previous debounce -- **Memory efficient**: Only stores timeout IDs, function is captured in closure +- **Memory efficient**: Only stores debounce IDs internally, function is captured in closure - **Automatic cleanup**: All debounces are cleared when component is destroyed ### $clearDebounce From f6cf3382125cfeedd76020d6062415e964cb8808 Mon Sep 17 00:00:00 2001 From: sairamg Date: Tue, 13 Jan 2026 15:04:06 +0530 Subject: [PATCH 54/66] feat: add automatic inspector metadata keys --- docs/essentials/element_attributes.md | 15 ++++++++ src/engines/L3/element.js | 49 +++++++++++++++++++++++++++ src/lib/codegenerator/generator.js | 12 +++++++ src/lib/lifecycle.js | 22 ++++++++++-- 4 files changed, 96 insertions(+), 2 deletions(-) diff --git a/docs/essentials/element_attributes.md b/docs/essentials/element_attributes.md index d5108f9c..c75aeb09 100644 --- a/docs/essentials/element_attributes.md +++ b/docs/essentials/element_attributes.md @@ -193,3 +193,18 @@ By default contents inside an Element (i.e. child Elements) will overflow the bo In order to contain / cut off the content inside an Element's `w` and `h`, you can add the `clipping="true"`-attribute. Setting `clipping` to `false` restores the default behaviour of content overflowing. Alternatively you can also use the `overflow`-attribute (and pass it `true` or `false`), which works similar to clipping just mapped inversly (i.e. `overflow="false"` ensures content that surpasses the parent dimensions is clipped-off). + +## Inspector Data + +The `inspector-data` attribute allows you to attach custom metadata to elements for debugging and automated testing. This data is visible in the Lightning inspector tool when enabled. + +```xml + +``` + +The framework automatically provides the following inspector metadata keys (prefixed with `$` to prevent naming collisions): +- `$componentType` - The type of element/component ('Element', 'Text', 'Layout', or component name) +- `$hasFocus` - Whether the component currently has focus (automatically updates) +- `$isTransitioning` - Whether the element is currently animating/transitioning (automatically updates) + +> **Note:** The `inspector-data` attribute is only processed in development mode when the inspector is enabled. It's automatically filtered out in production builds for performance. \ No newline at end of file diff --git a/src/engines/L3/element.js b/src/engines/L3/element.js index 1908af51..028fda3d 100644 --- a/src/engines/L3/element.js +++ b/src/engines/L3/element.js @@ -564,6 +564,44 @@ const Element = { }) } }, + /** + * Sets framework-provided inspector metadata + * Only sets if inspector is enabled and in dev mode + * @param {Object} data - Framework inspector metadata to merge + */ + setInspectorMetadata(data) { + // Early return if inspector not enabled (performance optimization) + if (inspectorEnabled === false) { + return + } + + if (inspectorEnabled === null) { + inspectorEnabled = Settings.get('inspector', false) + if (inspectorEnabled === false) return + } + + // Early return if element is destroyed (props.props is null) + if (this.props.props === undefined || this.props.props === null) { + return + } + + // Initialize data object if it doesn't exist + if (this.props['data'] === undefined) { + this.props['data'] = {} + } + if (this.props.props['data'] === undefined) { + this.props.props['data'] = {} + } + + // Merge framework data (with $ prefix to prevent collisions) + Object.assign(this.props['data'], data) + Object.assign(this.props.props['data'], data) + + // Sync to renderer node so inspector can see it + if (this.node !== undefined && this.node !== null) { + this.node.data = { ...this.props.props['data'] } + } + }, /** * Set an individual property on the node * @@ -651,6 +689,11 @@ const Element = { f, } + // Update inspector metadata when transition starts + if (inspectorEnabled !== false) { + this.setInspectorMetadata({ $isTransitioning: true }) + } + if (transition.start !== undefined && typeof transition.start === 'function') { // fire transition start callback when animation really starts (depending on specified delay) f.once('animating', () => { @@ -685,6 +728,12 @@ const Element = { } // remove the prop from scheduled transitions delete this.scheduledTransitions[prop] + // Update inspector metadata when transition ends + if (inspectorEnabled !== false) { + this.setInspectorMetadata({ + $isTransitioning: Object.keys(this.scheduledTransitions).length > 0, + }) + } }) // start animation diff --git a/src/lib/codegenerator/generator.js b/src/lib/codegenerator/generator.js index d4c13a7b..c5ade945 100644 --- a/src/lib/codegenerator/generator.js +++ b/src/lib/codegenerator/generator.js @@ -170,6 +170,18 @@ const generateElementCode = function ( renderCode.push(`elementConfigs[${counter}][Symbol.for('isSlot')] = true`) } + // Generate framework inspector data setup (dev mode only) + if (isDev === true) { + const componentType = templateObject[Symbol.for('componentType')] || 'Element' + renderCode.push(` + if (${elm} !== undefined && typeof ${elm}.setInspectorMetadata === 'function') { + ${elm}.setInspectorMetadata({ + $componentType: '${componentType}' + }) + } + `) + } + Object.keys(templateObject).forEach((key) => { if (key === 'slot') { renderCode.push(` diff --git a/src/lib/lifecycle.js b/src/lib/lifecycle.js index 43b56d02..f95429a1 100644 --- a/src/lib/lifecycle.js +++ b/src/lib/lifecycle.js @@ -82,8 +82,26 @@ export default { // emit 'public' hook emit(v, this.component[symbols.identifier], this.component) // update the built-in hasFocus state variable - if (v === 'focus') this.component[symbols.state].hasFocus = true - if (v === 'unfocus') this.component[symbols.state].hasFocus = false + if (v === 'focus') { + this.component[symbols.state].hasFocus = true + // Update inspector metadata on component's holder element + if ( + this.component[symbols.holder] && + typeof this.component[symbols.holder].setInspectorMetadata === 'function' + ) { + this.component[symbols.holder].setInspectorMetadata({ $hasFocus: true }) + } + } + if (v === 'unfocus') { + this.component[symbols.state].hasFocus = false + // Update inspector metadata on component's holder element + if ( + this.component[symbols.holder] && + typeof this.component[symbols.holder].setInspectorMetadata === 'function' + ) { + this.component[symbols.holder].setInspectorMetadata({ $hasFocus: false }) + } + } } }, } From 41db800280e4cbd2f1a1e10242ebd74afd6455db Mon Sep 17 00:00:00 2001 From: sairamg Date: Tue, 13 Jan 2026 16:00:47 +0530 Subject: [PATCH 55/66] Updated test cases --- src/lib/codegenerator/generator.test.js | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/lib/codegenerator/generator.test.js b/src/lib/codegenerator/generator.test.js index e6a04bb3..e3d13fba 100644 --- a/src/lib/codegenerator/generator.test.js +++ b/src/lib/codegenerator/generator.test.js @@ -4438,6 +4438,11 @@ test('Generate code for a template with verification of dynamic attributes', (as elementConfigs[0] = {} elms[0] = this.element({ parent: parent || 'root' }, inSlot === true ? slotComponent : component) + if (elms[0] !== undefined && typeof elms[0].setInspectorMetadata === 'function') { + elms[0].setInspectorMetadata({ + $componentType: 'Element' + }) + } propInComponent('eWidth', 'dynamic') elementConfigs[0]['w'] = component.eWidth @@ -4532,6 +4537,11 @@ test('Generate code for a template with verification of reactive attributes', (a elementConfigs[0] = {} elms[0] = this.element({ parent: parent || 'root' }, inSlot === true ? slotComponent : component) + if (elms[0] !== undefined && typeof elms[0].setInspectorMetadata === 'function') { + elms[0].setInspectorMetadata({ + $componentType: 'Element' + }) + } propInComponent('eWidth', 'reactive') elementConfigs[0]['w'] = component.eWidth @@ -4654,6 +4664,11 @@ test('Generate code for a template with attribute values verified against a nest elementConfigs[0] = {} elms[0] = this.element({ parent: parent || 'root' }, inSlot === true ? slotComponent : component) + if (elms[0] !== undefined && typeof elms[0].setInspectorMetadata === 'function') { + elms[0].setInspectorMetadata({ + $componentType: 'Element' + }) + } propInComponent('position.x', 'dynamic') elementConfigs[0]['x'] = component.position.x @@ -4780,6 +4795,11 @@ test('Generate code for a template with verification of attributes with Math cal elementConfigs[0] = {} elms[0] = this.element({ parent: parent || 'root' }, inSlot === true ? slotComponent : component) + if (elms[0] !== undefined && typeof elms[0].setInspectorMetadata === 'function') { + elms[0].setInspectorMetadata({ + $componentType: 'Element' + }) + } propInComponent('position.x', 'dynamic') elementConfigs[0]['x'] = component.position.x * 100 From 1aac2d938ca2546a34747e581eefe9efd9ea6987 Mon Sep 17 00:00:00 2001 From: Michiel van der Geest Date: Tue, 13 Jan 2026 16:37:31 +0100 Subject: [PATCH 56/66] Added some optimization / fixed some coding style issues. --- src/announcer/announcer.js | 5 +---- src/announcer/speechSynthesis.js | 26 +++++++++++++------------- 2 files changed, 14 insertions(+), 17 deletions(-) diff --git a/src/announcer/announcer.js b/src/announcer/announcer.js index 461d5053..410ec630 100644 --- a/src/announcer/announcer.js +++ b/src/announcer/announcer.js @@ -25,12 +25,9 @@ let isProcessing = false let currentId = null let debounce = null -const isAndroid = /android/i.test((window.navigator || {}).userAgent || '') -const defaultUtteranceKeepAlive = !isAndroid - // Global default utterance options let globalDefaultOptions = { - enableUtteranceKeepAlive: defaultUtteranceKeepAlive, + enableUtteranceKeepAlive: !/android/i.test((window.navigator || {}).userAgent || ''), } const noopAnnouncement = { diff --git a/src/announcer/speechSynthesis.js b/src/announcer/speechSynthesis.js index 36f726ec..00d8c2df 100644 --- a/src/announcer/speechSynthesis.js +++ b/src/announcer/speechSynthesis.js @@ -25,10 +25,10 @@ let initialized = false const clear = (id) => { const state = utterances.get(id) - if (!state) { + if (state === undefined) { return } - if (state && state.timer !== null) { + if (state.timer !== null) { clearTimeout(state.timer) state.timer = null } @@ -39,12 +39,12 @@ const startKeepAlive = (id) => { const state = utterances.get(id) // utterance status: utterance was removed (cancelled or finished) - if (!state) { + if (state == undefined) { return } // Clear existing timer for this specific utterance - if (state && state.timer !== null) { + if (state.timer !== null) { clearTimeout(state.timer) state.timer = null } @@ -66,10 +66,10 @@ const startKeepAlive = (id) => { }, 0) // Check if utterance still exists before scheduling next cycle - if (utterances.has(id)) { + if (utterances.has(id) === true) { state.timer = setTimeout(() => { // Double-check utterance still exists before resuming - if (utterances.has(id)) { + if (utterances.has(id) === true) { startKeepAlive(id) } }, 5000) @@ -99,35 +99,35 @@ const initialize = () => { const waitForSynthReady = (timeoutMs = 2000, checkIntervalMs = 100) => { return new Promise((resolve) => { if (!syn) { - Log.warn('SpeechSynthesis - syn unavailable') + Log.debug('SpeechSynthesis - syn unavailable') resolve() return } if (!syn.speaking && !syn.pending) { - Log.warn('SpeechSynthesis - ready immediately') + Log.debug('SpeechSynthesis - ready immediately') resolve() return } - Log.warn('SpeechSynthesis - waiting for ready state...') + Log.debug('SpeechSynthesis - waiting for ready state...') const startTime = Date.now() - const intervalId = window.setInterval(() => { + const intervalId = setInterval(() => { const elapsed = Date.now() - startTime const isReady = !syn.speaking && !syn.pending if (isReady) { Log.debug(`SpeechSynthesis - ready after ${elapsed}ms`) - window.clearInterval(intervalId) + clearInterval(intervalId) resolve() } else if (elapsed >= timeoutMs) { - Log.warn(`SpeechSynthesis - timeout after ${elapsed}ms, forcing ready`, { + Log.debug(`SpeechSynthesis - timeout after ${elapsed}ms, forcing ready`, { speaking: syn.speaking, pending: syn.pending, }) - window.clearInterval(intervalId) + clearInterval(intervalId) resolve() } }, checkIntervalMs) From d0c8edb4fbb531bbfd97549f6fe739a7f0862d1b Mon Sep 17 00:00:00 2001 From: Michiel van der Geest Date: Tue, 13 Jan 2026 17:07:54 +0100 Subject: [PATCH 57/66] Removed cancelPrevious concept in favour of clear readable function calls. --- index.d.ts | 6 ------ src/announcer/announcer.js | 13 +------------ 2 files changed, 1 insertion(+), 18 deletions(-) diff --git a/index.d.ts b/index.d.ts index d34d943e..5026ee53 100644 --- a/index.d.ts +++ b/index.d.ts @@ -54,12 +54,6 @@ declare module '@lightningjs/blits' { * @default 1 */ volume?: number, - /** - * Whether to cancel previous announcements when adding this one - * - * @default false - */ - cancelPrevious?: boolean, /** * Whether to enable utterance keep-alive (prevents pausing on some platforms) * diff --git a/src/announcer/announcer.js b/src/announcer/announcer.js index 410ec630..81d113ba 100644 --- a/src/announcer/announcer.js +++ b/src/announcer/announcer.js @@ -52,12 +52,6 @@ const toggle = (v) => { const speak = (message, politeness = 'off', options = {}) => { if (active === false) return noopAnnouncement - - // if cancelPrevious option is set, clear the queue and stop current speech - if (options.cancelPrevious === true) { - clear(true) - } - return addToQueue(message, politeness, false, options) } @@ -200,15 +194,10 @@ const stop = () => { } } -const clear = (cancelPrevious = false) => { +const clear = () => { // Clear debounce timer clearDebounceTimer() - // Cancel any active speech synthesis - if (cancelPrevious === true) { - speechSynthesis.cancel() - } - // Resolve all pending items in queue while (queue.length > 0) { const item = queue.shift() From 408f87f3c074f36d1e6970139c10f52d8dda76aa Mon Sep 17 00:00:00 2001 From: Michiel van der Geest Date: Wed, 14 Jan 2026 14:29:38 +0100 Subject: [PATCH 58/66] Fixed typo. --- docs/plugins/text-to-speech-announcer.md | 2 +- src/announcer/announcer.test.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/plugins/text-to-speech-announcer.md b/docs/plugins/text-to-speech-announcer.md index 31c74cf9..e3d658ea 100644 --- a/docs/plugins/text-to-speech-announcer.md +++ b/docs/plugins/text-to-speech-announcer.md @@ -49,7 +49,7 @@ Blits.Component('MyComponent', { ### Execute logic when done -The `speak()`-function returns a promise that resolves when it's done speaking, or when it's interupted or errors out. The reason of the `speak()` method resolving is passed as an argument into the resolve function. +The `speak()`-function returns a promise that resolves when it's done speaking, or when it's interrupted or errors out. The reason of the `speak()` method resolving is passed as an argument into the resolve function. ```js this.$announcer.speak('Hello').then((reason) => console.log('Speaking hello finished', reason)) diff --git a/src/announcer/announcer.test.js b/src/announcer/announcer.test.js index 15079983..bbbf9caa 100644 --- a/src/announcer/announcer.test.js +++ b/src/announcer/announcer.test.js @@ -171,9 +171,9 @@ test('Announcer stop interrupts processing', (assert) => { const announcement = announcer.speak('test message for interruption') announcement.then((status) => { - // Should resolve with 'interupted' when stop() is called + // Should resolve with 'interrupted' when stop() is called assert.ok( - status === 'interupted' || status === 'unavailable', + status === 'interrupted' || status === 'unavailable', 'Stop interrupts (or unavailable if no speechSynthesis)' ) assert.end() From 41bb5c869e8a5610147d8897f41244700438a44c Mon Sep 17 00:00:00 2001 From: sairamg Date: Fri, 16 Jan 2026 15:07:32 +0530 Subject: [PATCH 59/66] Updated the code to generate componentType on component --- src/engines/L3/element.js | 11 +++------ src/lib/codegenerator/generator.js | 31 ++++++++++++++----------- src/lib/codegenerator/generator.test.js | 20 ---------------- 3 files changed, 21 insertions(+), 41 deletions(-) diff --git a/src/engines/L3/element.js b/src/engines/L3/element.js index 028fda3d..acd4aa81 100644 --- a/src/engines/L3/element.js +++ b/src/engines/L3/element.js @@ -571,15 +571,10 @@ const Element = { */ setInspectorMetadata(data) { // Early return if inspector not enabled (performance optimization) - if (inspectorEnabled === false) { + if (inspectorEnabled !== true) { return } - if (inspectorEnabled === null) { - inspectorEnabled = Settings.get('inspector', false) - if (inspectorEnabled === false) return - } - // Early return if element is destroyed (props.props is null) if (this.props.props === undefined || this.props.props === null) { return @@ -690,7 +685,7 @@ const Element = { } // Update inspector metadata when transition starts - if (inspectorEnabled !== false) { + if (inspectorEnabled === true) { this.setInspectorMetadata({ $isTransitioning: true }) } @@ -729,7 +724,7 @@ const Element = { // remove the prop from scheduled transitions delete this.scheduledTransitions[prop] // Update inspector metadata when transition ends - if (inspectorEnabled !== false) { + if (inspectorEnabled === true) { this.setInspectorMetadata({ $isTransitioning: Object.keys(this.scheduledTransitions).length > 0, }) diff --git a/src/lib/codegenerator/generator.js b/src/lib/codegenerator/generator.js index c5ade945..882756df 100644 --- a/src/lib/codegenerator/generator.js +++ b/src/lib/codegenerator/generator.js @@ -170,18 +170,6 @@ const generateElementCode = function ( renderCode.push(`elementConfigs[${counter}][Symbol.for('isSlot')] = true`) } - // Generate framework inspector data setup (dev mode only) - if (isDev === true) { - const componentType = templateObject[Symbol.for('componentType')] || 'Element' - renderCode.push(` - if (${elm} !== undefined && typeof ${elm}.setInspectorMetadata === 'function') { - ${elm}.setInspectorMetadata({ - $componentType: '${componentType}' - }) - } - `) - } - Object.keys(templateObject).forEach((key) => { if (key === 'slot') { renderCode.push(` @@ -295,9 +283,11 @@ const generateComponentCode = function ( const children = templateObject.children delete templateObject.children + // Capture holder counter before generating element code (which may process children and increment counter) + const holderCounter = counter generateElementCode.call(this, templateObject, parent, { ...options, ...{ holder: true } }) - parent = options.key ? `elms[${counter}][${options.key}]` : `elms[${counter}]` + parent = options.key ? `elms[${holderCounter}][${options.key}]` : `elms[${holderCounter}]` counter++ @@ -366,6 +356,21 @@ const generateComponentCode = function ( } `) + // For forloops, this code runs per instance, setting metadata for each component instance + if (isDev === true) { + const componentType = templateObject[Symbol.for('componentType')] + const holderElm = options.key + ? `elms[${holderCounter}][${options.key}]` + : `elms[${holderCounter}]` + renderCode.push(` + if (${holderElm} !== undefined && typeof ${holderElm}.setInspectorMetadata === 'function') { + ${holderElm}.setInspectorMetadata({ + $componentType: '${componentType}' + }) + } + `) + } + this.cleanupCode.push(`components[${counter}] = null`) if (options.forloop) { diff --git a/src/lib/codegenerator/generator.test.js b/src/lib/codegenerator/generator.test.js index e3d13fba..e6a04bb3 100644 --- a/src/lib/codegenerator/generator.test.js +++ b/src/lib/codegenerator/generator.test.js @@ -4438,11 +4438,6 @@ test('Generate code for a template with verification of dynamic attributes', (as elementConfigs[0] = {} elms[0] = this.element({ parent: parent || 'root' }, inSlot === true ? slotComponent : component) - if (elms[0] !== undefined && typeof elms[0].setInspectorMetadata === 'function') { - elms[0].setInspectorMetadata({ - $componentType: 'Element' - }) - } propInComponent('eWidth', 'dynamic') elementConfigs[0]['w'] = component.eWidth @@ -4537,11 +4532,6 @@ test('Generate code for a template with verification of reactive attributes', (a elementConfigs[0] = {} elms[0] = this.element({ parent: parent || 'root' }, inSlot === true ? slotComponent : component) - if (elms[0] !== undefined && typeof elms[0].setInspectorMetadata === 'function') { - elms[0].setInspectorMetadata({ - $componentType: 'Element' - }) - } propInComponent('eWidth', 'reactive') elementConfigs[0]['w'] = component.eWidth @@ -4664,11 +4654,6 @@ test('Generate code for a template with attribute values verified against a nest elementConfigs[0] = {} elms[0] = this.element({ parent: parent || 'root' }, inSlot === true ? slotComponent : component) - if (elms[0] !== undefined && typeof elms[0].setInspectorMetadata === 'function') { - elms[0].setInspectorMetadata({ - $componentType: 'Element' - }) - } propInComponent('position.x', 'dynamic') elementConfigs[0]['x'] = component.position.x @@ -4795,11 +4780,6 @@ test('Generate code for a template with verification of attributes with Math cal elementConfigs[0] = {} elms[0] = this.element({ parent: parent || 'root' }, inSlot === true ? slotComponent : component) - if (elms[0] !== undefined && typeof elms[0].setInspectorMetadata === 'function') { - elms[0].setInspectorMetadata({ - $componentType: 'Element' - }) - } propInComponent('position.x', 'dynamic') elementConfigs[0]['x'] = component.position.x * 100 From b01f97cd1f0d4952bcff9d33ad67579045969a00 Mon Sep 17 00:00:00 2001 From: sairamg Date: Fri, 16 Jan 2026 15:14:21 +0530 Subject: [PATCH 60/66] Updated document --- docs/essentials/element_attributes.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/essentials/element_attributes.md b/docs/essentials/element_attributes.md index c75aeb09..41937a62 100644 --- a/docs/essentials/element_attributes.md +++ b/docs/essentials/element_attributes.md @@ -202,9 +202,11 @@ The `inspector-data` attribute allows you to attach custom metadata to elements ``` -The framework automatically provides the following inspector metadata keys (prefixed with `$` to prevent naming collisions): -- `$componentType` - The type of element/component ('Element', 'Text', 'Layout', or component name) -- `$hasFocus` - Whether the component currently has focus (automatically updates) +The framework automatically provides the following inspector metadata keys for **Components only** (prefixed with `$` to prevent naming collisions): +- `$componentType` - The component name (e.g., 'MyComponent', 'Button', etc.) +- `$hasFocus` - Whether the component currently has focus (automatically updates on focus/unfocus events) - `$isTransitioning` - Whether the element is currently animating/transitioning (automatically updates) -> **Note:** The `inspector-data` attribute is only processed in development mode when the inspector is enabled. It's automatically filtered out in production builds for performance. \ No newline at end of file +> **Note:** +> - The `inspector-data` attribute is only processed in development mode when the inspector is enabled. It's automatically filtered out in production builds for performance. +> - Automatic framework metadata (`$componentType`, `$hasFocus`, `$isTransitioning`) is only set for Components,to keep the render path lightweight. \ No newline at end of file From a44aa7e87f5bfa27947d4cfb03074bb0a7d3026a Mon Sep 17 00:00:00 2001 From: sairamg Date: Fri, 16 Jan 2026 15:30:36 +0530 Subject: [PATCH 61/66] Updated documentation --- docs/essentials/element_attributes.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/essentials/element_attributes.md b/docs/essentials/element_attributes.md index 41937a62..58b0a6cf 100644 --- a/docs/essentials/element_attributes.md +++ b/docs/essentials/element_attributes.md @@ -196,10 +196,11 @@ Alternatively you can also use the `overflow`-attribute (and pass it `true` or ` ## Inspector Data -The `inspector-data` attribute allows you to attach custom metadata to elements for debugging and automated testing. This data is visible in the Lightning inspector tool when enabled. +The `inspector-data` attribute allows you to attach custom metadata to elements and components for debugging and automated testing. This data is visible in the Lightning inspector tool when enabled. ```xml +