@lightningjs/blits 2.15.0 → 2.16.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ # 2.16.0
4
+
5
+ _4 nov 2026_
6
+
7
+
8
+ - Improved benchmark results using 95 student t confidendence interval
9
+ - Fixed using a computed prop as a reactive source of a for-loop
10
+ - Improved setup of automated tests
11
+ - Added caching mechanism to computed props
12
+ - Bumped L3 renderer to v3.11.0
13
+
14
+
3
15
  # 2.15.0
4
16
 
5
17
  _26 oct 2026_
package/eslint.config.cjs CHANGED
@@ -38,6 +38,8 @@ module.exports = defineConfig([
38
38
  ...globals.node,
39
39
  globalThis: false,
40
40
  __DEV__: false,
41
+ __BLITS_VERSION__: false,
42
+ __RENDERER_VERSION__: false,
41
43
  },
42
44
 
43
45
  parser: babelParser,
package/index.d.ts CHANGED
@@ -987,7 +987,10 @@ export type Route = {
987
987
  }
988
988
  }
989
989
 
990
- type ComponentFactory = () => void
990
+ type ComponentFactory = (() => void) & {
991
+ needsInteractive?: boolean
992
+ prepare?: () => void
993
+ }
991
994
 
992
995
  // Launch Related
993
996
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightningjs/blits",
3
- "version": "2.15.0",
3
+ "version": "2.16.0",
4
4
  "description": "Blits: The Lightning 3 App Development Framework",
5
5
  "bin": "bin/index.js",
6
6
  "exports": {
@@ -58,8 +58,8 @@
58
58
  },
59
59
  "scripts": {
60
60
  "test": "c8 npm run test:run | tap-diff",
61
- "test:run": "node -r global-jsdom/register node_modules/tape/bin/tape \"scripts/**/*.test.js\" \"src/**/*.test.js\"",
62
- "test:run:failures": "node -r global-jsdom/register node_modules/tape/bin/tape \"scripts/**/*.test.js\" \"src/**/*.test.js\" | tap-filter test-fail | tap-diff",
61
+ "test:run": "node -r global-jsdom/register node_modules/tape/bin/tape \"scripts/**/*.test.js\" \"src/**/*.test.js\" \"vite/**/*.test.js\"",
62
+ "test:run:failures": "node -r global-jsdom/register node_modules/tape/bin/tape \"scripts/**/*.test.js\" \"src/**/*.test.js\" \"vite/**/*.test.js\" | tap-filter test-fail | tap-diff",
63
63
  "lint": "node scripts/lint.js",
64
64
  "lint:fix": "node scripts/lint.js fix",
65
65
  "prepublishOnly": "node scripts/prepublishOnly.js",
@@ -103,7 +103,7 @@
103
103
  "tape": "^5.9.0"
104
104
  },
105
105
  "dependencies": {
106
- "@lightningjs/renderer": "^3.7.0",
106
+ "@lightningjs/renderer": "3.11.0",
107
107
  "magic-string": "^1.4.1"
108
108
  },
