@mintjamsinc/ichigojs 0.1.69 → 0.1.71

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/README.md CHANGED
@@ -198,7 +198,25 @@ VDOM.createApp({
198
198
  }).mount('#app');
199
199
  ```
200
200
 
201
- **Writable Computed Properties:**
201
+ **Lazy (pull-based) evaluation:**
202
+
203
+ Computed properties are evaluated lazily and cached. When a dependency changes,
204
+ the dependent computed is marked stale (rather than eagerly recomputed) and is
205
+ recomputed on the next read. Because of this, reading a computed property
206
+ **synchronously after mutating a dependency returns an up-to-date value** — you
207
+ do not have to wait for the next tick:
208
+
209
+ ```javascript
210
+ this.cartItems.push(item);
211
+ console.log(this.subtotal); // already reflects the new item
212
+ ```
213
+
214
+ DOM updates remain batched in a microtask, so multiple synchronous mutations
215
+ still result in a single render. Each computed is recomputed at most once per
216
+ update cycle (on first read, or during the pre-render flush, whichever comes
217
+ first), and a computed whose recomputed value is unchanged does not trigger DOM
218
+ updates or watchers that depend on it. Computed→computed chains resolve
219
+ automatically and independently of declaration order.
202
220
 
203
221
  A computed property can also be defined as an object with both a `get` and a
204
222
  `set` function. This makes it writable, so it can be used as a `v-model` target
@@ -913,7 +931,7 @@ ichigo.js uses several optimization techniques:
913
931
 
914
932
  - **Microtask batching**: Multiple synchronous changes result in a single DOM update
915
933
  - **Efficient change tracking**: Only changed properties trigger re-evaluation
916
- - **Smart computed caching**: Computed properties only re-evaluate when dependencies change
934
+ - **Lazy computed caching**: Computed properties are pull-based — they re-evaluate only when a dependency changes and the value is actually read, at most once per update cycle
917
935
 
918
936
  Benchmark (1000 item list update): **~6.8ms** ⚡
919
937
 
@@ -1003,6 +1021,10 @@ MIT License - Copyright (c) 2025 MintJams Inc.
1003
1021
 
1004
1022
  Inspired by [Vue.js](https://vuejs.org/) - A progressive JavaScript framework.
1005
1023
 
1024
+ ## Trademarks
1025
+
1026
+ All trademarks are the property of their respective owners.
1027
+
1006
1028
  ---
1007
1029
 
1008
1030
  Built with ❤️ by [MintJams Inc.](https://github.com/mintjamsinc)
package/dist/ichigo.cjs CHANGED
@@ -8399,6 +8399,31 @@
8399
8399
  * is replaced, the binding is set to null/undefined, or the bindings are destroyed.
8400
8400
  */
8401
8401
  #externalSubscriptions = new Map();
8402
+ /**
8403
+ * Recompute callbacks for computed properties registered on these bindings.
8404
+ * Maps a computed property name to a function that recomputes and caches its value.
8405
+ * Only the bindings instance that owns the computed (typically the root) holds an entry;
8406
+ * child bindings resolve computed reads by delegating to their parent through the proxy.
8407
+ */
8408
+ #computedRecomputers = new Map();
8409
+ /**
8410
+ * The set of computed properties whose cached value is stale and must be recomputed on next
8411
+ * access (pull-based evaluation). Populated by markComputedDirty when a dependency changes,
8412
+ * and cleared as each computed is resolved.
8413
+ */
8414
+ #dirtyComputeds = new Set();
8415
+ /**
8416
+ * Guard set used to detect re-entrant (circular) computed resolution. While a computed is
8417
+ * being recomputed its name is held here; a nested read of the same computed returns the
8418
+ * currently cached (stale) value instead of recursing infinitely.
8419
+ */
8420
+ #resolvingComputeds = new Set();
8421
+ /**
8422
+ * The plain backing object behind the #local proxy. Kept as a direct reference so the cached
8423
+ * value of a computed property can be read without triggering pull-based re-evaluation
8424
+ * (see peekComputed).
8425
+ */
8426
+ #store = {};
8402
8427
  /**
8403
8428
  * Creates a new instance of VBindings.
8404
8429
  * @param parent The parent bindings, if any.
@@ -8410,8 +8435,13 @@
8410
8435
  if (this.#logger?.isDebugEnabled) {
8411
8436
  this.#logger.debug(`VBindings created. Parent: ${this.#parent ? 'yes' : 'no'}`);
8412
8437
  }
