| void;
}
@@ -681,6 +691,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
& (
@@ -781,6 +810,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/package-lock.json b/package-lock.json
index 722c411d..36e1affe 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,16 +1,16 @@
{
"name": "@lightningjs/blits",
- "version": "1.45.2",
+ "version": "1.46.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@lightningjs/blits",
- "version": "1.45.2",
+ "version": "1.46.0",
"license": "Apache-2.0",
"dependencies": {
"@lightningjs/msdf-generator": "^1.2.0",
- "@lightningjs/renderer": "^2.20.2",
+ "@lightningjs/renderer": "^2.20.4",
"magic-string": "^0.30.21"
},
"bin": {
@@ -1179,9 +1179,9 @@
}
},
"node_modules/@lightningjs/renderer": {
- "version": "2.20.2",
- "resolved": "https://registry.npmjs.org/@lightningjs/renderer/-/renderer-2.20.2.tgz",
- "integrity": "sha512-9/FBimIzofhkz5g2BRSoMjsfN65dAQqe0owAuiLelc95L9B9wy6bsxDLmfscOoJweCFfy9keDd8GVhEA9HYq2A==",
+ "version": "2.20.4",
+ "resolved": "https://registry.npmjs.org/@lightningjs/renderer/-/renderer-2.20.4.tgz",
+ "integrity": "sha512-s3UWZukqMW5NfUJbfqesBWSTaUXcQcq+zd9tyhVvliIKAgeO1c/ITROgCDytrKw61GUTYUQ+JOpDoIMv/BISmg==",
"hasInstallScript": true,
"engines": {
"node": ">= 20.9.0",
diff --git a/package.json b/package.json
index 970bd43d..8d937d0e 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@lightningjs/blits",
- "version": "1.45.2",
+ "version": "1.46.0",
"description": "Blits: The Lightning 3 App Development Framework",
"bin": "bin/index.js",
"exports": {
@@ -74,7 +74,7 @@
},
"dependencies": {
"@lightningjs/msdf-generator": "^1.2.0",
- "@lightningjs/renderer": "^2.20.2",
+ "@lightningjs/renderer": "^2.20.4",
"magic-string": "^0.30.21"
},
"repository": {
diff --git a/scripts/prepublishOnly.js b/scripts/prepublishOnly.js
index f3d45c1e..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'
@@ -84,4 +85,9 @@ function formatFileWithESLint(filePath) {
})
}
-precompileComponents()
+// Only run if this file is executed directly (not imported)
+if (fileURLToPath(import.meta.url) === process.argv[1]) {
+ 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,
})
diff --git a/src/announcer/announcer.js b/src/announcer/announcer.js
index 11d2f26f..81d113ba 100644
--- a/src/announcer/announcer.js
+++ b/src/announcer/announcer.js
@@ -26,7 +26,9 @@ let currentId = null
let debounce = null
// Global default utterance options
-let globalDefaultOptions = {}
+let globalDefaultOptions = {
+ enableUtteranceKeepAlive: !/android/i.test((window.navigator || {}).userAgent || ''),
+}
const noopAnnouncement = {
then() {},
@@ -50,7 +52,6 @@ const toggle = (v) => {
const speak = (message, politeness = 'off', options = {}) => {
if (active === false) return noopAnnouncement
-
return addToQueue(message, politeness, false, options)
}
@@ -106,6 +107,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 +116,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,19 +143,21 @@ 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)
processQueue()
})
debounce = null
- }, 200)
+ }, 300)
}
}
@@ -158,13 +165,52 @@ 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 = () => {
+ // Clear debounce timer if speech hasn't started yet
+ clearDebounceTimer()
+
+ // Always cancel speech synthesis to ensure clean state
speechSynthesis.cancel()
+
+ // Store resolve function before resetting state
+ const resolveFn = currentResolveFn
+
+ // Reset state
+ currentId = null
+ currentResolveFn = null
+ isProcessing = false
+
+ // Resolve promise if there was an active utterance
+ if (resolveFn !== null) {
+ resolveFn('interrupted')
+ }
}
const clear = () => {
+ // 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')
+ }
+ }
+
+ // Reset state
+ currentId = null
+ currentResolveFn = null
isProcessing = false
- queue.length = 0
}
const configure = (options = {}) => {
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()
diff --git a/src/announcer/speechSynthesis.js b/src/announcer/speechSynthesis.js
index 5c454ba4..00d8c2df 100644
--- a/src/announcer/speechSynthesis.js
+++ b/src/announcer/speechSynthesis.js
@@ -19,33 +19,61 @@ import { Log } from '../lib/log.js'
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) {
- clearTimeout(infinityTimer)
- infinityTimer = null
+const clear = (id) => {
+ const state = utterances.get(id)
+ if (state === undefined) {
+ return
+ }
+ if (state.timer !== null) {
+ clearTimeout(state.timer)
+ state.timer = null
}
+ utterances.delete(id)
}
-const resumeInfinity = (target) => {
- if (!target || infinityTimer) {
- return clear()
+const startKeepAlive = (id) => {
+ const state = utterances.get(id)
+
+ // utterance status: utterance was removed (cancelled or finished)
+ if (state == undefined) {
+ 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)
+ return
}
syn.pause()
setTimeout(() => {
- 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)
- }, 5000)
+ // Check if utterance still exists before scheduling next cycle
+ if (utterances.has(id) === true) {
+ state.timer = setTimeout(() => {
+ // Double-check utterance still exists before resuming
+ if (utterances.has(id) === true) {
+ startKeepAlive(id)
+ }
+ }, 5000)
+ }
}
const defaultUtteranceProps = {
@@ -57,45 +85,124 @@ const defaultUtteranceProps = {
}
const initialize = () => {
+ // syn api check: syn might not have getVoices method
+ if (!syn || typeof syn.getVoices !== 'function') {
+ initialized = false
+ return
+ }
+
const voices = syn.getVoices()
defaultUtteranceProps.voice = voices[0] || null
initialized = true
}
-const speak = (options) => {
- const utterance = new SpeechSynthesisUtterance(options.message)
+const waitForSynthReady = (timeoutMs = 2000, checkIntervalMs = 100) => {
+ return new Promise((resolve) => {
+ if (!syn) {
+ Log.debug('SpeechSynthesis - syn unavailable')
+ resolve()
+ return
+ }
+
+ if (!syn.speaking && !syn.pending) {
+ Log.debug('SpeechSynthesis - ready immediately')
+ resolve()
+ return
+ }
+
+ Log.debug('SpeechSynthesis - waiting for ready state...')
+
+ const startTime = Date.now()
+
+ const intervalId = setInterval(() => {
+ const elapsed = Date.now() - startTime
+ const isReady = !syn.speaking && !syn.pending
+
+ if (isReady) {
+ Log.debug(`SpeechSynthesis - ready after ${elapsed}ms`)
+ clearInterval(intervalId)
+ resolve()
+ } else if (elapsed >= timeoutMs) {
+ Log.debug(`SpeechSynthesis - timeout after ${elapsed}ms, forcing ready`, {
+ speaking: syn.speaking,
+ pending: syn.pending,
+ })
+ clearInterval(intervalId)
+ resolve()
+ }
+ }, checkIntervalMs)
+ })
+}
+
+const speak = async (options) => {
+ // 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)
+ }
+
+ // 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
utterance.rate = options.rate || defaultUtteranceProps.rate
utterance.voice = options.voice || defaultUtteranceProps.voice
utterance.volume = options.volume || defaultUtteranceProps.volume
- utterances.set(id, utterance) // Strong reference
-
- if (isAndroid === false) {
- utterance.onstart = () => {
- resumeInfinity(utterance)
- }
- utterance.onresume = () => {
- resumeInfinity(utterance)
- }
- }
+ utterances.set(id, { utterance, timer: null, ignoreResume: false })
return new Promise((resolve, reject) => {
- utterance.onend = () => {
- clear()
- utterances.delete(id) // Cleanup
- resolve()
+ utterance.onend = (result) => {
+ clear(id)
+ resolve(result)
}
utterance.onerror = (e) => {
- clear()
- utterances.delete(id) // Cleanup
- reject(e)
+ Log.warn('SpeechSynthesisUtterance error:', e)
+ clear(id)
+ resolve()
}
- syn.speak(utterance)
+ if (options.enableUtteranceKeepAlive === true) {
+ utterance.onstart = () => {
+ // utterances status: check if utterance still exists
+ if (utterances.has(id)) {
+ startKeepAlive(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
+ }
+
+ startKeepAlive(id)
+ }
+ }
+ // handle error: syn.speak might throw
+ try {
+ syn.speak(utterance)
+ } catch (error) {
+ clear(id)
+ reject(error)
+ }
})
}
@@ -113,8 +220,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
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/router.js b/src/component/base/router.js
index 9fe021bd..3d8d59df 100644
--- a/src/component/base/router.js
+++ b/src/component/base/router.js
@@ -23,6 +23,12 @@ export default {
value: {
to,
back,
+ get backNavigation() {
+ return state.backNavigation !== false
+ },
+ set backNavigation(enabled) {
+ state.backNavigation = enabled !== false
+ },
get currentRoute() {
return currentRoute
},
diff --git a/src/component/base/timeouts_intervals.js b/src/component/base/timeouts_intervals.js
index b77e612e..dabfc275 100644
--- a/src/component/base/timeouts_intervals.js
+++ b/src/component/base/timeouts_intervals.js
@@ -96,4 +96,60 @@ export default {
enumerable: true,
configurable: false,
},
+ $debounce: {
+ 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 name if it exists
+ const existing = this[symbols.debounces].get(name)
+ if (existing !== undefined) {
+ this.$clearTimeout(existing)
+ this[symbols.debounces].delete(name)
+ }
+
+ // create new timeout
+ const timeoutId = setTimeout(() => {
+ this[symbols.debounces].delete(name)
+ this.$clearTimeout(timeoutId)
+ fn.apply(this, params)
+ }, ms)
+
+ // track timeout in timeouts array for automatic cleanup
+ this[symbols.timeouts].push(timeoutId)
+
+ // store timeoutId per name to enable replace behavior and lifecycle cleanup
+ this[symbols.debounces].set(name, timeoutId)
+
+ return timeoutId
+ },
+ writable: false,
+ enumerable: true,
+ configurable: false,
+ },
+ $clearDebounce: {
+ value: function (name) {
+ const existing = this[symbols.debounces].get(name)
+ if (existing !== undefined) {
+ this.$clearTimeout(existing)
+ this[symbols.debounces].delete(name)
+ }
+ },
+ writable: false,
+ enumerable: true,
+ configurable: false,
+ },
+ $clearDebounces: {
+ value: function () {
+ // clear all timeouts associated with debounces
+ const timeoutIds = Array.from(this[symbols.debounces].values())
+ for (let i = 0; i < timeoutIds.length; i++) {
+ this.$clearTimeout(timeoutIds[i])
+ }
+ this[symbols.debounces].clear()
+ },
+ writable: false,
+ enumerable: true,
+ configurable: false,
+ },
}
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 f6a3d97a..eb06c58b 100644
--- a/src/components/RouterView.js
+++ b/src/components/RouterView.js
@@ -16,46 +16,55 @@
*/
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.backNavigation === false) {
+ this.parent.$input(e)
+ return
+ }
+ const navigating = Router.back.call(this)
+ if (navigating === false) {
+ this.parent.$focus(e)
+ }
+ },
},
- },
- })
+ })
+ )
diff --git a/src/engines/L3/element.js b/src/engines/L3/element.js
index 1908af51..c5717acd 100644
--- a/src/engines/L3/element.js
+++ b/src/engines/L3/element.js
@@ -243,15 +243,21 @@ const propsTransformer = {
},
set w(v) {
this.props['width'] = parsePercentage.call(this, v, 'width')
+ if (this.___wrapper === true && this.element.component[symbols.holder] !== undefined) {
+ this.element.component[symbols.holder].set('w', this.props['width'])
+ }
},
set width(v) {
- this.props['width'] = parsePercentage.call(this, v, 'width')
+ this.w = v
},
set h(v) {
this.props['height'] = parsePercentage.call(this, v, 'height')
+ if (this.___wrapper === true && this.element.component[symbols.holder] !== undefined) {
+ this.element.component[symbols.holder].set('h', this.props['height'])
+ }
},
set height(v) {
- this.props['height'] = parsePercentage.call(this, v, 'height')
+ this.h = v
},
set x(v) {
this.props['x'] = parsePercentage.call(this, v, 'width')
@@ -564,6 +570,39 @@ 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 !== true) {
+ 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 +690,11 @@ const Element = {
f,
}
+ // Update inspector metadata when transition starts
+ if (inspectorEnabled === true) {
+ 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 +729,12 @@ const Element = {
}
// remove the prop from scheduled transitions
delete this.scheduledTransitions[prop]
+ // Update inspector metadata when transition ends
+ if (inspectorEnabled === true) {
+ 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..41d24fb9 100644
--- a/src/lib/codegenerator/generator.js
+++ b/src/lib/codegenerator/generator.js
@@ -151,6 +151,10 @@ const generateElementCode = function (
renderCode.push(`elementConfigs[${counter}] = {}`)
+ if (counter === 0) {
+ renderCode.push(`elementConfigs[${counter}]['___wrapper'] = true `)
+ }
+
if (options.forloop) {
renderCode.push(`if(${elm} === undefined) {`)
}
@@ -283,9 +287,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++
@@ -354,6 +360,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/lifecycle.js b/src/lib/lifecycle.js
index 43b56d02..0549f6aa 100644
--- a/src/lib/lifecycle.js
+++ b/src/lib/lifecycle.js
@@ -18,6 +18,9 @@
import { Log } from './log.js'
import { emit, privateEmit } from './hooks.js'
import symbols from './symbols.js'
+import Settings from '../settings.js'
+
+let inspectorEnabled = null
/**
* List of valid lifecycle states for a component.
@@ -82,8 +85,31 @@ 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' || v === 'unfocus') {
+ if (inspectorEnabled === null) {
+ inspectorEnabled = Settings.get('inspector', false)
+ }
+ }
+ if (v === 'focus') {
+ this.component[symbols.state].hasFocus = true
+ if (
+ inspectorEnabled === true &&
+ 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
+ if (
+ inspectorEnabled === true &&
+ this.component[symbols.holder] &&
+ typeof this.component[symbols.holder].setInspectorMetadata === 'function'
+ ) {
+ this.component[symbols.holder].setInspectorMetadata({ $hasFocus: 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'),
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
diff --git a/src/router/router.js b/src/router/router.js
index ec639eef..1da10871 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: '',
+ backNavigation: true,
},
Settings.get('reactivityMode'),
true
@@ -335,6 +339,7 @@ export const navigate = async function () {
return
}
}
+
// 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 (
@@ -354,6 +359,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
@@ -476,15 +482,17 @@ 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)
+ await removeView(previousRoute, oldView, route.transition.out, navigatingBack)
}
}
@@ -492,7 +500,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)
}
@@ -500,6 +508,33 @@ 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 {
+ await hooks.afterEach.call(
+ this.parent,
+ route, // to
+ previousRoute // from
+ )
+ } catch (error) {
+ Log.error('Error in "AfterEach" Hook', error)
+ }
+ }
+ }
+
+ if (route.hooks.after) {
+ try {
+ await route.hooks.after.call(
+ this.parent,
+ route, // to
+ previousRoute // from
+ )
+ } 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]