109
109
  "repository": {
@@ -24,6 +24,9 @@ import symbols from './lib/symbols.js'
24
24
  import { DEFAULT_HOLD_TIMEOUT_MS, DEFAULT_KEYMAP } from './constants.js'
25
25
  import { renderer } from './launch.js'
26
26
  import { platform } from './platform.js'
27
+ import { keyMap, setKeyMap } from './keymap.js'
28
+
29
+ export { keyMap } from './keymap.js'
27
30
 
28
31
  // Reacts to Blits-only settings changed at runtime via `this.$settings.set()`.
29
32
  Settings.onRuntimeSettingChange = (key, value) => {
@@ -74,13 +77,6 @@ Settings.onRuntimeSettingChange = (key, value) => {
74
77
  }
75
78
  }
76
79
 
77
- /**
78
- * Merged keyMap (default + custom settings).
79
- * Initialized once during application init.
80
- * @type {Object<string, string>}
81
- */
82
- export let keyMap = {}
83
-
84
80
  /** @type {any} Active App component instance, set during App init */
85
81
  let liveAppInstance = null
86
82
  /** @type {any} Platform input target, set during App init */
@@ -100,7 +96,7 @@ let mouseMoveThrottleMs = 100
100
96
  let enableMouseFlag = false
101
97
 
102
98
  export const initKeyMap = () => {
103
- keyMap = { ...DEFAULT_KEYMAP, ...Settings.get('keymap', {}) }
99
+ setKeyMap({ ...DEFAULT_KEYMAP, ...Settings.get('keymap', {}) })
104
100
  }
105
101
 
106
102
  const Application = (config) => {
@@ -248,6 +244,7 @@ const Application = (config) => {
248
244
 
249
245
  if (node === null) {
250
246
  currentComponent = undefined
247
+ currentNode = null
251
248
  Hover.clear()
252
249
  return
253
250
  }
@@ -27,7 +27,7 @@ import { trigger } from '../../lib/reactivity/effect.js'
27
27
  import { Log } from '../../lib/log.js'
28
28
  import { removeEffects } from '../../lib/reactivity/effect.js'
29
29
  import { renderer } from '../../launch.js'
30
- import { keyMap } from '../../application.js'
30
+ import { keyMap } from '../../keymap.js'
31
31
  import { platform } from '../../platform.js'
32
32
 
33
33
  const selectCache = new WeakMap()
@@ -18,6 +18,7 @@
18
18
  import { Log } from '../../lib/log.js'
19
19
 
20
20
  import symbols from '../../lib/symbols.js'
21
+ import { createComputed } from '../../lib/reactivity/effect.js'
21
22
 
22
23
  export default (component, computeds) => {
23
24
  component[symbols.computedKeys] = []
@@ -40,7 +41,10 @@ export default (component, computeds) => {
40
41
  component[symbols.computedKeys].push(computed)
41
42
  Object.defineProperty(component, computed, {
42
43
  get() {
43
- return computeds[computed].apply(this)
44
+ // Setup runs on the shared prototype; install the cache on first instance access.
45
+ const get = createComputed(computeds[computed], this, computed)
46
+ Object.defineProperty(this, computed, { get })
47
+ return get()
44
48
  },
45
49
  })
46
50
  }
package/src/component.js CHANGED
@@ -496,16 +496,24 @@ const Component = (name = required('name'), config = required('config')) => {
496
496
  base = setupComponent(Object.create(Base), config)
497
497
  }
498
498
 
499
- // one time code generation (only if precompilation is turned off)
500
- if (config.code === undefined) {
501
- Log.debug(`Generating code for ${name} component`)
502
- config.code = codegenerator.call(config, parser(config.template, name), devMode)
503
- }
499
+ // Direct factory calls also prepare runtime code before rendering.
500
+ if (factory.prepare !== undefined) factory.prepare()
504
501
 
505
502
  // create an instance of the component, using base as the prototype (which contains Base)
506
503
  return component.call(Object.create(base), options, parentEl, parentComponent, rootComponent)
507
504
  }
508
505
 
506
+ // Precompiled factories have no preparation step. Runtime factories compile once,
507
+ // explicitly before holder creation, then take the same path as precompiled ones.
508
+ if (config.code === undefined) {
509
+ factory.prepare = () => {
510
+ Log.debug(`Generating code for ${name} component`)
511
+ config.code = codegenerator.call(config, parser(config.template, name), devMode)
512
+ factory.needsInteractive = factory.needsInteractive || !!config.code.usesHoverState
513
+ factory.prepare = undefined
514
+ }
515
+ }
516
+
509
517
  // store the config on the factory, in order to access the config
510
518
  // during the code generation step
511
519
  factory[Symbol.for('config')] = config
@@ -516,6 +524,13 @@ const Component = (name = required('name'), config = required('config')) => {
516
524
  // To determine whether dynamic component is actual Blits component or not
517
525
  factory[symbols.isComponent] = true
518
526
 
527
+ // Pointer hit testing supports both hover and click-to-focus/input.
528
+ factory.needsInteractive =
529
+ (config.hooks && !!(config.hooks.hover || config.hooks.unhover)) ||
530
+ (config.input &&
531
+ (typeof config.input.enter === 'function' || typeof config.input.any === 'function')) ||
532
+ !!(config.code && config.code.usesHoverState)
533
+
519
534
  return factory
520
535
  }
521
536
 
@@ -66,7 +66,7 @@ export default () =>
66
66
  'content',
67
67
  'placement',
68
68
  'inspector-data',
69
- 'holder',
69
+ 'interactive',
70
70
  ]
71
71
  const elementConfigs = []
72
72
  const forloops = []
@@ -133,6 +133,7 @@ export default () =>
133
133
  },
134
134
  ],