8413
- this.#local = new Proxy({}, {
8438
+ this.#local = new Proxy(this.#store, {
8414
8439
  get: (obj, key) => {
8440
+ // Pull-based evaluation: if this key is a dirty computed property, recompute it now
8441
+ // so the read returns a fresh value (synchronously, even before the update microtask).
8442
+ if (typeof key === 'string') {
8443
+ this.#resolveComputedIfDirty(key);
8444
+ }
8415
8445
  if (Reflect.has(obj, key)) {
8416
8446
  return Reflect.get(obj, key);
8417
8447
  }
@@ -8669,6 +8699,79 @@
8669
8699
  registerWritableComputed(key, setter) {
8670
8700
  this.#writableComputeds.set(key, setter);
8671
8701
  }
8702
+ /**
8703
+ * Registers a computed property for pull-based (lazy) evaluation. The provided recompute
8704
+ * callback is invoked the first time the property is read after it has been marked dirty
8705
+ * (or during the pre-render flush), and is expected to update the cached value (typically
8706
+ * via setSilent).
8707
+ * @param key The computed property name.
8708
+ * @param recompute The callback that recomputes and caches the property's value.
8709
+ */
8710
+ registerComputed(key, recompute) {
8711
+ this.#computedRecomputers.set(key, recompute);
8712
+ }
8713
+ /**
8714
+ * Marks a computed property as dirty so it will be recomputed on next access.
8715
+ * @param key The computed property name.
8716
+ */
8717
+ markComputedDirty(key) {
8718
+ this.#dirtyComputeds.add(key);
8719
+ }
8720
+ /**
8721
+ * Reads the currently cached value of a computed property without triggering pull-based
8722
+ * re-evaluation. Used by the recompute routine to obtain the previous value for change
8723
+ * detection. Falls back to the parent bindings when the key is not stored locally.
8724
+ * @param key The computed property name.
8725
+ * @returns The cached value, or undefined if not cached.
8726
+ */
8727
+ peekComputed(key) {
8728
+ if (Object.prototype.hasOwnProperty.call(this.#store, key)) {
8729
+ return this.#store[key];
8730
+ }
8731
+ return this.#parent?.peekComputed(key);
8732
+ }
8733
+ /**
8734
+ * Forces resolution of every computed property currently marked dirty. Called before the DOM
8735
+ * diff and watcher notification so that the set of changed identifiers is complete.
8736
+ */
8737
+ flushDirtyComputeds() {
8738
+ for (const key of [...this.#dirtyComputeds]) {
8739
+ this.#resolveComputedIfDirty(key);
8740
+ }
8741
+ }
8742
+ /**
8743
+ * Resolves a computed property if its cached value is dirty (pull-based evaluation), by
8744
+ * invoking its registered recompute callback. Computed→computed chains resolve naturally:
8745
+ * reading another computed inside the getter re-enters this method for that key. Re-entrant
8746
+ * resolution of the same key (a circular dependency) is detected and short-circuited, leaving
8747
+ * the previously cached value in place.
8748
+ * @param key The property name to resolve.
8749
+ */
8750
+ #resolveComputedIfDirty(key) {
8751
+ const recompute = this.#computedRecomputers.get(key);
8752
+ if (!recompute) {
8753
+ // Not a computed owned by these bindings; a parent (if any) resolves it when the value
8754
+ // is read through the proxy delegation in the get trap.
8755
+ return;
8756
+ }
8757
+ if (!this.#dirtyComputeds.has(key)) {
8758
+ return;
8759
+ }
8760
+ if (this.#resolvingComputeds.has(key)) {
8761
+ this.#logger?.warn(`Circular dependency detected while resolving computed property '${key}'.`);
8762
+ return;
8763
+ }
8764
+ this.#resolvingComputeds.add(key);
8765
+ try {
8766
+ // Clear the dirty flag before recomputing so reads of this same key during recomputation
8767
+ // (other than a true cycle) do not attempt to resolve it again.
8768
+ this.#dirtyComputeds.delete(key);
8769
+ recompute();
8770
+ }
8771
+ finally {
8772
+ this.#resolvingComputeds.delete(key);
8773
+ }
8774
+ }
8672
8775
  /**
8673
8776
  * Manually adds an identifier to the set of changed identifiers.
8674
8777
  * This is useful for computed properties that need to mark themselves as changed
@@ -13654,7 +13757,8 @@
13654
13757
  this.#logManager = new VLogManager(options.logLevel);
13655
13758
  this.#logger = this.#logManager.getLogger('VApplication');
13656
13759
  // Analyze function dependencies
13657
- this.#functionDependencies = ExpressionUtils.analyzeFunctionDependencies(options.methods || {});
13760
+ const methods = (options.methods || {});
13761
+ this.#functionDependencies = ExpressionUtils.analyzeFunctionDependencies(methods);
13658
13762
  // Analyze computed dependencies based on getter functions only.
13659
13763
  // Writable computeds (defined as { get, set }) contribute their getter for dependency analysis.
13660
13764
  const computedGetters = {};
@@ -13663,7 +13767,23 @@
13663
13767
  computedGetters[key] = VApplication.#getComputedGetter(def);
13664
13768
  }
13665
13769
  }
13666
- this.#computedDependencies = ExpressionUtils.analyzeFunctionDependencies(computedGetters);
13770
+ // Resolve computed dependencies against BOTH the computed getters AND the methods, so a
13771
+ // computed that delegates to a method inherits that method's reactive dependencies. A
13772
+ // computed whose only reactive reads happen inside a called method (e.g. a reactive i18n
13773
+ // `t()` helper that reads `this.localization`) would otherwise never be invalidated, leaving
13774
+ // its binding stale on a dependency change. Analyzing the combined set flattens every
13775
+ // computed→method (and computed→computed, method→method) edge down to the underlying
13776
+ // reactive paths — the same expansion that template-expression analysis already performs for
13777
+ // method calls (see ExpressionUtils.extractIdentifiers). We then keep only the computed
13778
+ // entries, since #computedDependencies must be keyed by computed name alone.
13779
+ const combinedDependencies = ExpressionUtils.analyzeFunctionDependencies({
13780
+ ...methods,
13781
+ ...computedGetters,
13782
+ });
13783
+ this.#computedDependencies = {};
13784
+ for (const key of Object.keys(computedGetters)) {
13785
+ this.#computedDependencies[key] = combinedDependencies[key] || [];
13786
+ }
13667
13787
  // Initialize watcher manager
13668
13788
  this.#watcher = new VWatcher(this.#logger);
13669
13789
  // Initialize bindings from data, computed, and methods
@@ -13854,7 +13974,11 @@
13854
13974
  #initializeBindings() {
13855
13975
  // Create bindings with change tracking
13856
13976
  this.#bindings = new VBindings({
13857
- onChange: () => {
13977
+ onChange: (identifier) => {
13978
+ // Pull-based invalidation: mark dependent computed properties dirty synchronously
13979
+ // so a subsequent synchronous read returns a fresh value, then schedule the
13980
+ // batched DOM update for the next microtask.
13981
+ this.#markDirtyComputeds(identifier);
13858
13982
  this.#scheduleUpdate();
13859
13983
  },
13860
13984
  vApplication: this
@@ -13904,8 +14028,17 @@
13904
14028
  }
13905
14029
  }
13906
14030
  }
13907
- // Add computed properties (initialization mode)
13908
- this.#recomputeProperties(true);
14031
+ // Register computed properties for pull-based (lazy) evaluation. Each computed is
14032
+ // recomputed on first access after being marked dirty, rather than eagerly in the update
14033
+ // microtask. We mark them all dirty and resolve once here so that initial values are
14034
+ // cached and recorded as changes for the first render.
14035
+ if (this.#options.computed) {
14036
+ for (const key of Object.keys(this.#options.computed)) {
14037
+ this.#bindings.registerComputed(key, () => this.#recomputeOne(key));
14038
+ this.#bindings.markComputedDirty(key);
14039
+ }
14040
+ }
14041
+ this.#flushDirtyComputeds();
13909
14042
  }
13910
14043
  /**
13911
14044
  * Initializes watchers from the watch option.
@@ -13956,8 +14089,9 @@
13956
14089
  * Executes an immediate DOM update.
13957
14090
  */
13958
14091
  #update() {
13959
- // Re-evaluate computed properties that depend on changed values
13960
- this.#recomputeProperties();
14092
+ // Resolve any computed properties still marked dirty (those not already pulled by a
14093
+ // synchronous read) so that the set of changed identifiers is complete before the DOM diff.
14094
+ this.#flushDirtyComputeds();
13961
14095
  // Apply computed source path mappings to changes
13962
14096
  // This converts paths like "model.elements[0].executionListeners"
13963
14097
  // to "selectedElement.executionListeners" when selectedElement points to model.elements[0]
@@ -14002,110 +14136,116 @@
14002
14136
  }
14003
14137
  }
