diff --git a/docs/_sidebar.md b/docs/_sidebar.md
index 89586c92..caa717c9 100644
--- a/docs/_sidebar.md
+++ b/docs/_sidebar.md
@@ -24,6 +24,7 @@
- [Directives](/built-in/directives.md)
- [For loop](/built-in/for-loop.md)
- [Layout](/built-in/layout.md)
+ - [Stats](/built-in/stats.md)
- Router
- [Basics](/router/basics.md)
- [Hooks](/router/hooks.md)
diff --git a/docs/built-in/stats.md b/docs/built-in/stats.md
new file mode 100644
index 00000000..5ec5c6e0
--- /dev/null
+++ b/docs/built-in/stats.md
@@ -0,0 +1,94 @@
+# Blits Stats & Performance Logging
+
+Blits provides a highly-performant stats and system logging mechanism. This feature is **entirely opt-in** and is controlled at build time using a Vite flag. When disabled, all stats code is removed from the final bundle for maximum performance.
+
+Ideally this is only enabled in development or test builds for performance validation.
+
+## Enabling Stats Logging
+
+To enable stats logging, set the `__BLITS_STATS__` flag in your `vite.config.js`:
+
+```js
+import { defineConfig } from 'vite'
+
+export default defineConfig({
+ define: {
+ __BLITS_STATS__: true // Set to false to disable stats (default)
+ },
+ // ...other Vite config options
+})
+```
+
+- When `__BLITS_STATS__` is `true`, Blits will track and periodically log system statistics (components, elements, event listeners, timeouts, intervals, etc.).
+- When `__BLITS_STATS__` is `false` (or not set), **all stats logic and function calls are optimized out** at build time. There is zero runtime cost in production.
+
+## How It Works
+
+- All stats-related calls (e.g., `increment`, `decrement`) are guarded with the `BLITS_STATS_ENABLED` flag, which is exported from `src/lib/stats.js`.
+- Example usage in the codebase:
+
+ ```js
+ import { increment, BLITS_STATS_ENABLED } from './lib/stats.js'
+ // ...
+ BLITS_STATS_ENABLED && increment('components', 'created')
+ ```
+- When the flag is `false`, these calls are removed by dead code elimination during the build.
+
+## No Runtime Settings
+
+There is **no runtime setting** for enabling/disabling stats. The only way to enable stats is at build time using the Vite flag.
+
+## What Gets Logged?
+
+When enabled, Blits will periodically log:
+
+- Number of active, created, and deleted components
+- Number of elements, event listeners, timeouts, and intervals
+- Rolling load averages for each category
+- Renderer memory usage information (when available)
+
+Example log output:
+
+```
+--- System Statistics ---
+Components: Active: 5, Created: 10, Deleted: 5, Load: 0.10, 0.05, 0.01
+Elements: Active: 20, Created: 40, Deleted: 20, Load: 0.20, 0.10, 0.02
+Listeners: Active: 3, Created: 6, Deleted: 3, Load: 0.03, 0.01, 0.00
+Timeouts: Active: 1, Created: 2, Deleted: 1, Load: 0.01, 0.00, 0.00
+Intervals: Active: 2, Created: 4, Deleted: 2, Load: 0.02, 0.01, 0.00
+--- Renderer Memory Info ---
+Memory used: 27.27 Mb, Renderable: 25.62 Mb, Target: 160.00 Mb, Critical: 200.00 Mb
+Textures loaded 6, renderable textures: 3
+-------------------------
+```
+
+## Visual Stats Overlay
+
+Blits includes a built-in component that displays real-time stats in the top left corner of the screen:
+
+```js
+// Import the component
+import { StatsOverlay } from 'blits'
+
+// Add it to your root component
+export default Component('App', {
+ template: `
+
+
+
+
+ `
+})
+```
+
+The `StatsOverlay` component:
+- Only updates when `__BLITS_STATS__` is enabled
+- Shows real-time stats for components, elements, listeners, timeouts and intervals as well as texture and memory usage statistics
+- Displays renderer memory information when available
+- Automatically updates every 500ms
+- Has zero impact when stats are disabled
+
+## Best Practices
+
+- **Enable stats only for development or diagnostics builds.**
+- For production, keep `__BLITS_STATS__` set to `false` for optimal performance.
diff --git a/src/application.js b/src/application.js
index b6e38ac4..fdd11c96 100644
--- a/src/application.js
+++ b/src/application.js
@@ -22,6 +22,8 @@ import Settings from './settings.js'
import symbols from './lib/symbols.js'
import { DEFAULT_HOLD_TIMEOUT_MS } from './constants.js'
+import { BLITS_STATS_ENABLED, resetStats, printStats } from './lib/stats.js'
+
const Application = (config) => {
const defaultKeyMap = {
ArrowLeft: 'left',
@@ -60,6 +62,15 @@ const Application = (config) => {
keyDownHandler = async (e) => {
const key = keyMap[e.key] || keyMap[e.keyCode] || e.key || e.keyCode
+
+ if (BLITS_STATS_ENABLED) {
+ if (key === 'p') {
+ printStats()
+ } else if (key === 'r') {
+ resetStats()
+ }
+ }
+
// intercept key press if specified in main Application component
if (
this[symbols.inputEvents] !== undefined &&
diff --git a/src/component.js b/src/component.js
index 4e468953..d73a96fe 100644
--- a/src/component.js
+++ b/src/component.js
@@ -24,6 +24,7 @@ import { reactive, getRaw } from './lib/reactivity/reactive.js'
import { effect } from './lib/reactivity/effect.js'
import Lifecycle from './lib/lifecycle.js'
import symbols from './lib/symbols.js'
+import { increment, BLITS_STATS_ENABLED } from './lib/stats.js'
import { stage, renderer } from './launch.js'
@@ -212,6 +213,8 @@ const Component = (name = required('name'), config = required('config')) => {
// finaly set the lifecycle state to ready (in the next tick)
setTimeout(() => (this.lifecycle.state = 'ready'))
+ BLITS_STATS_ENABLED && increment('components', 'created')
+
// and return this
return this
}
diff --git a/src/component/base/events.js b/src/component/base/events.js
index fff927f5..62007c21 100644
--- a/src/component/base/events.js
+++ b/src/component/base/events.js
@@ -29,6 +29,8 @@ export default {
},
$listen: {
value: function (event, callback, priority = 0) {
+ // early exit when component is marked as end of life
+ if (this.eol === true) return
eventListeners.registerListener(this, event, callback, priority)
},
writable: false,
diff --git a/src/component/base/methods.js b/src/component/base/methods.js
index 37593fa4..a88f3127 100644
--- a/src/component/base/methods.js
+++ b/src/component/base/methods.js
@@ -21,6 +21,7 @@ import eventListeners from '../../lib/eventListeners.js'
import { trigger } from '../../lib/reactivity/effect.js'
import { Log } from '../../lib/log.js'
import { removeGlobalEffects } from '../../lib/reactivity/effect.js'
+import { BLITS_STATS_ENABLED, decrement } from '../../lib/stats.js'
export default {
focus: {
@@ -52,6 +53,7 @@ export default {
},
destroy: {
value: function () {
+ this.eol = true
this.lifecycle.state = 'destroy'
this.$clearTimeouts()
this.$clearIntervals()
@@ -87,6 +89,7 @@ export default {
delete this[symbols.holder]
Log.debug(`Destroyed component ${this.componentId}`)
+ BLITS_STATS_ENABLED && decrement('components', 'deleted')
},
writable: false,
enumerable: true,
diff --git a/src/component/base/timeouts_intervals.js b/src/component/base/timeouts_intervals.js
index 5c667096..05921ef7 100644
--- a/src/component/base/timeouts_intervals.js
+++ b/src/component/base/timeouts_intervals.js
@@ -16,19 +16,24 @@
*/
import symbols from '../../lib/symbols.js'
+import { increment, decrement, BLITS_STATS_ENABLED } from '../../lib/stats.js'
export default {
$setTimeout: {
value: function (fn, ms, ...params) {
+ // early exit when component is marked as end of life
+ if (this.eol === true) return
const timeoutId = setTimeout(
() => {
this[symbols.timeouts] = this[symbols.timeouts].filter((id) => id !== timeoutId)
+ BLITS_STATS_ENABLED && decrement('timeouts', 'deleted')
fn.apply(null, params)
},
ms,
params
)
this[symbols.timeouts].push(timeoutId)
+ BLITS_STATS_ENABLED && increment('timeouts', 'created')
return timeoutId
},
writable: false,
@@ -40,6 +45,7 @@ export default {
if (this[symbols.timeouts].indexOf(timeoutId) > -1) {
this[symbols.timeouts] = this[symbols.timeouts].filter((id) => id !== timeoutId)
clearTimeout(timeoutId)
+ BLITS_STATS_ENABLED && decrement('timeouts', 'deleted')
}
},
writable: false,
@@ -50,6 +56,7 @@ export default {
value: function () {
for (let i = 0; i < this[symbols.timeouts].length; i++) {
clearTimeout(this[symbols.timeouts][i])
+ BLITS_STATS_ENABLED && decrement('timeouts', 'deleted')
}
this[symbols.timeouts] = []
},
@@ -59,8 +66,11 @@ export default {
},
$setInterval: {
value: function (fn, ms, ...params) {
+ // early exit when component is marked as end of life
+ if (this.eol === true) return
const intervalId = setInterval(() => fn.apply(null, params), ms, params)
this[symbols.intervals].push(intervalId)
+ BLITS_STATS_ENABLED && increment('intervals', 'created')
return intervalId
},
writable: false,
@@ -72,6 +82,7 @@ export default {
if (this[symbols.intervals].indexOf(intervalId) > -1) {
this[symbols.intervals] = this[symbols.intervals].filter((id) => id !== intervalId)
clearInterval(intervalId)
+ BLITS_STATS_ENABLED && decrement('intervals', 'deleted')
}
},
writable: false,
@@ -82,6 +93,7 @@ export default {
value: function () {
for (let i = 0; i < this[symbols.intervals].length; i++) {
clearInterval(this[symbols.intervals][i])
+ BLITS_STATS_ENABLED && decrement('intervals', 'deleted')
}
this[symbols.intervals] = []
},
diff --git a/src/components/StatsOverlay.js b/src/components/StatsOverlay.js
new file mode 100644
index 00000000..d6911b13
--- /dev/null
+++ b/src/components/StatsOverlay.js
@@ -0,0 +1,142 @@
+/*
+ * 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 Component from '../component.js'
+import { BLITS_STATS_ENABLED, getStats, bytesToMb } from '../lib/stats.js'
+import { renderer } from '../launch.js'
+import { Log } from '../lib/log.js'
+
+/**
+ * BlitsStatsOverlay
+ * Renders live system and renderer stats on screen (top left corner)
+ * Only displayed when BLITS_STATS_ENABLED is true
+ */
+export default () =>
+ Component('StatsOverlay', {
+ template: `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `,
+ state() {
+ return {
+ statsEnabled: BLITS_STATS_ENABLED,
+ statsTitle: 'System Statistics',
+ componentStats: 'Components: Loading...',
+ elementStats: 'Elements: Loading...',
+ listenerStats: 'Listeners: Loading...',
+ timeoutStats: 'Timeouts: Loading...',
+ intervalStats: 'Intervals: Loading...',
+
+ hasMemoryInfo: false,
+ memoryTitle: 'Renderer Memory',
+ memoryStats1: '',
+ memoryStats2: '',
+ }
+ },
+ hooks: {
+ ready() {
+ // Only start the interval if stats are enabled
+ if (BLITS_STATS_ENABLED) {
+ Log.info('BlitsStatsOverlay: Starting stats update interval')
+ this._interval = this.$setInterval(() => {
+ this.updateStats()
+ }, 500)
+ }
+ },
+ destroy() {
+ if (this._interval) {
+ this.$clearInterval(this._interval)
+ }
+ },
+ },
+ methods: {
+ updateStats() {
+ // Skip if stats are disabled
+ if (!BLITS_STATS_ENABLED) return
+
+ // Update system stats
+ const components = getStats('components')
+ const elements = getStats('elements')
+ const listeners = getStats('eventListeners')
+ const timeouts = getStats('timeouts')
+ const intervals = getStats('intervals')
+
+ if (components) {
+ this.componentStats = `Components: Active ${components.active} | Created ${components.created} | Deleted ${components.deleted}`
+ }
+
+ if (elements) {
+ this.elementStats = `Elements: Active ${elements.active} | Created ${elements.created} | Deleted ${elements.deleted}`
+ }
+
+ if (listeners) {
+ this.listenerStats = `Listeners: Active ${listeners.active} | Created ${listeners.created} | Deleted ${listeners.deleted}`
+ }
+
+ if (timeouts) {
+ this.timeoutStats = `Timeouts: Active ${timeouts.active} | Created ${timeouts.created} | Deleted ${timeouts.deleted}`
+ }
+
+ if (intervals) {
+ this.intervalStats = `Intervals: Active ${intervals.active} | Created ${intervals.created} | Deleted ${intervals.deleted}`
+ }
+
+ // Update memory info if available
+ const memInfo = renderer?.stage?.txMemManager.getMemoryInfo() || null
+ if (memInfo) {
+ this.hasMemoryInfo = true
+ this.memoryStats1 = `Memory: ${bytesToMb(memInfo.memUsed)}MB | Renderable: ${bytesToMb(
+ memInfo.renderableMemUsed
+ )}MB`
+ this.memoryStats2 = `Target: ${bytesToMb(
+ memInfo.targetThreshold
+ )}MB | Critical: ${bytesToMb(memInfo.criticalThreshold)}MB`
+
+ if (memInfo.loadedTextures !== undefined) {
+ this.memoryStats2 += ` | Textures: ${memInfo.loadedTextures}`
+ }
+ }
+ },
+ },
+ })
diff --git a/src/components/index.js b/src/components/index.js
index 3533f10b..57d89706 100644
--- a/src/components/index.js
+++ b/src/components/index.js
@@ -19,10 +19,12 @@ import Circle from './Circle.js'
import RouterView from './RouterView.js'
import Sprite from './Sprite.js'
import FPScounter from './FPScounter.js'
+import StatsOverlay from './StatsOverlay.js'
export default () => ({
Circle: Circle(),
RouterView: RouterView(),
Sprite: Sprite(),
FPScounter: FPScounter(),
+ StatsOverlay: StatsOverlay(),
})
diff --git a/src/engines/L3/element.js b/src/engines/L3/element.js
index d254d657..ffbfd6b6 100644
--- a/src/engines/L3/element.js
+++ b/src/engines/L3/element.js
@@ -17,6 +17,7 @@
import { renderer } from './launch.js'
import colors from '../../lib/colors/colors.js'
+import { increment, BLITS_STATS_ENABLED, decrement } from '../../lib/stats.js'
import { Log } from '../../lib/log.js'
import symbols from '../../lib/symbols.js'
@@ -505,6 +506,9 @@ const Element = {
this.config.parent.triggerLayout(this.config.parent.props)
})
}
+
+ // Increment element creation
+ BLITS_STATS_ENABLED && increment('elements', 'created')
},
set(prop, value) {
if (value === undefined) return
@@ -637,6 +641,9 @@ const Element = {
// remove node reference
this.node = null
+
+ // Decrement element deletion
+ BLITS_STATS_ENABLED && decrement('elements', 'deleted')
},
get nodeId() {
return this.node && this.node.id
diff --git a/src/launch.js b/src/launch.js
index 26936baf..30436cdd 100644
--- a/src/launch.js
+++ b/src/launch.js
@@ -18,6 +18,7 @@
import Settings from './settings.js'
import { initLog, Log } from './lib/log.js'
import engine from './engine.js'
+import { enableLogging, BLITS_STATS_ENABLED } from './lib/stats.js'
import blitsPackageInfo from '../package.json' assert { type: 'json' }
export let renderer = {}
@@ -42,6 +43,10 @@ export default (App, target, settings) => {
initLog()
+ // Only start logging if BLITS_STATS_ENABLED is true
+ // This code will be optimized out if BLITS_STATS_ENABLED is false
+ BLITS_STATS_ENABLED && enableLogging()
+
rendererVersion().then((v) => {
Log.info('Blits Version ', blitsPackageInfo.version)
Log.info('Renderer Version ', v)
diff --git a/src/lib/eventListeners.js b/src/lib/eventListeners.js
index 6b96f6ff..d739fee1 100644
--- a/src/lib/eventListeners.js
+++ b/src/lib/eventListeners.js
@@ -15,6 +15,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
+import { increment, decrement, BLITS_STATS_ENABLED } from './stats.js'
+
const eventsMap = new Map()
const callbackCache = new Map()
@@ -34,6 +36,9 @@ export default {
components.add({ cb, priority })
callbackCache.delete(event) // Invalidate the callbackCache when a new callback is added
+
+ // Log the event listener creation
+ BLITS_STATS_ENABLED && increment('eventListeners', 'created')
},
deregisterListener(component, event) {
@@ -46,6 +51,9 @@ export default {
componentsMap.delete(component)
eventsMap.set(event, componentsMap)
callbackCache.delete(event)
+
+ // Log the event listener deletion
+ BLITS_STATS_ENABLED && decrement('eventListeners', 'deleted')
}
},
@@ -90,5 +98,7 @@ export default {
}
}
}
+
+ BLITS_STATS_ENABLED && decrement('eventListeners', 'deleted')
},
}
diff --git a/src/lib/stats.js b/src/lib/stats.js
new file mode 100644
index 00000000..bcaeb7c5
--- /dev/null
+++ b/src/lib/stats.js
@@ -0,0 +1,135 @@
+import { Log } from './log.js'
+import { renderer } from '../launch.js'
+
+// Vite flag for enabling stats (should be replaced at build time)
+// Use globalThis to avoid TDZ and reference errors
+// prettier-ignore
+const __BLITS_STATS__ = typeof globalThis.__BLITS_STATS__ !== 'undefined' ? globalThis.__BLITS_STATS__ : false
+
+/**
+ * Blits Stats module to track and report system statistics.
+ * Only enabled if __BLITS_STATS__ is true at build time.
+ */
+const stats = __BLITS_STATS__
+ ? {
+ components: { created: 0, deleted: 0, active: 0 },
+ elements: { created: 0, deleted: 0, active: 0 },
+ eventListeners: { created: 0, deleted: 0, active: 0 },
+ timeouts: { created: 0, deleted: 0, active: 0 },
+ intervals: { created: 0, deleted: 0, active: 0 },
+ }
+ : null
+
+let isLoggingEnabled = false
+
+/**
+ * Increment a specific statistic in the given category.
+ * No-op if stats are not enabled.
+ * @param {string} category - The category to update (e.g., 'components', 'elements', 'eventListeners').
+ * @param {string} type - The type of statistic to update (e.g., 'created', 'deleted').
+ */
+export function increment(category, type) {
+ if (!__BLITS_STATS__ || !isLoggingEnabled) return
+ if (stats[category] && stats[category][type] !== undefined) {
+ stats[category][type]++
+ if (type === 'created') stats[category].active++
+ }
+}
+
+/**
+ * Decrement a specific statistic in the given category.
+ * No-op if stats are not enabled.
+ * @param {string} category - The category to update (e.g., 'components', 'elements', 'eventListeners').
+ * @param {string} type - The type of statistic to update (e.g., 'created', 'deleted').
+ */
+export function decrement(category, type) {
+ if (!__BLITS_STATS__ || !isLoggingEnabled) return
+ if (stats[category] && stats[category][type] !== undefined) {
+ stats[category][type]++
+ if (type === 'deleted') stats[category].active--
+ }
+}
+
+function logStats() {
+ if (!__BLITS_STATS__) return
+ printStats()
+}
+
+const formatStats = (category) => {
+ const { created, deleted, active } = stats[category]
+ return `Active: ${active}, Created: ${created}, Deleted: ${deleted}`
+}
+
+export function printStats() {
+ if (!__BLITS_STATS__) return
+
+ Log.info('------------------------------')
+ Log.info('--- System Statistics ---')
+ Log.info('URL: ', window.location.href)
+ Log.info('Components:', formatStats('components'))
+ Log.info('Elements:', formatStats('elements'))
+ Log.info('Listeners:', formatStats('eventListeners'))
+ Log.info('Timeouts:', formatStats('timeouts'))
+ Log.info('Intervals:', formatStats('intervals'))
+
+ const memInfo = renderer?.stage?.txMemManager.getMemoryInfo() || null
+ if (memInfo) {
+ Log.info('--- Renderer Memory Info ---')
+ Log.info(
+ `Memory used: ${bytesToMb(memInfo.memUsed)} Mb, Renderable: ${bytesToMb(
+ memInfo.renderableMemUsed
+ )} Mb, Target: ${bytesToMb(memInfo.targetThreshold)} Mb, Critical: ${bytesToMb(
+ memInfo.criticalThreshold
+ )} Mb`
+ )
+ Log.info(
+ `Textures loaded ${memInfo.loadedTextures}, renderable textures: ${memInfo.renderableTexturesLoaded}`
+ )
+ }
+}
+
+export function resetStats() {
+ if (!__BLITS_STATS__) return
+ for (const category in stats) {
+ if (Object.prototype.hasOwnProperty.call(stats, category)) {
+ stats[category].created = 0
+ stats[category].deleted = 0
+ stats[category].active = 0
+ }
+ }
+}
+
+/**
+ * Enables logging functionality.
+ */
+export function enableLogging() {
+ if (!__BLITS_STATS__) return
+ if (isLoggingEnabled) return
+ isLoggingEnabled = true
+ logStats()
+}
+
+/**
+ * Get stats for a given category (for UI overlay).
+ * @param {string} category
+ * @returns {object|null}
+ */
+export function getStats(category) {
+ if (!__BLITS_STATS__ || !stats) return null
+ return stats[category] || null
+}
+
+/**
+ * Format bytes to MB (for UI overlay).
+ * @param {number} bytes
+ * @returns {string}
+ */
+export function bytesToMb(bytes) {
+ return (bytes / 1024 / 1024).toFixed(2)
+}
+
+/**
+ * Expose the build-time stats flag for use in other modules.
+ * @type {boolean}
+ */
+export const BLITS_STATS_ENABLED = __BLITS_STATS__