135
135
  context: {},
136
+ usesHoverState: false,
136
137
  },
137
138
  props: { size: 40, bgColor: 'white' },
138
139
  computed: {
@@ -72,7 +72,7 @@ export const FPScounter = Blits.Component('FPScounter', {
72
72
  'content',
73
73
  'placement',
74
74
  'inspector-data',
75
- 'holder',
75
+ 'interactive',
76
76
  ]
77
77
  const elementConfigs = []
78
78
  const forloops = []
@@ -634,6 +634,7 @@ export const FPScounter = Blits.Component('FPScounter', {
634
634
  },
635
635
  ],
636
636
  context: {},
637
+ usesHoverState: false,
637
638
  },
638
639
  state() {
639
640
  return {
@@ -78,7 +78,7 @@ export default () =>
78
78
  'content',
79
79
  'placement',
80
80
  'inspector-data',
81
- 'holder',
81
+ 'interactive',
82
82
  ]
83
83
  const elementConfigs = []
84
84
  const forloops = []
@@ -130,6 +130,7 @@ export default () =>
130
130
  },
131
131
  effects: [],
132
132
  context: {},
133
+ usesHoverState: false,
133
134
  },
134
135
  props: {
135
136
  name: '',
@@ -585,7 +585,7 @@ const propsTransformer = {
585
585
  this.props['data'] = v
586
586
  }
587
587
  },
588
- set holder(v) {
588
+ set interactive(v) {
589
589
  this.props['interactive'] = v
590
590
  },
591
591
  }
package/src/keymap.js ADDED
@@ -0,0 +1,27 @@
1
+ /*
2
+ * Copyright 2023 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
+ /**
19
+ * Merged keyMap (default + custom settings).
20
+ * Updated during application initialization and runtime settings changes.
21
+ * @type {Object<string, string>}
22
+ */
23
+ export let keyMap = {}
24
+
25
+ export const setKeyMap = (value) => {
26
+ keyMap = value
27
+ }
package/src/launch.js CHANGED
@@ -18,7 +18,6 @@
18
18
  import Settings from './settings.js'
19
19
  import { initLog, Log } from './lib/log.js'
20
20
  import engine from './engine.js'
21
- import blitsPackageInfo from '../package.json' with { type: 'json' }
22
21
  import { configurePlatform } from './platform.js'
23
22
  import profiler from './lib/profiler.js'
24
23
 
@@ -52,20 +51,6 @@ const isDev = typeof __DEV__ !== 'undefined' && __DEV__
52
51
  export let renderer = {}
53
52
  export const stage = {}
54
53
 
