@lightningjs/blits 2.13.0 → 2.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,386 @@
1
+ /*
2
+ * Copyright 2026 Comcast Cable Communications Management, LLC
3
+ * Licensed under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License.
5
+ * You may obtain a copy of the License at
6
+ *
7
+ * http://www.apache.org/licenses/LICENSE-2.0
8
+ *
9
+ * Unless required by applicable law or agreed to in writing, software
10
+ * distributed under the License is distributed on an "AS IS" BASIS,
11
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ * See the License for the specific language governing permissions and
13
+ * limitations under the License.
14
+ *
15
+ * SPDX-License-Identifier: Apache-2.0
16
+ */
17
+
18
+ import { Log } from './log.js'
19
+
20
+ /**
21
+ * @typedef {Object} ProfilerEntry
22
+ * @property {number} calls - Number of times the measurement was recorded
23
+ * @property {number} totalMs - Total time spent, in milliseconds
24
+ * @property {number} maxMs - Slowest single measurement, in milliseconds
25
+ * @property {number} averageMs - Average time per call, in milliseconds
26
+ */
27
+
28
+ const stats = new Map()
29
+ const activeMarks = new Map()
30
+ let enabled = false
31
+ let sessionId = 0
32
+ let activeSpan
33
+ let renderer
34
+ let frameTickHandler
35
+ let frameConfig
36
+ let activeFrame
37
+ let frameHistory = []
38
+
39
+ const hasPerformance = typeof globalThis.performance === 'object'
40
+
41
+ const now = () => (hasPerformance === true ? globalThis.performance.now() : Date.now())
42
+
43
+ const percentile = (values, percent) => {
44
+ if (values.length === 0) return 0
45
+ const index = Math.min(values.length - 1, Math.floor(values.length * percent))
46
+ return values.slice().sort((a, b) => a - b)[index]
47
+ }
48
+
49
+ const frameSummary = (values) => ({
50
+ medianMs: percentile(values, 0.5),
51
+ p95Ms: percentile(values, 0.95),
52
+ p99Ms: percentile(values, 0.99),
53
+ maxMs: values.length === 0 ? 0 : Math.max(...values),
54
+ })
55
+
56
+ const clearFrames = () => {
57
+ activeFrame = undefined
58
+ frameHistory = []
59
+ }
60
+
61
+ const finishFrame = (data) => {
62
+ if (activeFrame === undefined || activeFrame.sessionId !== sessionId) return
63
+ activeFrame.intervalMs = typeof data.delta === 'number' ? data.delta : 0
64
+ frameHistory.push(activeFrame)
65
+ if (frameHistory.length > frameConfig.maxFrames) frameHistory.shift()
66
+ }
67
+
68
+ const beginFrame = (data) => {
69
+ finishFrame(data)
70
+ activeFrame = {
71
+ sessionId,
72
+ intervalMs: 0,
73
+ totalMs: 0,
74
+ labels: new Map(),
75
+ }
76
+ }
77
+
78
+ const onFrameTick = (...args) => {
79
+ if (enabled !== true || frameConfig === undefined) return
80
+ const data = args[args.length - 1]
81
+ if (!data || typeof data.time !== 'number') return
82
+ beginFrame(data)
83
+ }
84
+
85
+ /**
86
+ * Attaches optional frame collection to a renderer that emits `frameTick`.
87
+ * @param {{on?: Function, off?: Function}} nextRenderer RendererMain instance
88
+ * @returns {void}
89
+ */
90
+ export const setProfilerRenderer = (nextRenderer) => {
91
+ if (renderer === nextRenderer) return
92
+ if (renderer && frameTickHandler && typeof renderer.off === 'function') {
93
+ renderer.off('frameTick', frameTickHandler)
94
+ }
95
+ renderer = nextRenderer
96
+ frameTickHandler = undefined
97
+ if (renderer && typeof renderer.on === 'function') {
98
+ frameTickHandler = onFrameTick
99
+ renderer.on('frameTick', frameTickHandler)
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Marks the start of a measurement.
105
+ *
106
+ * Returns `0` when the profiler is disabled, which keeps the overhead
107
+ * down to a single boolean check.
108
+ *
109
+ * @returns {{sessionId: number, startTime: number} | 0} Token to pass into {@link profileEnd}
110
+ */
111
+ export const profileBegin = () => {
112
+ if (enabled !== true) return 0
113
+ const token = {
114
+ sessionId,
115
+ startTime: now(),
116
+ parent: activeSpan,
117
+ frame: activeFrame,
118
+ }
119
+ activeSpan = token
120
+ return token
121
+ }
122
+
123
+ /**
124
+ * Records a measurement started with {@link profileBegin}.
125
+ *
126
+ * @param {string} name - Label the measurement is aggregated under
127
+ * @param {{sessionId: number, startTime: number} | 0} start - Token returned by {@link profileBegin}
128
+ * @returns {void}
129
+ */
130
+ export const profileEnd = (name, start) => {
131
+ if (enabled !== true || !start || start.sessionId !== sessionId) return
132
+
133
+ let entry = stats.get(name)
134
+ if (entry === undefined) {
135
+ entry = { calls: 0, totalMs: 0, maxMs: 0 }
136
+ stats.set(name, entry)
137
+ }
138
+
139
+ const elapsed = now() - start.startTime
140
+ start.closed = true
141
+ entry.calls++
142
+ entry.totalMs += elapsed
143
+ if (elapsed > entry.maxMs) entry.maxMs = elapsed
144
+
145
+ if (start.frame && start.frame.sessionId === sessionId) {
146
+ let frameEntry = start.frame.labels.get(name)
147
+ if (frameEntry === undefined) {
148
+ frameEntry = { calls: 0, totalMs: 0 }
149
+ start.frame.labels.set(name, frameEntry)
150
+ }
151
+ frameEntry.calls++
152
+ frameEntry.totalMs += elapsed
153
+ if (start.parent === undefined || start.parent.frame !== start.frame)
154
+ start.frame.totalMs += elapsed
155
+ }
156
+
157
+ if (activeSpan === start) {
158
+ activeSpan = start.parent
159
+ while (activeSpan !== undefined && activeSpan.closed === true) activeSpan = activeSpan.parent
160
+ }
161
+ }
162
+
163
+ const mark = (name) => {
164
+ if (enabled !== true) return
165
+
166
+ let marks = activeMarks.get(name)
167
+ if (marks === undefined) {
168
+ marks = []
169
+ activeMarks.set(name, marks)
170
+ }
171
+ marks.push(profileBegin())
172
+ }
173
+
174
+ const markEnd = (name) => {
175
+ if (enabled !== true) return
176
+
177
+ const marks = activeMarks.get(name)
178
+ if (marks === undefined) return
179
+
180
+ const start = marks.pop()
181
+ if (marks.length === 0) activeMarks.delete(name)
182
+ profileEnd(name, start)
183
+ }
184
+
185
+ /**
186
+ * Measures the execution time of a (synchronous) function.
187
+ *
188
+ * @template T
189
+ * @param {string} name - Label the measurement is aggregated under
190
+ * @param {() => T} fn - Function to measure
191
+ * @returns {T} The return value of `fn`
192
+ */
193
+ export const profile = (name, fn) => {
194
+ if (enabled !== true) return fn()
195
+ const start = profileBegin()
196
+ try {
197
+ return fn()
198
+ } finally {
199
+ profileEnd(name, start)
200
+ }
201
+ }
202
+
203
+ const measurementsSnapshot = () =>
204
+ Object.fromEntries(
205
+ [...stats].map(([name, entry]) => [name, { ...entry, averageMs: entry.totalMs / entry.calls }])
206
+ )
207
+
208
+ const frameSnapshot = () => {
209
+ const config = frameConfig || { maxFrames: 0, targetFrameMs: 1000 / 60 }
210
+ const intervals = frameHistory.map((frame) => frame.intervalMs)
211
+ const instrumented = frameHistory.map((frame) => frame.totalMs)
212
+ const labels = new Map()
213
+ for (const frame of frameHistory) {
214
+ for (const [name, entry] of frame.labels) {
215
+ let total = labels.get(name)
216
+ if (total === undefined) {
217
+ total = { calls: 0, totalMs: 0 }
218
+ labels.set(name, total)
219
+ }
220
+ total.calls += entry.calls
221
+ total.totalMs += entry.totalMs
222
+ }
223
+ }
224
+ const frameCount = frameHistory.length
225
+ const slowFrames = intervals.filter((value) => value > config.targetFrameMs * 1.5).length
226
+ const slowestFrames = frameHistory
227
+ .slice()
228
+ .sort((a, b) => b.totalMs - a.totalMs)
229
+ .slice(0, 10)
230
+ .map((frame) => ({
231
+ intervalMs: frame.intervalMs,
232
+ instrumentedMs: frame.totalMs,
233
+ labels: Object.fromEntries(frame.labels),
234
+ }))
235
+ return {
236
+ frameCount,
237
+ retainedFrameLimit: config.maxFrames,
238
+ frameInterval: frameSummary(intervals),
239
+ instrumentedTime: frameSummary(instrumented),
240
+ slowFramePercent: frameCount === 0 ? 0 : (slowFrames / frameCount) * 100,
241
+ slowestFrames,
242
+ labels: Object.fromEntries(
243
+ [...labels].map(([name, entry]) => [
244
+ name,
245
+ {
246
+ ...entry,
247
+ callsPerFrame: frameCount === 0 ? 0 : entry.calls / frameCount,
248
+ averageMsPerFrame: frameCount === 0 ? 0 : entry.totalMs / frameCount,
249
+ },
250
+ ])
251
+ ),
252
+ }
253
+ }
254
+
255
+ const snapshot = () => ({
256
+ measurements: measurementsSnapshot(),
257
+ frames: frameSnapshot(),
258
+ })
259
+
260
+ const report = () => {
261
+ const result = snapshot()
262
+ const measurements = result.measurements
263
+ const frameLabels = result.frames.labels
264
+ const slowFrames = Object.fromEntries(
265
+ result.frames.slowestFrames.map((frame, index) => [
266
+ `#${index + 1}`,
267
+ {
268
+ intervalMs: frame.intervalMs,
269
+ instrumentedMs: frame.instrumentedMs,
270
+ labels: Object.entries(frame.labels)
271
+ .sort(([, a], [, b]) => b.totalMs - a.totalMs)
272
+ .map(
273
+ ([name, entry]) =>
274
+ `${name}: ${entry.totalMs.toFixed(2)} ms (${entry.calls} ${
275
+ entry.calls === 1 ? 'call' : 'calls'
276
+ })`
277
+ )
278
+ .join(' | '),
279
+ },
280
+ ])
281
+ )
282
+
283
+ Log.info('Profiler summary')
284
+ console.table({
285
+ 'Measurements tracked': Object.keys(measurements).length,
286
+ 'Frames analyzed': result.frames.frameCount,
287
+ 'Median frame interval (ms)': result.frames.frameInterval.medianMs,
288
+ 'p95 frame interval (ms)': result.frames.frameInterval.p95Ms,
289
+ 'Median instrumented time (ms)': result.frames.instrumentedTime.medianMs,
290
+ 'p95 instrumented time (ms)': result.frames.instrumentedTime.p95Ms,
291
+ 'Slow frames (%)': result.frames.slowFramePercent,
292
+ })
293
+ if (Object.keys(measurements).length) {
294
+ Log.info('Profiler: all marker timings')
295
+ console.table(measurements)
296
+ }
297
+ if (Object.keys(frameLabels).length) {
298
+ Log.info('Profiler: marker timings within retained frames')
299
+ console.table(frameLabels)
300
+ }
301
+ if (Object.keys(slowFrames).length) {
302
+ Log.info('Profiler: most expensive retained frames')
303
+ console.table(slowFrames)
304
+ }
305
+ return result
306
+ }
307
+
308
+ /**
309
+ * Lightweight, opt-in profiler used to measure performance bottlenecks
310
+ * in Blits internals and in App code.
311
+ */
312
+ const profiler = {
313
+ /**
314
+ * Clears previously collected measurements and starts recording
315
+ * @returns {void}
316
+ */
317
+ start(options = {}) {
318
+ sessionId++
319
+ stats.clear()
320
+ activeMarks.clear()
321
+ activeSpan = undefined
322
+ frameConfig =
323
+ options.frames !== false
324
+ ? {
325
+ maxFrames: options.maxFrames || 300,
326
+ targetFrameMs: 1000 / (options.targetFps || 60),
327
+ }
328
+ : undefined
329
+ clearFrames()
330
+ enabled = true
331
+ },
332
+ /**
333
+ * Marks the start of a measurement
334
+ * @param {string} name - Label the measurement is aggregated under
335
+ * @returns {void}
336
+ */
337
+ mark,
338
+ /**
339
+ * Ends the most recent measurement started with the same name
340
+ * @param {string} name - Label passed to {@link mark}
341
+ * @returns {void}
342
+ */
343
+ markEnd,
344
+ /**
345
+ * Stops recording and returns the collected measurements
346
+ * @returns {Object.<string, ProfilerEntry>}
347
+ */
348
+ stop() {
349
+ enabled = false
350
+ activeMarks.clear()
351
+ activeSpan = undefined
352
+ return snapshot()
353
+ },
354
+ /**
355
+ * Clears collected measurements and pending spans, without changing the enabled state
356
+ * @returns {void}
357
+ */
358
+ reset() {
359
+ sessionId++
360
+ stats.clear()
361
+ activeMarks.clear()
362
+ activeSpan = undefined
363
+ clearFrames()
364
+ },
365
+ /**
366
+ * Returns the measurements collected so far
367
+ * @returns {Object.<string, ProfilerEntry>}
368
+ */
369
+ snapshot,
370
+ report,
371
+ setRenderer: setProfilerRenderer,
372
+ /**
373
+ * Returns frame-oriented measurements collected by default or with start({ frames: true }).
374
+ * @returns {object}
375
+ */
376
+ frameSnapshot,
377
+ /**
378
+ * Whether the profiler is currently recording
379
+ * @returns {boolean}
380
+ */
381
+ get enabled() {
382
+ return enabled
383
+ },
384
+ }
385
+
386
+ export default profiler
@@ -590,9 +590,22 @@ export const getSingleRegisteredRouterView = () => {
590
590
  return singleRouterView
591
591
  }
592
592
 
593
- export const back = function () {
593
+ export const back = function (steps = 1) {
594
594
  if (this.history === undefined) return
595
+ if (steps < 1) steps = 1
596
+ if (steps > this.history.length) steps = this.history.length
595
597
  const viewState = getViewState(this.name)
598
+
599
+ // discard the intermediate history entries when navigating back more than 1 step
600
+ for (let i = 1; i < steps; i++) {
601
+ const poppedRoute = this.history.pop()
602
+ // if skipped history item is configured as keepAlive true then invoke destroy on view
603
+ if (poppedRoute.view !== undefined && poppedRoute.view !== null) {
604
+ poppedRoute.view.destroy()
605
+ poppedRoute.focus = null
606
+ }
607
+ }
608
+
596
609
  const route = this.history.pop()
597
610
  if (route && this.currentRoute !== route) {
598
611
  // set indicator that we are navigating back (to prevent adding page to history stack)
package/src/settings.js CHANGED
@@ -19,6 +19,8 @@ import symbols from './lib/symbols.js'
19
19
 
20
20
  const settings = {
21
21
  [symbols.settings]: {},
22
+ // Reacts to Blits-only settings changed at runtime via `this.$settings.set()`.
23
+ onRuntimeSettingChange: null,
22
24
  get(key, defaultValue = null) {
23
25
  if (key in this[symbols.settings]) {
24
26
  return this[symbols.settings][key]
@@ -33,6 +35,9 @@ const settings = {
33
35
  })
34
36
  } else {
35
37
  this[symbols.settings][key] = value
38
+ if (typeof this.onRuntimeSettingChange === 'function') {
39
+ this.onRuntimeSettingChange(key, value)
40
+ }
36
41
  }
37
42
  },
38
43
  }