From 09b8730c7a172e62324fa329a3cfb36212235588 Mon Sep 17 00:00:00 2001 From: Michiel van der Geest Date: Mon, 29 Jun 2026 09:44:07 +0200 Subject: [PATCH 01/10] Added keepAlive option to src object to set the renderer preventCleanup flag. --- src/engines/L3/element.js | 13 ++++- src/engines/L3/element.test.js | 86 +++++++++++++++++++++++++++++++++- 2 files changed, 96 insertions(+), 3 deletions(-) diff --git a/src/engines/L3/element.js b/src/engines/L3/element.js index a45029bb..52959a32 100644 --- a/src/engines/L3/element.js +++ b/src/engines/L3/element.js @@ -202,6 +202,12 @@ const colorMap = { right: 'colorRight', } +const setTextureOption = (target, key, value) => { + const opts = target.props['textureOptions'] || target.element.node.textureOptions || {} + opts[key] = value + target.props['textureOptions'] = opts +} + /** * Default text settings for text nodes (initialized on first use). * @type {object|null} @@ -275,6 +281,9 @@ const propsTransformer = { if (typeof v === 'object' || (isObjectString(v) === true && (v = parseToObject(v)))) { this.props['src'] = v.src this.props['imageType'] = v.type + if ('keepAlive' in v) { + setTextureOption(this, 'preventCleanup', v.keepAlive === true) + } } else { this.props['src'] = v } @@ -318,7 +327,7 @@ const propsTransformer = { const resizeMode = {} if (v === 'cover' || v === 'contain') { - this.props['textureOptions'] = { resizeMode: { type: v } } + setTextureOption(this, 'resizeMode', { type: v }) return } @@ -333,7 +342,7 @@ const propsTransformer = { resizeMode['clipX'] = 'x' in v.position === true ? v.position.x : null resizeMode['clipY'] = 'y' in v.position === true ? v.position.y : null } - this.props['textureOptions'] = { resizeMode } + setTextureOption(this, 'resizeMode', resizeMode) } }, set rtt(v) { diff --git a/src/engines/L3/element.test.js b/src/engines/L3/element.test.js index 4afb2ad9..406072cc 100644 --- a/src/engines/L3/element.test.js +++ b/src/engines/L3/element.test.js @@ -255,7 +255,7 @@ test('Element - Set `color` property', (assert) => { test('Element - Set `src` property', (assert) => { assert.capture(renderer, 'createNode', () => new EventEmitter()) const el = createElement() - const value = 'file://foo.html' + const value = 'assets/image.jpg' el.set('src', value) @@ -266,6 +266,50 @@ test('Element - Set `src` property', (assert) => { assert.end() }) +test('Element - Set `src` property with keepAlive option', (assert) => { + assert.capture(renderer, 'createNode', () => new EventEmitter()) + const el = createElement() + const value = { src: 'assets/image.jpg', type: 'regular', keepAlive: true } + + el.set('src', value) + + assert.equal(el.node['src'], value.src, 'Node src parameter should be set') + assert.equal(el.node['imageType'], value.type, 'Node imageType parameter should be set') + assert.ok(el.node['textureOptions'] instanceof Object, 'textureOptions should be an object') + assert.equal( + el.node['textureOptions']['preventCleanup'], + true, + 'Node textureOptions.preventCleanup should be true' + ) + assert.equal( + el.props.props['textureOptions']['preventCleanup'], + true, + 'Props textureOptions.preventCleanup should be true' + ) + assert.equal(el.props.raw['src'], value, "Props' raw map entry should be added") + assert.end() +}) + +test('Element - Update `src.keepAlive` reactively', (assert) => { + assert.capture(renderer, 'createNode', () => new EventEmitter()) + const el = createElement() + + el.set('src', { src: 'assets/image.jpg', keepAlive: true }) + el.set('src', { src: 'assets/image.jpg', keepAlive: false }) + + assert.equal( + el.node['textureOptions']['preventCleanup'], + false, + 'Node textureOptions.preventCleanup should be updated to false' + ) + assert.equal( + el.props.props['textureOptions']['preventCleanup'], + false, + 'Props textureOptions.preventCleanup should be updated to false' + ) + assert.end() +}) + test('Element - Set `texture` property', (assert) => { assert.capture(renderer, 'createNode', () => new EventEmitter()) const el = createElement() @@ -392,6 +436,46 @@ test('Element - Set `fit` property should not set not required keys', (assert) = assert.end() }) +test('Element - Set `src.keepAlive` then `fit` preserves both textureOptions', (assert) => { + assert.capture(renderer, 'createNode', () => new EventEmitter()) + const el = createElement() + + el.set('src', { src: 'assets/image.jpg', keepAlive: true }) + el.set('fit', 'contain') + + assert.equal( + el.node['textureOptions']['preventCleanup'], + true, + 'Node textureOptions.preventCleanup should be preserved' + ) + assert.equal( + el.node['textureOptions']['resizeMode']['type'], + 'contain', + 'Node resizeMode.type should be set' + ) + assert.end() +}) + +test('Element - Set `fit` then `src.keepAlive` preserves both textureOptions', (assert) => { + assert.capture(renderer, 'createNode', () => new EventEmitter()) + const el = createElement() + + el.set('fit', 'cover') + el.set('src', { src: 'assets/image.jpg', keepAlive: true }) + + assert.equal( + el.node['textureOptions']['resizeMode']['type'], + 'cover', + 'Node resizeMode.type should be preserved' + ) + assert.equal( + el.node['textureOptions']['preventCleanup'], + true, + 'Node textureOptions.preventCleanup should be set' + ) + assert.end() +}) + test('Element - Set `placement` property with value `center`', (assert) => { assert.capture(renderer, 'createNode', () => new EventEmitter()) const el = createElement() From b63efcc2c04f9d843d03802f945a1df54ccc3cc5 Mon Sep 17 00:00:00 2001 From: Michiel van der Geest Date: Mon, 29 Jun 2026 10:00:16 +0200 Subject: [PATCH 02/10] Added documentation about the object format for src. --- docs/essentials/displaying_images.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/essentials/displaying_images.md b/docs/essentials/displaying_images.md index 41e1777b..34e5b3c8 100644 --- a/docs/essentials/displaying_images.md +++ b/docs/essentials/displaying_images.md @@ -19,6 +19,18 @@ It is recommended to give your Element a width (`w`) and a height (`h`) attribut For the best performance, it's important to keep your source images as small as possible. If you're displaying an image at `200px x 200px`, make sure the image is exactly that size or _smaller_. The latter option may lead to some quality loss, but can positively impact the overall performance of your App. +## Advanced src Attribute + +Besides passing a string to the `src` attribute, you can also pass an object with extra options. + +```xml + +``` + +The `type` option can be used to explicitly set the image type. This is useful when the type can not be derived from the url or filename, or when you want to use a compressed texture format like `ktx`. + +The `keepAlive` option prevents the texture generated from the source image from being cleaned up. Use this for images that are reused often and should stay available in memory. + ## Colorization You also have the option to _colorize_ an image on the fly. Just add a `color` attribute to the Element with a `src` attribute. You can use a single color, or apply a gradient. From 44fe8f1f700d895281cc45fc6814bd44289c741d Mon Sep 17 00:00:00 2001 From: Michiel van der Geest Date: Mon, 29 Jun 2026 13:54:49 +0200 Subject: [PATCH 03/10] Asbtracted announcer (and speechSynthesis) to platform layer to make it injectable and better separated. --- docs/essentials/settings.md | 20 +++++++- index.d.ts | 30 +++++++++--- src/announcer/announcer.js | 33 +++++++++++-- src/announcer/announcer.test.js | 69 +++++++++++++++++++++++++-- src/announcer/speechSynthesis.js | 33 +++++++++---- src/announcer/speechSynthesis.test.js | 29 +++++++---- src/platform.js | 11 ++--- src/platform.test.js | 26 ++++++++++ src/testing/nodePlatform.js | 16 ++----- 9 files changed, 215 insertions(+), 52 deletions(-) diff --git a/docs/essentials/settings.md b/docs/essentials/settings.md index e52af70b..2da1a34f 100644 --- a/docs/essentials/settings.md +++ b/docs/essentials/settings.md @@ -109,7 +109,25 @@ Blits.Launch(App, 'app', { }) ``` -Common platform properties that can be overwritten are `input`, `viewport`, `dispatchEvent`, `localStorage`, `getCookie`, `setCookie`, `historyBack`, `screenHeight`, `hardwareConcurrency`, `userAgent`, `KeyboardEvent`, `isKeyboardEvent`, `createKeyboardEvent`, `SpeechSynthesisUtterance`, `speechSynthesis`, and `now`. +Common platform properties that can be overwritten are `input`, `viewport`, `dispatchEvent`, `localStorage`, `getCookie`, `setCookie`, `historyBack`, `screenHeight`, `hardwareConcurrency`, `userAgent`, `KeyboardEvent`, `isKeyboardEvent`, `createKeyboardEvent`, `announcer`, and `now`. + +The `announcer` platform property can be used to provide a custom text-to-speech driver. When set, Blits keeps using the built-in announcer queue and calls the custom driver's `speak(options)` and `cancel()` methods instead of the default Web Speech implementation. + +```js +Blits.Launch(App, 'app', { + announcer: true, + platform: (defaults) => ({ + announcer: { + speak(options) { + return myPlatformSpeech.speak(options.message) + }, + cancel() { + myPlatformSpeech.cancel() + }, + }, + }), +}) +``` The `rendererPlatform` setting is separate from `platform`. It is only passed to the renderer, and should be used for renderer specific platform configuration. diff --git a/index.d.ts b/index.d.ts index ff65ca56..2bbeaefe 100644 --- a/index.d.ts +++ b/index.d.ts @@ -66,6 +66,28 @@ declare module '@lightningjs/blits' { enableUtteranceKeepAlive?: boolean } + export interface AnnouncerDriverOptions extends AnnouncerUtteranceOptions { + /** + * Message to be spoken by the platform announcer driver. + */ + message: string | number, + /** + * Internal announcement id used by the Blits announcer queue. + */ + id: number, + } + + export interface AnnouncerDriver { + /** + * Speak one queued announcement. + */ + speak(options: AnnouncerDriverOptions): Promise, + /** + * Stop the active platform announcement, when supported. + */ + cancel(): void, + } + export interface AnnouncerUtterance extends Promise { /** * Removes a specific message from the announcement queue, @@ -1070,13 +1092,9 @@ declare module '@lightningjs/blits' { */ createKeyboardEvent?: (type: string, init?: KeyboardEventInit | any) => any, /** - * SpeechSynthesisUtterance constructor used by the announcer. - */ - SpeechSynthesisUtterance?: typeof SpeechSynthesisUtterance | any, - /** - * Speech synthesis API implementation used by the announcer. + * Platform-specific announcer driver used instead of the default Web Speech driver. */ - speechSynthesis?: SpeechSynthesis | any, + announcer?: AnnouncerDriver, /** * Monotonic timestamp provider used for input throttling. */ diff --git a/src/announcer/announcer.js b/src/announcer/announcer.js index 622acea4..94bb48b9 100644 --- a/src/announcer/announcer.js +++ b/src/announcer/announcer.js @@ -16,7 +16,6 @@ */ import { Log } from '../lib/log.js' -import speechSynthesis from './speechSynthesis.js' import { platform } from '../platform.js' let active = false @@ -31,6 +30,20 @@ let globalDefaultOptions = { enableUtteranceKeepAlive: !/android/i.test(platform.userAgent || ''), } +const getDriver = () => { + const driver = platform.announcer + if (driver && typeof driver.speak === 'function') { + return driver + } +} + +const cancelDriver = () => { + const driver = getDriver() + if (driver && typeof driver.cancel === 'function') { + driver.cancel() + } +} + const noopAnnouncement = { then() {}, done() {}, @@ -84,7 +97,7 @@ const addToQueue = (message, politeness, delay = false, options = {}) => { // augment the promise with a stop function done.stop = () => { if (id === currentId) { - speechSynthesis.cancel() + cancelDriver() isProcessing = false resolveFn('interupted') } @@ -133,7 +146,17 @@ const processQueue = async () => { debounce = setTimeout(() => { Log.debug(`Announcer - speaking: "${message}" (id: ${id})`) - speechSynthesis + const driver = getDriver() + if (driver === undefined) { + currentId = null + currentResolveFn = null + isProcessing = false + resolveFn('unavailable') + processQueue() + return + } + + driver .speak({ message, id, @@ -180,8 +203,8 @@ const stop = () => { // Clear debounce timer if speech hasn't started yet clearDebounceTimer() - // Always cancel speech synthesis to ensure clean state - speechSynthesis.cancel() + // Always cancel the platform announcer to ensure clean state + cancelDriver() // Store resolve function before resetting state const resolveFn = currentResolveFn diff --git a/src/announcer/announcer.test.js b/src/announcer/announcer.test.js index f27a67c8..842cdd11 100644 --- a/src/announcer/announcer.test.js +++ b/src/announcer/announcer.test.js @@ -17,7 +17,7 @@ import test from 'tape' -// Mock speechSynthesis API before importing announcer (which imports speechSynthesis) +// Mock Web Speech API for the default announcer driver const mockUtterance = class { constructor(text) { this.text = text @@ -62,16 +62,15 @@ const mockSpeechSynthesis = { }, } -// Setup mocks before module loads (speechSynthesis.js captures window.speechSynthesis at import time) window.speechSynthesis = mockSpeechSynthesis window.SpeechSynthesisUtterance = mockUtterance globalThis.SpeechSynthesisUtterance = mockUtterance -// Dynamic import to ensure mocks are set before module evaluation const announcerModule = await import('./announcer.js') const announcer = announcerModule.default import { initLog } from '../lib/log.js' +import { configurePlatform } from '../platform.js' initLog() announcer.enable() @@ -229,7 +228,7 @@ test('Announcer stop interrupts processing', (assert) => { // Due to timing, it may finish before stop is called assert.ok( status === 'interrupted' || status === 'unavailable' || status === 'finished', - 'Stop interrupts (or unavailable if no speechSynthesis, or finished if completed)' + 'Stop interrupts (or unavailable if Web Speech is missing, or finished if completed)' ) assert.end() }) @@ -277,3 +276,65 @@ test('Announcer queue behavior', (assert) => { assert.end() }) }) + +test('Announcer uses custom platform announcer driver', (assert) => { + announcer.stop() + announcer.clear() + announcer.enable() + + const calls = [] + + configurePlatform((defaults) => ({ + ...defaults, + announcer: { + speak(options) { + calls.push(options) + return Promise.resolve() + }, + cancel() {}, + }, + })) + + announcer.speak('custom driver message', 'off', { lang: 'nl-NL' }).then((status) => { + assert.equal(status, 'finished', 'custom driver announcement resolves') + assert.equal(calls.length, 1, 'custom driver speak is called once') + assert.equal(calls[0].message, 'custom driver message', 'custom driver receives message') + assert.equal(calls[0].lang, 'nl-NL', 'custom driver receives options') + + configurePlatform(() => ({})) + assert.end() + }) +}) + +test('Announcer stop uses custom platform announcer driver cancel', (assert) => { + announcer.stop() + announcer.clear() + announcer.enable() + + let cancelCount = 0 + + configurePlatform((defaults) => ({ + ...defaults, + announcer: { + speak() { + return new Promise(() => {}) + }, + cancel() { + cancelCount++ + }, + }, + })) + + const announcement = announcer.speak('custom driver stop') + announcement.then((status) => { + assert.equal(status, 'interupted', 'announcement resolves as interrupted') + assert.equal(cancelCount, 1, 'custom driver cancel is called') + + configurePlatform(() => ({})) + assert.end() + }) + + setTimeout(() => { + announcement.stop() + }, 400) +}) diff --git a/src/announcer/speechSynthesis.js b/src/announcer/speechSynthesis.js index b734bb18..1bf203ad 100644 --- a/src/announcer/speechSynthesis.js +++ b/src/announcer/speechSynthesis.js @@ -16,11 +16,28 @@ */ import { Log } from '../lib/log.js' -import { platform } from '../platform.js' const utterances = new Map() // id -> { utterance, timer, ignoreResume } let initialized = false +const globalScope = globalThis + +const getSpeechSynthesis = () => { + const windowRef = globalScope.window + const selfRef = globalScope.self || windowRef || globalScope + + return ( + globalScope.speechSynthesis || + (selfRef && selfRef.speechSynthesis) || + (windowRef && windowRef.speechSynthesis) + ) +} + +const getSpeechSynthesisUtterance = () => { + const windowRef = globalScope.window + + return globalScope.SpeechSynthesisUtterance || (windowRef && windowRef.SpeechSynthesisUtterance) +} const clear = (id) => { const state = utterances.get(id) @@ -35,7 +52,7 @@ const clear = (id) => { } const startKeepAlive = (id) => { - const syn = platform.speechSynthesis + const syn = getSpeechSynthesis() const state = utterances.get(id) // utterance status: utterance was removed (cancelled or finished) @@ -85,7 +102,7 @@ const defaultUtteranceProps = { } const initialize = () => { - const syn = platform.speechSynthesis + const syn = getSpeechSynthesis() // syn api check: syn might not have getVoices method if (!syn || typeof syn.getVoices !== 'function') { initialized = false @@ -99,7 +116,7 @@ const initialize = () => { const waitForSynthReady = (timeoutMs = 2000, checkIntervalMs = 100) => { return new Promise((resolve) => { - const syn = platform.speechSynthesis + const syn = getSpeechSynthesis() if (!syn) { Log.debug('SpeechSynthesis - syn unavailable') resolve() @@ -137,7 +154,7 @@ const waitForSynthReady = (timeoutMs = 2000, checkIntervalMs = 100) => { } const speak = async (options) => { - const syn = platform.speechSynthesis + const syn = getSpeechSynthesis() // options check: missing required options if (!options || !options.message) { return Promise.reject({ error: 'Missing message' }) @@ -157,7 +174,7 @@ const speak = async (options) => { // Wait for engine to be ready await waitForSynthReady() - const SpeechSynthesisUtterance = platform.SpeechSynthesisUtterance + const SpeechSynthesisUtterance = getSpeechSynthesisUtterance() if (SpeechSynthesisUtterance === undefined) { return Promise.reject({ error: 'unavailable' }) } @@ -216,7 +233,7 @@ const speak = async (options) => { export default { speak(options) { - const syn = platform.speechSynthesis + const syn = getSpeechSynthesis() if (syn !== undefined) { if (initialized === false) { initialize() @@ -228,7 +245,7 @@ export default { } }, cancel() { - const syn = platform.speechSynthesis + const syn = getSpeechSynthesis() if (syn !== undefined) { // timers: clear all timers before cancelling for (const id of utterances.keys()) { diff --git a/src/announcer/speechSynthesis.test.js b/src/announcer/speechSynthesis.test.js index c4e0246c..e1cdfaf4 100644 --- a/src/announcer/speechSynthesis.test.js +++ b/src/announcer/speechSynthesis.test.js @@ -62,13 +62,12 @@ const mockSpeechSynthesis = { }, } -// Setup mocks before module loads (speechSynthesis.js captures window.speechSynthesis at import time) +// Setup Web Speech mocks for the default browser announcer driver window.speechSynthesis = mockSpeechSynthesis window.SpeechSynthesisUtterance = mockUtterance globalThis.SpeechSynthesisUtterance = mockUtterance const mockWindow = window -// Dynamic import to ensure mocks are set before module evaluation const speechSynthesisModule = await import('./speechSynthesis.js') const speechSynthesis = speechSynthesisModule.default @@ -277,14 +276,26 @@ test('speechSynthesis - multiple speak calls', (assert) => { test('speechSynthesis - handle unavailable API', (assert) => { assert.plan(1) - // Temporarily remove speechSynthesis const originalSyn = global.window.speechSynthesis - global.window.speechSynthesis = undefined + const originalGlobalSyn = globalThis.speechSynthesis - // Need to reimport to pick up the new window.speechSynthesis value - // For this test, we'll just verify the behavior conceptually - global.window.speechSynthesis = originalSyn + global.window.speechSynthesis = undefined + globalThis.speechSynthesis = undefined - assert.pass('Should handle unavailable speechSynthesis API') - assert.end() + speechSynthesis + .speak({ + id: 'test-10', + message: 'Unavailable test', + }) + .then(() => { + assert.fail('Promise should reject when speechSynthesis is unavailable') + }) + .catch((e) => { + assert.equal(e.error, 'unavailable', 'Should handle unavailable speechSynthesis API') + }) + .finally(() => { + global.window.speechSynthesis = originalSyn + globalThis.speechSynthesis = originalGlobalSyn + assert.end() + }) }) diff --git a/src/platform.js b/src/platform.js index 9a0a493f..402cda32 100644 --- a/src/platform.js +++ b/src/platform.js @@ -15,6 +15,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import speechSynthesis from './announcer/speechSynthesis.js' + const globalScope = globalThis const createKeyboardEventFactory = (KeyboardEventConstructor) => { @@ -73,10 +75,7 @@ const browserPlatform = () => { ? (event) => event instanceof KeyboardEventConstructor : undefined, createKeyboardEvent, - SpeechSynthesisUtterance: - globalScope.SpeechSynthesisUtterance || (windowRef && windowRef.SpeechSynthesisUtterance), - speechSynthesis: - (selfRef && selfRef.speechSynthesis) || (windowRef && windowRef.speechSynthesis), + announcer: speechSynthesis, now: globalScope.performance && globalScope.performance.now !== undefined ? globalScope.performance.now.bind(globalScope.performance) @@ -108,9 +107,7 @@ export const configurePlatform = (customPlatform) => { platform.getCookie = platform.getCookie || basePlatform.getCookie platform.setCookie = platform.setCookie || basePlatform.setCookie platform.historyBack = platform.historyBack || basePlatform.historyBack - platform.SpeechSynthesisUtterance = - platform.SpeechSynthesisUtterance || basePlatform.SpeechSynthesisUtterance - platform.speechSynthesis = platform.speechSynthesis || basePlatform.speechSynthesis + platform.announcer = platform.announcer || basePlatform.announcer platform.now = platform.now || basePlatform.now return platform diff --git a/src/platform.test.js b/src/platform.test.js index 26ef2421..13a5ab8e 100644 --- a/src/platform.test.js +++ b/src/platform.test.js @@ -17,6 +17,7 @@ import test from 'tape' import { configurePlatform, platform as activePlatform } from './platform.js' +import nodePlatform from './testing/nodePlatform.js' test('Platform - configurePlatform merges custom references with browser defaults', (assert) => { const customInput = {} @@ -46,6 +47,31 @@ test('Platform - configurePlatform accepts a callback with browser defaults', (a assert.end() }) +test('Platform - default announcer driver is preserved', (assert) => { + const platform = configurePlatform(() => ({})) + + assert.equal(typeof platform.announcer, 'object', 'default announcer driver is available') + assert.equal(typeof platform.announcer.speak, 'function', 'default announcer can speak') + assert.equal(typeof platform.announcer.cancel, 'function', 'default announcer can cancel') + + configurePlatform(() => ({})) + assert.end() +}) + +test('Platform - node platform provides a noop announcer driver', (assert) => { + const platform = configurePlatform(() => nodePlatform()) + + assert.equal(typeof platform.announcer.speak, 'function', 'node announcer can speak') + assert.equal(typeof platform.announcer.cancel, 'function', 'node announcer can cancel') + + platform.announcer.speak({ message: 'test', id: 1 }).then(() => { + assert.pass('node announcer speak resolves') + platform.announcer.cancel() + configurePlatform(() => ({})) + assert.end() + }) +}) + test('Platform - custom KeyboardEvent constructor is used for keyboard helpers', (assert) => { class CustomKeyboardEvent { constructor(type, init = {}) { diff --git a/src/testing/nodePlatform.js b/src/testing/nodePlatform.js index bfb826d8..b9edf77d 100644 --- a/src/testing/nodePlatform.js +++ b/src/testing/nodePlatform.js @@ -95,19 +95,11 @@ const nodePlatform = () => { KeyboardEvent: TestKeyboardEvent, isKeyboardEvent: (event) => event instanceof TestKeyboardEvent, createKeyboardEvent: (type, init = {}) => new TestKeyboardEvent(type, init), - SpeechSynthesisUtterance: class TestSpeechSynthesisUtterance { - constructor(text = '') { - this.text = text - } - }, - speechSynthesis: { - cancel() {}, - getVoices() { - return [] + announcer: { + speak() { + return Promise.resolve() }, - pause() {}, - resume() {}, - speak() {}, + cancel() {}, }, now: Date.now, } From b86a62a6bd1aecc97f8f751cac5f1da390b37415 Mon Sep 17 00:00:00 2001 From: Suresh Kumar Gangumalla Date: Mon, 29 Jun 2026 18:32:48 +0530 Subject: [PATCH 04/10] Resolved node undefined issues while accessing texture Signed-off-by: Suresh Kumar Gangumalla --- src/engines/L3/element.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/engines/L3/element.js b/src/engines/L3/element.js index 52959a32..09ea15d5 100644 --- a/src/engines/L3/element.js +++ b/src/engines/L3/element.js @@ -203,7 +203,10 @@ const colorMap = { } const setTextureOption = (target, key, value) => { - const opts = target.props['textureOptions'] || target.element.node.textureOptions || {} + const opts = + target.props['textureOptions'] || + (target.element.node && target.element.node.textureOptions) || + {} opts[key] = value target.props['textureOptions'] = opts } From 52297e3e14aa333980c07abc1aa5f376694b8558 Mon Sep 17 00:00:00 2001 From: Michiel van der Geest Date: Tue, 30 Jun 2026 09:12:13 +0200 Subject: [PATCH 05/10] Fixed typo in announcer when interrupted. --- src/announcer/announcer.js | 2 +- src/announcer/announcer.test.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/announcer/announcer.js b/src/announcer/announcer.js index 94bb48b9..cb6f09dd 100644 --- a/src/announcer/announcer.js +++ b/src/announcer/announcer.js @@ -99,7 +99,7 @@ const addToQueue = (message, politeness, delay = false, options = {}) => { if (id === currentId) { cancelDriver() isProcessing = false - resolveFn('interupted') + resolveFn('interrupted') } } diff --git a/src/announcer/announcer.test.js b/src/announcer/announcer.test.js index 842cdd11..e272eeb7 100644 --- a/src/announcer/announcer.test.js +++ b/src/announcer/announcer.test.js @@ -327,7 +327,7 @@ test('Announcer stop uses custom platform announcer driver cancel', (assert) => const announcement = announcer.speak('custom driver stop') announcement.then((status) => { - assert.equal(status, 'interupted', 'announcement resolves as interrupted') + assert.equal(status, 'interrupted', 'announcement resolves as interrupted') assert.equal(cancelCount, 1, 'custom driver cancel is called') configurePlatform(() => ({})) From 613580f5a13c70df0a68c6fdb1763a93a2496b27 Mon Sep 17 00:00:00 2001 From: Michiel van der Geest Date: Tue, 30 Jun 2026 09:52:23 +0200 Subject: [PATCH 06/10] Added a webOS platform specific announcer implementation based on luna. --- docs/essentials/settings.md | 13 +++ package.json | 4 + src/platforms/webOS/announcer.d.ts | 39 ++++++++ src/platforms/webOS/announcer.js | 77 +++++++++++++++ src/platforms/webOS/announcer.test.js | 129 ++++++++++++++++++++++++++ 5 files changed, 262 insertions(+) create mode 100644 src/platforms/webOS/announcer.d.ts create mode 100644 src/platforms/webOS/announcer.js create mode 100644 src/platforms/webOS/announcer.test.js diff --git a/docs/essentials/settings.md b/docs/essentials/settings.md index 2da1a34f..5451e501 100644 --- a/docs/essentials/settings.md +++ b/docs/essentials/settings.md @@ -129,6 +129,19 @@ Blits.Launch(App, 'app', { }) ``` +For LG webOS apps, Blits provides a webOS announcer factory that calls the platform TTS service. + +```js +import createAnnouncer from '@lightningjs/blits/platforms/webOS/announcer' + +Blits.Launch(App, 'app', { + announcer: true, + platform: () => ({ + announcer: createAnnouncer(), + }), +}) +``` + The `rendererPlatform` setting is separate from `platform`. It is only passed to the renderer, and should be used for renderer specific platform configuration. ## Effects & Shaders diff --git a/package.json b/package.json index 8f5203ff..f221407d 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,10 @@ "types": "./src/plugins/storage/storage.d.ts", "default": "./src/plugins/storage/storage.js" }, + "./platforms/webOS/announcer": { + "types": "./src/platforms/webOS/announcer.d.ts", + "default": "./src/platforms/webOS/announcer.js" + }, "./testing": { "types": "./src/testing/index.d.ts", "default": "./src/testing/index.js" diff --git a/src/platforms/webOS/announcer.d.ts b/src/platforms/webOS/announcer.d.ts new file mode 100644 index 00000000..23b69436 --- /dev/null +++ b/src/platforms/webOS/announcer.d.ts @@ -0,0 +1,39 @@ +import type { AnnouncerDriver } from '@lightningjs/blits' + +export interface WebOSRequestOptions { + method: 'speak' + parameters: { + text: string + clear: boolean + } + onFailure(error: any): void +} + +export type WebOSRequest = (uri: string, options: WebOSRequestOptions) => any + +export interface WebOSAnnouncerOptions { + /** + * webOS service request function. Defaults to `webOS.service.request` when available. + */ + request?: WebOSRequest + /** + * webOS TTS service URI. + * + * @default 'luna://com.webos.service.tts' + */ + uri?: string + /** + * Whether the platform TTS queue should be cleared before speaking. + * + * @default true + */ + clear?: boolean + /** + * Optional failure callback for the webOS TTS request. + */ + onFailure?: (error: any) => void +} + +declare function createAnnouncer(options?: WebOSAnnouncerOptions): AnnouncerDriver + +export default createAnnouncer diff --git a/src/platforms/webOS/announcer.js b/src/platforms/webOS/announcer.js new file mode 100644 index 00000000..8130c447 --- /dev/null +++ b/src/platforms/webOS/announcer.js @@ -0,0 +1,77 @@ +/* + * Copyright 2026 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 + */ + +const globalScope = globalThis +const defaultUri = 'luna://com.webos.service.tts' + +const getDefaultRequest = () => { + const windowRef = globalScope.window + const webOS = globalScope.webOS || (windowRef && windowRef.webOS) + + if (webOS && webOS.service && typeof webOS.service.request === 'function') { + return webOS.service.request.bind(webOS.service) + } +} + +const createAnnouncer = (options = {}) => { + const request = options.request || getDefaultRequest() + const uri = options.uri || defaultUri + const clear = options.clear !== false + + let currentRequest + + return { + speak(announcement) { + if (request === undefined) { + return Promise.reject({ error: 'unavailable' }) + } + + if (!announcement || announcement.message === undefined || announcement.message === null) { + return Promise.reject({ error: 'Missing message' }) + } + + try { + currentRequest = request(uri, { + method: 'speak', + parameters: { + text: String(announcement.message), + clear, + }, + onFailure(error) { + currentRequest = undefined + if (options.onFailure) { + options.onFailure(error) + } + }, + }) + } catch (error) { + currentRequest = undefined + return Promise.reject(error) + } + + return Promise.resolve() + }, + cancel() { + if (currentRequest && typeof currentRequest.cancel === 'function') { + currentRequest.cancel() + } + currentRequest = undefined + }, + } +} + +export default createAnnouncer diff --git a/src/platforms/webOS/announcer.test.js b/src/platforms/webOS/announcer.test.js new file mode 100644 index 00000000..b87c2248 --- /dev/null +++ b/src/platforms/webOS/announcer.test.js @@ -0,0 +1,129 @@ +/* + * Copyright 2026 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 createAnnouncer from './announcer.js' + +test('webOS announcer speaks through webOS TTS service', (assert) => { + const calls = [] + const announcer = createAnnouncer({ + request(uri, options) { + calls.push({ uri, options }) + return {} + }, + }) + + announcer + .speak({ id: 1, message: 'Hello webOS' }) + .then(() => { + assert.equal(calls.length, 1, 'one webOS service request is made') + assert.equal(calls[0].uri, 'luna://com.webos.service.tts', 'default TTS service is used') + assert.equal(calls[0].options.method, 'speak', 'speak method is used') + assert.deepEqual( + calls[0].options.parameters, + { text: 'Hello webOS', clear: true }, + 'message text and clear flag are passed' + ) + assert.end() + }) + .catch((e) => { + assert.fail('Should not reject: ' + JSON.stringify(e)) + assert.end() + }) +}) + +test('webOS announcer supports custom URI and clear option', (assert) => { + const calls = [] + const announcer = createAnnouncer({ + uri: 'luna://custom.tts', + clear: false, + request(uri, options) { + calls.push({ uri, options }) + return {} + }, + }) + + announcer + .speak({ id: 2, message: 'Custom' }) + .then(() => { + assert.equal(calls[0].uri, 'luna://custom.tts', 'custom URI is used') + assert.deepEqual( + calls[0].options.parameters, + { text: 'Custom', clear: false }, + 'custom clear option is used' + ) + assert.end() + }) + .catch((e) => { + assert.fail('Should not reject: ' + JSON.stringify(e)) + assert.end() + }) +}) + +test('webOS announcer calls onFailure hook', (assert) => { + const expectedError = { errorText: 'TTS failed' } + const announcer = createAnnouncer({ + request(uri, options) { + options.onFailure(expectedError) + return {} + }, + onFailure(error) { + assert.equal(error, expectedError, 'failure error is forwarded') + assert.end() + }, + }) + + announcer.speak({ id: 3, message: 'Failure' }).catch((e) => { + assert.fail('Should not reject from async onFailure: ' + JSON.stringify(e)) + assert.end() + }) +}) + +test('webOS announcer cancels active request handle', (assert) => { + const handle = { + canceled: false, + cancel() { + this.canceled = true + }, + } + const announcer = createAnnouncer({ + request() { + return handle + }, + }) + + announcer.speak({ id: 4, message: 'Cancel me' }).then(() => { + announcer.cancel() + assert.equal(handle.canceled, true, 'active request is canceled') + assert.end() + }) +}) + +test('webOS announcer rejects as unavailable without webOS request', (assert) => { + const announcer = createAnnouncer() + + announcer + .speak({ id: 5, message: 'Unavailable' }) + .then(() => { + assert.fail('Should reject when webOS request is missing') + assert.end() + }) + .catch((e) => { + assert.equal(e.error, 'unavailable', 'unavailable error is returned') + assert.end() + }) +}) From b4c8c6729a3b22d2ddd3125a96e5d160a6bdb6b3 Mon Sep 17 00:00:00 2001 From: Michiel van der Geest Date: Wed, 1 Jul 2026 12:18:51 +0200 Subject: [PATCH 07/10] Added findByData and findAllByData methods to test-harness. --- docs/testing/test-harness.md | 17 +++++++++++ src/component.js | 7 ++++- src/testing/index.d.ts | 2 ++ src/testing/renderComponent.js | 45 ++++++++++++++++++++++++++++- src/testing/renderComponent.test.js | 33 +++++++++++++++++++++ 5 files changed, 102 insertions(+), 2 deletions(-) diff --git a/docs/testing/test-harness.md b/docs/testing/test-harness.md index 365dc4d3..42f09837 100644 --- a/docs/testing/test-harness.md +++ b/docs/testing/test-harness.md @@ -46,6 +46,23 @@ The snapshot contains: Nested Components are included in the `tree` as Component snapshots as well. +## Finding Nodes by Inspector Data + +Use `findByData()` and `findAllByData()` to find nodes with matching `inspector-data`. + +```xml + + +``` + +```js +const title = fixture.findByData('testId', 'menu-title') +const items = fixture.findAllByData('role', 'menu-item') +``` + +`findByData(key, value)` returns the first matching node or `null`. +`findAllByData(key, value)` returns all matching nodes, or an empty array when nothing matches. + ## Updating Props and State Props can be updated with `setProps()`. diff --git a/src/component.js b/src/component.js index 71fbdd43..60648b20 100644 --- a/src/component.js +++ b/src/component.js @@ -38,6 +38,11 @@ import { plugins } from './plugin.js' // object to store global components let globalComponents +let devMode = false + +export const setDevMode = (enabled) => { + devMode = enabled === true +} const required = (name) => { throw new Error(`Parameter ${name} is required`) @@ -493,7 +498,7 @@ const Component = (name = required('name'), config = required('config')) => { // one time code generation (only if precompilation is turned off) if (config.code === undefined) { Log.debug(`Generating code for ${name} component`) - config.code = codegenerator.call(config, parser(config.template, name)) + config.code = codegenerator.call(config, parser(config.template, name), devMode) } // create an instance of the component, using base as the prototype (which contains Base) diff --git a/src/testing/index.d.ts b/src/testing/index.d.ts index 3f9f9a38..c392e2ad 100644 --- a/src/testing/index.d.ts +++ b/src/testing/index.d.ts @@ -43,6 +43,8 @@ export interface RenderComponentFixture { component: any root: any snapshot(): SnapshotNode | ComponentSnapshotNode + findByData(key: string, value: any): SnapshotNode | ComponentSnapshotNode | null + findAllByData(key: string, value: any): Array setProps(props: Record): void setState(state: Record): void focus(event?: KeyboardEvent): Promise diff --git a/src/testing/renderComponent.js b/src/testing/renderComponent.js index b0b585e5..6f82e726 100644 --- a/src/testing/renderComponent.js +++ b/src/testing/renderComponent.js @@ -17,12 +17,14 @@ import Settings from '../settings.js' import { renderer, stage } from '../launch.js' +import { setDevMode } from '../component.js' import symbols from '../lib/symbols.js' import { initLog } from '../lib/log.js' import { getRaw } from '../lib/reactivity/reactive.js' import Focus from '../focus/focus.js' import { initKeyMap, keyMap } from '../application.js' import { configurePlatform, platform } from '../platform.js' +import { isObjectString, parseToObject } from '../lib/utils.js' import nodePlatform from './nodePlatform.js' let nodeId = 0 @@ -32,6 +34,7 @@ const renderComponent = (Component, options = {}) => { const originalPlatform = platform Settings.set(options.settings || {}) + setDevMode(true) configurePlatform(() => nodePlatform()) initLog() initKeyMap() @@ -58,6 +61,20 @@ const renderComponent = (Component, options = {}) => { return componentSnapshot(component, holderMap) } + const findAllByData = (key, value) => { + const matches = [] + visitSnapshot(snapshot(), (node) => { + if (node.attributes && node.attributes.data && node.attributes.data[key] === value) { + matches.push(node) + } + }) + return matches + } + + const findByData = (key, value) => { + return findAllByData(key, value)[0] || null + } + const setProps = (props = {}) => { const keys = Object.keys(props) for (let i = 0; i < keys.length; i++) { @@ -107,6 +124,8 @@ const renderComponent = (Component, options = {}) => { component, root, snapshot, + findByData, + findAllByData, setProps, setState, focus, @@ -289,10 +308,21 @@ const createElement = ({ parent } = {}) => { } }, set(key, value) { + if (key === 'inspector-data') { + this.setInspectorMetadata(value) + return + } this.attributes[key] = value this.node[key] = value }, - setInspectorMetadata() {}, + setInspectorMetadata(data) { + if (isObjectString(data) === true) { + data = parseToObject(data) + } + if (data === null || typeof data !== 'object') return + this.attributes.data = { ...(this.attributes.data || {}), ...data } + this.node.data = { ...(this.node.data || {}), ...data } + }, destroy() { this.eol = true this.children.length = 0 @@ -314,6 +344,19 @@ const appendToParent = (element, parent) => { } } +const visitSnapshot = (node, visit) => { + if (node === undefined || node === null) return + visit(node) + if (node.type === 'Component') { + visitSnapshot(node.tree, visit) + return + } + const children = node.children || [] + for (let i = 0; i < children.length; i++) { + visitSnapshot(children[i], visit) + } +} + const createKeyboardEvent = (key, init = {}) => { const keyCode = init.keyCode || keyCodeFromKey(key) return platform.createKeyboardEvent('keydown', { diff --git a/src/testing/renderComponent.test.js b/src/testing/renderComponent.test.js index 9a459df8..d5bfb0f1 100644 --- a/src/testing/renderComponent.test.js +++ b/src/testing/renderComponent.test.js @@ -191,6 +191,36 @@ test('renderComponent setState updates state and reactive attributes in snapshot assert.end() }) +test('renderComponent finds nodes by inspector data', (assert) => { + const Menu = Component('InspectableMenu', { + template: ` + + + + + + `, + }) + + const fixture = renderComponent(Menu) + + assert.equal( + fixture.findByData('testId', 'menu-title').attributes.content, + 'Menu', + 'findByData should return the first node with matching inspector data' + ) + assert.equal( + fixture.findAllByData('role', 'menu-item').length, + 2, + 'findAllByData should return all matching nodes' + ) + assert.equal(fixture.findByData('testId', 'missing'), null, 'findByData should return null') + assert.deepEqual(fixture.findAllByData('role', 'missing'), [], 'findAllByData should return []') + + fixture.destroy() + assert.end() +}) + test('renderComponent snapshots nested component attributes and props separately', (assert) => { const Card = Component('Card', { template: ` @@ -263,6 +293,9 @@ test('renderComponent snapshots nested component attributes and props separately attributes: { x: 100, y: 20, + data: { + 'blits-componentType': 'Card', + }, }, props: { title: 'Dune', From e254dafecc44d0f619431c1fdb57ebde771164d9 Mon Sep 17 00:00:00 2001 From: Suresh Kumar Gangumalla Date: Wed, 1 Jul 2026 18:15:49 +0530 Subject: [PATCH 08/10] Updated findByData method to look for only single item Signed-off-by: Suresh Kumar Gangumalla --- src/testing/renderComponent.js | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/testing/renderComponent.js b/src/testing/renderComponent.js index 6f82e726..5ffe39a7 100644 --- a/src/testing/renderComponent.js +++ b/src/testing/renderComponent.js @@ -72,7 +72,14 @@ const renderComponent = (Component, options = {}) => { } const findByData = (key, value) => { - return findAllByData(key, value)[0] || null + let match = null + visitSnapshot(snapshot(), (node) => { + if (node.attributes && node.attributes.data && node.attributes.data[key] === value) { + match = node + return true + } + }) + return match } const setProps = (props = {}) => { @@ -345,16 +352,16 @@ const appendToParent = (element, parent) => { } const visitSnapshot = (node, visit) => { - if (node === undefined || node === null) return - visit(node) + if (node === undefined || node === null) return false + if (visit(node) === true) return true if (node.type === 'Component') { - visitSnapshot(node.tree, visit) - return + return visitSnapshot(node.tree, visit) } const children = node.children || [] for (let i = 0; i < children.length; i++) { - visitSnapshot(children[i], visit) + if (visitSnapshot(children[i], visit) === true) return true } + return false } const createKeyboardEvent = (key, init = {}) => { From 515a37a88d8c84ff3ac750d3c489c028a98d3e06 Mon Sep 17 00:00:00 2001 From: il-sairamg Date: Wed, 1 Jul 2026 18:51:45 +0530 Subject: [PATCH 09/10] Added test verifying frameTick and idle renderer listeners are unregistered on destroy --- src/component.test.js | 51 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/src/component.test.js b/src/component.test.js index 7b93b3cd..43091f63 100644 --- a/src/component.test.js +++ b/src/component.test.js @@ -30,6 +30,9 @@ initLog() if (renderer && !renderer.on) { renderer.on = () => {} } +if (renderer && !renderer.off) { + renderer.off = () => {} +} test('Type', (assert) => { const expected = 'function' @@ -532,6 +535,54 @@ test('Component - Warn when method name matches prop name', (assert) => { assert.end() }) +test('Component - Should unregister renderer hook listeners on destroy', (assert) => { + const offCapture = assert.capture(renderer, 'off', () => {}) + const onCapture = assert.capture(renderer, 'on', () => {}) + const config = { + code: { + render: () => { + return { elms: [{ node: { on: () => {} } }], cleanup: () => {} } + }, + effects: [], + }, + hooks: { + frameTick() {}, + idle() {}, + }, + } + + const holder = { destroy() {} } + const parent = { $focus() {} } + const foo = Component('Foo', config)({}, holder, parent, {}) + const onCalls = onCapture() + + foo.destroy() + + const offCalls = offCapture() + const frameTickCb = onCalls.find((c) => c.args[0] === 'frameTick').args[1] + const idleCb = onCalls.find((c) => c.args[0] === 'idle').args[1] + const activeCb = onCalls.find((c) => c.args[0] === 'active').args[1] + + assert.ok( + offCalls.some((c) => c.args[0] === 'frameTick' && c.args[1] === frameTickCb), + 'frameTick listener should be removed on destroy' + ) + assert.ok( + offCalls.some((c) => c.args[0] === 'idle' && c.args[1] === idleCb), + 'idle listener should be removed on destroy' + ) + assert.ok( + offCalls.some((c) => c.args[0] === 'active' && c.args[1] === activeCb), + 'active listener should be removed on destroy' + ) + assert.equal( + foo[symbols.rendererEventListeners], + null, + 'rendererEventListeners should be cleared' + ) + assert.end() +}) + test('Component - Should register renderer event listeners for hooks', (assert) => { assert.capture(renderer, 'on', () => {}) const config = { From 89801901fe39137b27125551d6d0234178c5cd2a Mon Sep 17 00:00:00 2001 From: Michiel van der Geest Date: Thu, 2 Jul 2026 12:31:23 +0200 Subject: [PATCH 10/10] Bumped version to 2.7.0 and updated changelog. --- CHANGELOG.md | 9 +++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b24440b..47c9d49e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 2.7.0 + +_2 jul 2026_ + +- Added `keepAlive` option to `src`-object to prevent a texture from being cleaned up by the renderer +- Abstracted announcer (and speechSynthesis) to be platform provided (with speechSynthesis as the built-in default) +- Added dedicated findByData methods to testing harness to select specific nodes in the tree +- Improved test cases around frameTick cleanup + ## 2.6.0 _25 jun 2026_ diff --git a/package-lock.json b/package-lock.json index 68bc892f..1a0daa40 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@lightningjs/blits", - "version": "2.6.0", + "version": "2.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@lightningjs/blits", - "version": "2.6.0", + "version": "2.7.0", "license": "Apache-2.0", "dependencies": { "@lightningjs/renderer": "^3.0.6", diff --git a/package.json b/package.json index f221407d..3259cb75 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lightningjs/blits", - "version": "2.6.0", + "version": "2.7.0", "description": "Blits: The Lightning 3 App Development Framework", "bin": "bin/index.js", "exports": {