55
- async function rendererVersion() {
56
- let rendererPackageInfo
57
- try {
58
- // Dynamically import the renderer package.json
59
- rendererPackageInfo = await import('../../renderer/package.json')
60
- if (rendererPackageInfo !== undefined) {
61
- return rendererPackageInfo.version
62
- }
63
- } catch (e) {
64
- // Fallback to renderer version in dependencies
65
- return blitsPackageInfo.dependencies['@lightningjs/renderer']
66
- }
67
- }
68
-
69
54
  /**
70
55
  * @typedef {Object} BlitsSettings
71
56
  * @property {number} [w] - Width of the Application
@@ -148,10 +133,14 @@ export default (App, target, settings) => {
148
133
  )
149
134
  }
150
135
 
151
- rendererVersion().then((v) => {
152
- Log.info('Blits Version ', blitsPackageInfo.version)
153
- Log.info('Renderer Version ', v)
154
- })
136
+ Log.info(
137
+ 'Blits version:',
138
+ typeof __BLITS_VERSION__ === 'undefined' ? 'unknown' : __BLITS_VERSION__
139
+ )
140
+ Log.info(
141
+ 'Renderer version:',
142
+ typeof __RENDERER_VERSION__ === 'undefined' ? 'unknown' : __RENDERER_VERSION__
143
+ )
155
144
 
156
145
  stage.element = engine.Element
157
146
 
@@ -87,6 +87,8 @@ export default function (templateObject = { children: [] }, devMode = false) {
87
87
  }, skips}
88
88
  `)
89
89
 
90
+ const renderCodeString = ctx.renderCode.join('\n')
91
+
90
92
  return {
91
93
  render: new Function(
92
94
  'parent',
@@ -96,7 +98,7 @@ export default function (templateObject = { children: [] }, devMode = false) {
96
98
  'effect',
97
99
  'getRaw',
98
100
  'Log',
99
- ctx.renderCode.join('\n')
101
+ renderCodeString
100
102
  ),
101
103
  effects: ctx.effectsCode.map(
102
104
  (code) =>
@@ -112,6 +114,7 @@ export default function (templateObject = { children: [] }, devMode = false) {
112
114
  )
113
115
  ),
114
116
  context: ctx.context,
117
+ usesHoverState: renderCodeString.includes('.$isHovered'),
115
118
  }
116
119
  }
117
120
 
@@ -212,6 +215,7 @@ const generateElementCode = function (
212
215
 
213
216
  if (options.holder) {
214
217
  renderCode.push(`elementConfigs[${counter}]['holder'] = true`)
218
+ renderCode.push(`elementConfigs[${counter}]['interactive'] = cmps[${counter}].needsInteractive`)
215
219
  }
216
220
 
217
221
  Object.keys(templateObject).forEach((key) => {
@@ -342,12 +346,31 @@ const generateComponentCode = function (
342
346
  ) {
343
347
  const renderCode = options.forceEffect ? this.effectsCode : this.renderCode
344
348
 
345
- renderCode.push(`
349
+ if ('is' in templateObject || ':is' in templateObject) {
350
+ const selectedComponent =
351
+ ':is' in templateObject
352
+ ? interpolate(templateObject[':is'], options.component)
353
+ : cast(templateObject.is, 'is', options.component)
354
+ // Resolve the selected factory before populating its holder and filtering props.
355
+ renderCode.push(`
356
+ componentType = ${selectedComponent} || '${templateObject[Symbol.for('componentType')]}'
357
+ cmps[${counter}] = typeof componentType === 'function'
358
+ ? componentType
359
+ : (context.components && context.components[componentType]) || components[componentType]
360
+ if (!cmps[${counter}]) {
361
+ throw new Error('Component "${templateObject[Symbol.for('componentType')]}" not found')
362
+ }
363
+ `)
364
+ } else {
365
+ renderCode.push(`
346
366
  cmps[${counter}] =
347
367
  (context.components && context.components['${
348
368
  templateObject[Symbol.for('componentType')]
349
369
  }']) || components['${templateObject[Symbol.for('componentType')]}']
350
370
  `)
371
+ }
372
+
373
+ renderCode.push(`if (cmps[${counter}].prepare !== undefined) cmps[${counter}].prepare()`)
351
374
 
352
375
  this.cleanupCode.push(`
353
376
  cmps[${counter}] = null
package/src/lib/hooks.js CHANGED
@@ -57,3 +57,7 @@ export const registerHooks = (hooks = {}, identifier) => {
57
57
  if (typeof hooks[hook] === 'function') cbs[identifier][hook] = hooks[hook]
58
58
  })
59
59
  }
60
+
61
+ export const hasHook = (hook, identifier) => {
62
+ return cbs[identifier] !== undefined && hook in cbs[identifier]
63
+ }
@@ -31,6 +31,11 @@ export default (source, filePath, mode) => {
31
31
  // Use MagicString to track changes
32
32
  const s = new MagicString(source)
33
33
 
34
+ // Find component definition start positions for scoping JS-layer checks
35
+ const componentStarts = [...source.matchAll(/Blits\.(Component|Application)\s*\(/g)]
36
+ .map((m) => m.index)
37
+ .sort((a, b) => a - b)
38
+
34
39
  for (const template of templates) {
35
40
  if (template[2]) {
36
41
  const templateContent = template[2]
@@ -52,10 +57,30 @@ export default (source, filePath, mode) => {
52
57
  // Generate the code
53
58
  const code = generator.call({ components: {} }, parsed, mode === 'development')
54
59
 
60
+ // Determine the component scope containing this template
61
+ let scopeStart = 0
62
+ let scopeEnd = source.length
63
+ for (let i = 0; i < componentStarts.length; i++) {
64
+ if (componentStarts[i] <= templateStartIndex) {
65
+ scopeStart = componentStarts[i]
66
+ scopeEnd = i + 1 < componentStarts.length ? componentStarts[i + 1] : source.length
67
+ }
68
+ }
69
+ const componentScope = source.slice(scopeStart, scopeEnd)
70
+
71
+ // Derive usesHoverState from both template ($$isHovered) and JS layer (this.$isHovered)
72
+ // Match strings first so URL/comment delimiters inside literals stay intact.
73
+ // Keep template literals too: their interpolations may read hover state.
74
+ const strippedScope = componentScope.replace(
75
+ /("(?:\\[\s\S]|[^"\\])*"|'(?:\\[\s\S]|[^'\\])*'|`(?:\\[\s\S]|[^`\\])*`)|\/\/[^\r\n]*|\/\*[\s\S]*?\*\//g,
76
+ (match, literal) => literal || ' '
77
+ )
78
+ const usesHoverState = code.usesHoverState || strippedScope.includes('this.$isHovered')
79
+
55
80
  // Insert the code in the component using the 'code' key, replacing the template key
56
81
  const replacement = `/* eslint-disable no-unused-vars */ \ncode: { render: ${code.render.toString()}, effects: [${code.effects.map(
57
82
  (fn) => fn.toString()
58
- )}], context: {}}`
83
+ )}], context: {}, usesHoverState: ${usesHoverState}}`
59
84
 