14004
14138
  /**
14005
- * Recursively recomputes computed properties based on changed identifiers.
14006
- * @param isInitialization - If true, computes all computed properties regardless of dependencies
14139
+ * Marks computed properties as dirty (pull-based invalidation) when a dependency changes.
14140
+ * Uses the statically analyzed dependency graph; because computed→computed and computed→method
14141
+ * dependencies are flattened to their underlying reactive paths during analysis, a single change
14142
+ * marks every transitively dependent computed dirty in one pass. The actual recomputation is
14143
+ * deferred until the value is read (see #recomputeOne).
14144
+ * @param identifier The changed identifier reported by the bindings change tracker.
14007
14145
  */
14008
- #recomputeProperties(isInitialization = false) {
14009
- if (!this.#options.computed) {
14146
+ #markDirtyComputeds(identifier) {
14147
+ if (!this.#options.computed || !this.#bindings) {
14010
14148
  return;
14011
14149
  }
14012
- const computed = new Set();
14013
- const processing = new Set();
14014
- // Gather all changed identifiers, including all parent paths
14015
- // e.g., for "model.elements[0].messageRef", also add:
14016
- // "model.elements[0]", "model.elements", "model"
14017
- const allChanges = new Set();
14018
- this.#bindings?.changes.forEach(id => {
14019
- allChanges.add(id);
14020
- // Add all parent paths by progressively stripping from the end
14021
- let path = id;
14022
- while (path.length > 0) {
14023
- // Find last separator (either '[' or '.')
14024
- const bracketIdx = path.lastIndexOf('[');
14025
- const dotIdx = path.lastIndexOf('.');
14026
- const lastSep = Math.max(bracketIdx, dotIdx);
14027
- if (lastSep === -1) {
14028
- break;
14029
- }
14030
- path = path.substring(0, lastSep);
14031
- if (path.length > 0) {
14032
- allChanges.add(path);
14033
- }
14034
- }
14035
- });
14036
- // Helper function to recursively compute a property
14037
- const compute = (key) => {
14038
- // Skip if already computed in this update cycle
14039
- if (computed.has(key)) {
14040
- return;
14041
- }
14042
- // Detect circular dependency
14043
- if (processing.has(key)) {
14044
- this.#logger.error(`Circular dependency detected for computed property '${key}'`);
14045
- return;
14046
- }
14047
- processing.add(key);
14048
- // Get the dependencies for this computed property
14150
+ for (const key of Object.keys(this.#computedDependencies)) {
14049
14151
  const deps = this.#computedDependencies[key] || [];
14050
- // First, recursively compute any dependent computed properties.
14051
- // This must happen before the change check so that computed→computed
14052
- // dependency chains are resolved and allChanges is up-to-date.
14053
- for (const dep of deps) {
14054
- if (this.#options.computed[dep]) {
14055
- compute(dep);
14056
- }
14152
+ if (deps.some(dep => this.#dependencyAffectedBy(dep, identifier))) {
14153
+ this.#bindings.markComputedDirty(key);
14057
14154
  }
14058
- // If none of the dependencies have changed, skip recomputation (unless it's initialization).
14059
- // Checked after recursive computation to detect transitive changes through computed properties.
14060
- if (!isInitialization && !deps.some(dep => allChanges.has(dep))) {
14061
- computed.add(key);
14062
- return;
14063
- }
14064
- // Now compute this property
14065
- const computedFn = VApplication.#getComputedGetter(this.#options.computed[key]);
14066
- try {
14067
- const oldValue = this.#bindings?.get(key);
14068
- const newValue = computedFn.call(this.#bindings?.raw);
14069
- // Check if the value actually changed
14070
- let hasChanged = oldValue !== newValue;
14071
- // For arrays, always update (VBindings will detect length changes via its length cache)
14072
- if (!hasChanged && Array.isArray(newValue)) {
14073
- hasChanged = true;
14074
- }
14075
- if (hasChanged) {
14076
- // Use setSilent to avoid triggering onChange during computed property updates
14077
- // Then mark the computed property as changed so UI depending on it will update
14078
- this.#bindings?.setSilent(key, newValue);
14079
- this.#bindings?.markChanged(key);
14080
- allChanges.add(key);
14081
- // Track source path mapping for computed property values
14082
- // This allows changes like "model.elements[0].x" to be mapped to "selectedElement.x"
14083
- if (typeof newValue === 'object' && newValue !== null) {
14084
- const sourcePath = ReactiveProxy.getPath(newValue);
14085
- if (sourcePath) {
14086
- // Remove old mapping for this computed property
14087
- for (const [path, name] of this.#computedSourcePaths) {
14088
- if (name === key) {
14089
- this.#computedSourcePaths.delete(path);
14090
- break;
14091
- }
14155
+ }
14156
+ }
14157
+ /**
14158
+ * Determines whether a change to `changePath` affects a computed dependency `dep`, taking both
14159
+ * path directions into account:
14160
+ * - an exact match;
14161
+ * - `changePath` is a descendant of `dep` (e.g. dep "cartItems", change "cartItems.0.quantity")
14162
+ * — a nested mutation of the dependency;
14163
+ * - `dep` is a descendant of `changePath` (e.g. dep "user.name", change "user") — the container
14164
+ * holding the dependency was replaced wholesale.
14165
+ * Local and global path aliases (e.g. computed source paths) are also honored via the bindings'
14166
+ * own alias-aware matcher.
14167
+ * @param dep The dependency path declared by a computed property.
14168
+ * @param changePath The identifier reported by the bindings change tracker.
14169
+ */
14170
+ #dependencyAffectedBy(dep, changePath) {
14171
+ if (changePath === dep) {
14172
+ return true;
14173
+ }
14174
+ if (changePath.startsWith(dep + '.') || changePath.startsWith(dep + '[')) {
14175
+ return true;
14176
+ }
14177
+ if (dep.startsWith(changePath + '.') || dep.startsWith(changePath + '[')) {
14178
+ return true;
14179
+ }
14180
+ // Fall back to alias-aware matching for aliased paths (computed source paths, props, etc.).
14181
+ return this.#bindings.doesChangeMatchIdentifier(changePath, dep);
14182
+ }
14183
+ /**
14184
+ * Forces resolution of all computed properties currently marked dirty so that the set of
14185
+ * changed identifiers is complete before watcher notification and the DOM diff. Computeds that
14186
+ * were already pulled by a synchronous read earlier in the cycle are no longer dirty and are
14187
+ * skipped, so each computed is recomputed at most once per update cycle.
14188
+ */
14189
+ #flushDirtyComputeds() {
14190
+ this.#bindings?.flushDirtyComputeds();
14191
+ }
14192
+ /**
14193
+ * Recomputes a single computed property and updates its cached value. Registered with the
14194
+ * bindings as the pull-based recompute callback, so it runs lazily the first time the property
14195
+ * is read after being marked dirty (or during the pre-render flush). Computed→computed chains
14196
+ * resolve naturally and order-independently: reading a dependent computed inside the getter
14197
+ * triggers its own lazy resolution through the bindings proxy.
14198
+ *
14199
+ * When the value actually changes, the computed name is recorded as a change so that
14200
+ * dependency-precise DOM updates and watcher notifications still fire, and the source-path
14201
+ * mapping is refreshed so nested changes to the underlying object map back to the computed.
14202
+ * @param key The computed property name.
14203
+ */
14204
+ #recomputeOne(key) {
14205
+ if (!this.#options.computed || !this.#bindings) {
14206
+ return;
14207
+ }
14208
+ const def = this.#options.computed[key];
14209
+ if (!def) {
14210
+ return;
14211
+ }
14212
+ const computedFn = VApplication.#getComputedGetter(def);
14213
+ try {
14214
+ // Read the previous value without triggering re-resolution, for change detection.
14215
+ const oldValue = this.#bindings.peekComputed(key);
14216
+ const newValue = computedFn.call(this.#bindings.raw);
14217
+ // Check if the value actually changed.
14218
+ let hasChanged = oldValue !== newValue;
14219
+ // For arrays, always treat as changed (VBindings detects length changes via its length
14220
+ // cache). This preserves the precise-update behavior of the previous eager implementation.
14221
+ if (!hasChanged && Array.isArray(newValue)) {
14222
+ hasChanged = true;
14223
+ }
14224
+ // Cache the value silently so the read returns it without re-triggering reactivity.
14225
+ this.#bindings.setSilent(key, newValue);
14226
+ if (hasChanged) {
14227
+ // Mark the computed property as changed so UI and watchers depending on it update.
14228
+ this.#bindings.markChanged(key);
14229
+ // Track source path mapping for computed property values.
14230
+ // This allows changes like "model.elements[0].x" to be mapped to "selectedElement.x".
14231
+ if (typeof newValue === 'object' && newValue !== null) {
14232
+ const sourcePath = ReactiveProxy.getPath(newValue);
14233
+ if (sourcePath) {
14234
+ // Remove old mapping for this computed property
14235
+ for (const [path, name] of this.#computedSourcePaths) {
14236
+ if (name === key) {
14237
+ this.#computedSourcePaths.delete(path);
14238
+ break;
14092
14239
  }
14093
- // Add new mapping
14094
- this.#computedSourcePaths.set(sourcePath, key);
14095
14240
  }
14241
+ // Add new mapping
14242
+ this.#computedSourcePaths.set(sourcePath, key);
14096
14243
  }
14097
14244
  }
14098
14245
  }
14099
- catch (error) {
14100
- this.#logger.error(`Error evaluating computed property '${key}': ${error}`);
14101
- }
14102
- computed.add(key);
14103
- processing.delete(key);
14104
- };
14105
- // Compute all properties; the recursive logic inside compute() handles
14106
- // dependency ordering and skips properties whose dependencies did not change.
14107
- for (const key of Object.keys(this.#computedDependencies)) {
14108
- compute(key);
14246
+ }
14247
+ catch (error) {
14248
+ this.#logger.error(`Error evaluating computed property '${key}': ${error}`);
14109
14249
  }
14110
14250
  }
14111
14251
  /**