60
85
  // MagicString tracks all changes automatically, no offset needed
61
86
  s.overwrite(templateStartIndex, templateEndIndex, replacement)
@@ -15,8 +15,12 @@
15
15
  * SPDX-License-Identifier: Apache-2.0
16
16
  */
17
17
 
18
+ import symbols from '../symbols.js'
19
+
18
20
  let currentEffect = null
19
21
  let currentKey = null
22
+ // Write position for dependency-array reuse, restored after nested computed getters.
23
+ let computedDependencyIndex = 0
20
24
 
21
25
  // counter that tracks all pauseTracking invocations
22
26
  // to ensure nested array operations don't prematurely
@@ -33,20 +37,98 @@ export const resumeTracking = () => {
33
37
 
34
38
  const objectMap = new WeakMap()
35
39
  const effectDependenciesMap = new WeakMap()
40
+ const computedEffects = new WeakMap()
41
+ const effectQueue = new Set()
42
+ let pendingEffects = null
43
+
44
+ const computedRunner = (effectFn, key) => {
45
+ if (effectFn.computed === true) return effectFn
46
+ let runner = computedEffects.get(effectFn)
47
+ if (runner === undefined) {
48
+ runner = (force) => effect(effectFn, key, force)
49
+ computedEffects.set(effectFn, runner)
50
+ }
51
+ return runner
52
+ }
53
+
54
+ export const createComputed = (getter, component, key) => {
55
+ let value
56
+ let status = 'dirty'
57
+
58
+ const invalidate = (force) => {
59
+ if (status === 'dirty' || status === 'disposed') return
60
+ status = 'dirty'
61
+ trigger(component, key, force)
62
+ }
63
+ invalidate.computed = true
64
+ invalidate.dispose = () => {
65
+ value = undefined
66
+ status = 'disposed'
67
+ }
68
+ if (component[symbols.effects] !== undefined) component[symbols.effects].push(invalidate)
69
+
70
+ return () => {
71
+ if (status === 'disposed') return undefined
72
+
73
+ if (currentEffect !== null) track(component, key, true)
74
+ if (status === 'clean') return value
75
+
76
+ const previousEffect = currentEffect
77
+ const previousKey = currentKey
78
+ const previousPauseCounter = pauseCounter
79
+ const previousDependencyIndex = computedDependencyIndex
80
+ computedDependencyIndex = 0
81
+ try {
82
+ // Refresh dependencies in isolation, including reads made outside effects.
83
+ clearDependencies(invalidate)
84
+ currentEffect = invalidate
85
+ currentKey = null
86
+ pauseCounter = 0
87
+ value = getter.call(component)
88
+ status = 'clean'
89
+ return value
90
+ } catch (error) {
91
+ status = 'failed'
92
+ throw error
93
+ } finally {
94
+ const dependencies = effectDependenciesMap.get(invalidate)
95
+ if (dependencies !== undefined) dependencies.length = computedDependencyIndex
96
+ computedDependencyIndex = previousDependencyIndex
97
+ currentEffect = previousEffect
98
+ currentKey = previousKey
99
+ pauseCounter = previousPauseCounter
100
+ }
101
+ }
102
+ }
103
+
104
+ const clearDependencies = (effect, releaseStorage) => {
105
+ const dependencies = effectDependenciesMap.get(effect)
106
+ if (dependencies === undefined) return
107
+ for (let i = 0; i < dependencies.length; i++) {
108
+ const effects = dependencies[i]
109
+ effects.delete(effect)
110
+ if (effect.computed === true) effects.computedCount--
111
+ }
112
+ if (releaseStorage === true) dependencies.length = 0
113
+ }
36
114
 
37
115
  export const removeEffects = (effectsToRemove) => {
38
116
  for (let i = 0; i < effectsToRemove.length; i++) {
39
117
  const effect = effectsToRemove[i]
40
- const dependencies = effectDependenciesMap.get(effect)
41
- if (dependencies === undefined) continue
42
- for (let j = 0; j < dependencies.length; j++) {
43
- dependencies[j].delete(effect)
118
+ const computedEffect = computedEffects.get(effect)
119
+ if (computedEffect !== undefined) {
120
+ // Computed dependencies subscribe the runner itself, so unsubscribe it separately.
121
+ clearDependencies(computedEffect, true)
122
+ effectDependenciesMap.delete(computedEffect)
123
+ computedEffects.delete(effect)
44
124
  }
125
+ clearDependencies(effect, true)
45
126
  effectDependenciesMap.delete(effect)
127
+ if (effect.computed === true) effect.dispose()
46
128
  }
47
129
  }
48
130
 
49
- export const track = (target, key) => {
131
+ export const track = (target, key, computed) => {
50
132
  if (currentEffect !== null) {
51
133
  if (pauseCounter > 0) {
52
134
  return
@@ -56,6 +138,7 @@ export const track = (target, key) => {
56
138
  if (currentKey.includes(key) === false) return
57
139
  } else if (currentKey !== null && key !== currentKey) return
58
140
 
141
+ const subscriber = computed === true ? computedRunner(currentEffect, currentKey) : currentEffect
59
142
  let effectsMap = objectMap.get(target)
60
143
  if (effectsMap === undefined) {
61
144
  effectsMap = new Map()
@@ -66,14 +149,19 @@ export const track = (target, key) => {
66
149
  effects = new Set()
67
150
  effectsMap.set(key, effects)
68
151
  }
69
- if (effects.has(currentEffect) === false) {
70
- let dependencies = effectDependenciesMap.get(currentEffect)
152
+ if (effects.has(subscriber) === false) {
153
+ let dependencies = effectDependenciesMap.get(subscriber)
71
154
  if (dependencies === undefined) {
72
155
  dependencies = []
73
- effectDependenciesMap.set(currentEffect, dependencies)
156
+ effectDependenciesMap.set(subscriber, dependencies)
157
+ }
158
+ effects.add(subscriber)
159
+ if (subscriber.computed === true) {
160
+ dependencies[computedDependencyIndex++] = effects
161
+ effects.computedCount = effects.computedCount === undefined ? 1 : effects.computedCount + 1
162
+ } else {
163
+ dependencies.push(effects)
74
164
  }
75
- dependencies.push(effects)
76
- effects.add(currentEffect)
77
165
  }
78
166
  }
79
167
  }
@@ -85,14 +173,52 @@ export const trigger = (target, key, force = false) => {
85
173
  return
86
174
  }
87
175
  const effects = effectsMap.get(key)
176
+ // Ordinary state updates keep their direct path.
177
+ if (
178
+ effects !== undefined &&
179
+ pendingEffects === null &&
180
+ (effects.computedCount === undefined || effects.computedCount === 0)
181
+ ) {
182
+ for (const effect of effects) {
183
+ if (effect.computed !== true) effect(force)
184
+ }
185
+ return
186
+ }
88
187
  if (effects !== undefined) {
89
- for (let effect of effects) {
90
- effect(force)
188
+ const parentQueue = pendingEffects
189
+ // Reuse the root queue; only nested updates with pending consumers need another set.
190
+ let queue = parentQueue
191
+ if (queue === null) {
192
+ queue = effectQueue.size === 0 ? effectQueue : new Set()
193
+ // All consumers share the source mutation's force flag.
194
+ queue.force = force
195
+ }
196
+ pendingEffects = queue
197
+ try {
198
+ for (const effect of effects) {
199
+ if (effect.computed === true) {
200
+ effect(force)
201
+ } else {
202
+ const runner = computedEffects.get(effect)
203
+ queue.add(runner === undefined ? effect : runner)
204
+ }
205
+ }
206
+ // Invalidate the whole computed chain before any consumer can read its cache.
207
+ if (parentQueue === null) {
208
+ pendingEffects = null
209
+ for (const effect of queue) {
210
+ queue.delete(effect)
211
+ if (effectDependenciesMap.has(effect) === true) effect(queue.force)
212
+ }
213
+ }
214
+ } finally {
215
+ pendingEffects = parentQueue
216
+ if (parentQueue === null) queue.clear()
91
217
  }
92
218
  }
93
219
  }
94
220
 
95
- export const effect = (effectFn, key = null) => {
221
+ export const effect = (effectFn, key = null, force) => {
96
222
  const previousEffect = currentEffect
97
223
  const previousKey = currentKey
98
224
 
@@ -100,7 +226,7 @@ export const effect = (effectFn, key = null) => {
100
226
  currentKey = key
101
227
 
102
228
  try {
103
- effectFn()
229
+ effectFn(force)
104
230
  } finally {
105
231
  currentEffect = previousEffect
106
232
  currentKey = previousKey
@@ -100,9 +100,7 @@ const reactiveProxy = (original, _parent = null, _key) => {
100
100
 
101
101
  // handling objects (but not null values, which have object type in JS)
102
102
  if (typeof target[key] === 'object' && target[key] !== null) {
103
- if (Array.isArray(target[key]) === true) {
104
- track(target, key)
105
- }
103
+ track(target, key)
106
104
  // create a new reactive proxy
107
105
  return reactiveProxy(getRaw(target[key]), target, key)
108
106
  }
@@ -140,6 +138,8 @@ const reactiveProxy = (original, _parent = null, _key) => {
140
138
  Object.getPrototypeOf(value) === Object.prototype
141
139
  ) {
142
140
  value = Object.assign(receiver[key], value)
141
+ // The object keeps its identity; its nested setters already notified consumers.
142
+ isEqual = true
143
143
  }
144
144
  }
145
145
  result = Reflect.set(target, key, value, receiver)
@@ -167,7 +167,7 @@ const reactiveDefineProperty = (target) => {
167
167
 
168
168
  if (target[key] !== null && typeof target[key] === 'object') {
169
169
  if (Object.getPrototypeOf(target[key]) === Object.prototype) {
170
- return reactiveDefineProperty(target[key])
170
+ reactiveDefineProperty(target[key])
171
171
  } else if (Array.isArray(target[key]) === true) {
172
172
  for (let i = 0; i < arrayPatchMethods.length; i++) {
173
173
  target[key][arrayPatchMethods[i]] = function (v) {
@@ -189,6 +189,13 @@ const reactiveDefineProperty = (target) => {
189
189
  // todo: support assigning array (as we do with proxies)
190
190
  let oldValue = internalValue
191
191
  if (oldValue !== newValue) {
192
+ if (
193
+ newValue !== null &&
194
+ typeof newValue === 'object' &&
195
+ Object.getPrototypeOf(newValue) === Object.prototype
196
+ ) {
197
+ reactiveDefineProperty(newValue)
198
+ }
192
199
  internalValue = newValue
193
200
  trigger(target, key)
194
201
  }
@@ -44,7 +44,7 @@ import { platform } from '../platform.js'
44
44
  * @property {Object} hooks - The hooks of the route
45
45
  * @property {Object} transition - The transition of the route
46
46
  * @property {Object} announce - The announce of the route
47
- * @property {(options: Object, parentEl: BlitsElement, parentComponent: BlitsComponent, rootComponent?: BlitsComponent) => BlitsComponent} component - The component factory of the route
47
+ * @property {BlitsComponentFactory} component - The component factory of the route (or a lazy import function)
48
48
  *
49
49
  * @typedef {Object} Hash
50
50
  * @property {string} path - The path of the hash
@@ -288,9 +288,13 @@ const performNavigation = async function (viewState) {
288
288
 
289
289
  // routing to a new page (instead of routing back to a keepAlive page)
290
290
  if (view === undefined) {
291
+ // Prepare eager runtime factories before creating their holder. Lazy loaders
292
+ // retain the conservative interactive fallback until their factory is known.
293
+ if (route.component.prepare !== undefined) route.component.prepare()
294
+ const needsInteractive = route.component.needsInteractive ?? true
291
295
  // create a holder element for the new view
292
296
  holder = stage.element({ parent: this[symbols.children][0] })
293
- holder.populate({})
297
+ holder.populate({ holder: true, interactive: needsInteractive })
294
298
  holder.set('w', '100%')
295
299
  holder.set('h', '100%')
296
300
 
package/vite/index.js CHANGED
@@ -20,15 +20,18 @@ import msdfGenerator from './msdfGenerator.js'
20
20
  import blitsFileConverter from './blitsFileConverter.js'
21
21
  import reactivityGuard from './reactivityGuard.js'
22
22
  import injectDevConfig from './inject-dev-config.js'
23
+ import injectVersions from './injectVersions.js'
23
24
 
24
25
  export { default as preCompiler } from './preCompiler.js'
25
26
  export { default as msdfGenerator } from './msdfGenerator.js'
26
27
  export { default as blitsFileConverter } from './blitsFileConverter.js'
27
28
  export { default as reactivityGuard } from './reactivityGuard.js'
28
29
  export { default as injectDevConfig } from './inject-dev-config.js'
30
+ export { default as injectVersions } from './injectVersions.js'
29
31
 
30
32
  export default [
31
33
  injectDevConfig(),
34
+ injectVersions(),
32
35
  blitsFileConverter(),
33
36
  reactivityGuard(),
34
37
  preCompiler(),
@@ -0,0 +1,58 @@
1
+ /*
2
+ * Copyright 2025 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 { readFileSync, existsSync } from 'node:fs'
19
+ import { createRequire } from 'node:module'
20
+ import { dirname, join } from 'node:path'
21
+
22
+ const require = createRequire(import.meta.url)
23
+
24
+ function getVersions() {
25
+ const blits = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
26
+ // Resolve the public entry: the renderer does not export its package.json.
27
+ // Walk upwards to support hoisted, nested, and symlinked installations.
28
+ let directory = dirname(require.resolve('@lightningjs/renderer'))
29
+ while (true) {
30
+ const filename = join(directory, 'package.json')
31
+ if (existsSync(filename)) {
32
+ const pkg = JSON.parse(readFileSync(filename, 'utf8'))
33
+ if (pkg.name === '@lightningjs/renderer') {
34
+ return { blits: blits.version, renderer: pkg.version }
35
+ }
36
+ }
37
+ const parent = dirname(directory)
38
+ if (parent === directory) throw new Error('Unable to locate @lightningjs/renderer/package.json')
39
+ directory = parent
40
+ }
41
+ }
42
+
43
+ export default function injectVersions() {
44
+ return {
45
+ name: 'inject-versions',
46
+ config(config) {
47
+ const versions = getVersions()
48
+ return {
49
+ define: {
50
+ __BLITS_VERSION__: JSON.stringify(versions.blits || 'unknown'),
51
+ __RENDERER_VERSION__: JSON.stringify(versions.renderer || 'unknown'),
52
+ // merge any existing define block
53
+ ...config.define,
54
+ },
55
+ }
56
+ },
57
+ }
58
+ }