@mintjamsinc/ichigojs 0.1.85 → 0.1.87

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/dist/ichigo.cjs CHANGED
@@ -6732,6 +6732,507 @@
6732
6732
  if (node.value) { c(node.value, st, "Expression"); }
6733
6733
  };
6734
6734
 
6735
+ // Copyright (c) 2025 MintJams Inc. Licensed under MIT License.
6736
+ /**
6737
+ * Utility class for creating reactive proxies that automatically track changes.
6738
+ */
6739
+ class ReactiveProxy {
6740
+ /**
6741
+ * A WeakMap to store the proxy for each target object and path combination.
6742
+ * This prevents creating multiple proxies for the same object accessed from different paths.
6743
+ */
6744
+ static proxyCache = new WeakMap();
6745
+ /**
6746
+ * A WeakMap to track which objects are proxies, mapping proxy -> original target.
6747
+ * This prevents double-wrapping of already proxied objects.
6748
+ */
6749
+ static proxyToTarget = new WeakMap();
6750
+ /**
6751
+ * A WeakSet to track objects marked as "raw" (non-reactive).
6752
+ * These objects will not be wrapped with Proxy.
6753
+ */
6754
+ static rawObjects = new WeakSet();
6755
+ /**
6756
+ * A WeakMap to store the path for each proxy object.
6757
+ * This allows retrieving the source path of an object for computed property mapping.
6758
+ */
6759
+ static proxyPaths = new WeakMap();
6760
+ /**
6761
+ * Dispatchers per (target, path). Every target reached while walking a proxy
6762
+ * subtree registers an entry pointing at the same dispatcher as the outermost
6763
+ * proxy of that subtree, so callers can look up the dispatcher from any
6764
+ * intermediate proxy when subscribing.
6765
+ */
6766
+ static dispatchers = new WeakMap();
6767
+ /**
6768
+ * A Map to store path aliases.
6769
+ * Key: alias path (e.g., "editingNestedStep.steps")
6770
+ * Value: source path (e.g., "routes[0].steps[0].steps")
6771
+ * This allows mapping variable names to their actual source paths for dependency tracking.
6772
+ */
6773
+ static pathAliases = new Map();
6774
+ /**
6775
+ * Compiled patterns for identifiers that contain a "[*]" index, keyed by the
6776
+ * identifier and whether descendants of the path also match.
6777
+ */
6778
+ static pathPatterns = new Map();
6779
+ /**
6780
+ * The set collecting the paths read through any proxy while track() runs, or
6781
+ * undefined when no expression is being tracked.
6782
+ */
6783
+ static tracker;
6784
+ /**
6785
+ * Whether a property key is spelled as an array index ("0", "12") rather than a
6786
+ * property name. Both the proxy and the expression analyzer decide between
6787
+ * "items[0]" and "items.name" with this test alone, so a numeric key on a plain
6788
+ * object and a numeric key on an array get the same spelling.
6789
+ *
6790
+ * @param key The property key as a string.
6791
+ * @returns True if the key is a canonical non-negative integer.
6792
+ */
6793
+ static isIndexKey(key) {
6794
+ return /^(?:0|[1-9]\d*)$/.test(key);
6795
+ }
6796
+ /**
6797
+ * Appends a property key to a source path the way change paths are reported:
6798
+ * an index in brackets ("items[0]"), a name after a dot ("user.name").
6799
+ *
6800
+ * @param path The path of the containing object, or "" for a root binding.
6801
+ * @param key The property key being appended.
6802
+ * @returns The joined path.
6803
+ */
6804
+ static joinPath(path, key) {
6805
+ const keyStr = String(key);
6806
+ if (!path) {
6807
+ return keyStr;
6808
+ }
6809
+ return this.isIndexKey(keyStr) ? `${path}[${keyStr}]` : `${path}.${keyStr}`;
6810
+ }
6811
+ /**
6812
+ * Runs fn while recording, into paths, the source path of every property read
6813
+ * through a reactive proxy. Nested calls record into the innermost set only;
6814
+ * the previous set is restored when fn returns or throws.
6815
+ *
6816
+ * @param paths The set that receives the paths read.
6817
+ * @param fn The function to run.
6818
+ * @returns The value fn returns.
6819
+ */
6820
+ static track(paths, fn) {
6821
+ const previous = this.tracker;
6822
+ this.tracker = paths;
6823
+ try {
6824
+ return fn();
6825
+ }
6826
+ finally {
6827
+ this.tracker = previous;
6828
+ }
6829
+ }
6830
+ /**
6831
+ * The `Object.prototype.toString` tags of the *only* value types we deeply
6832
+ * proxy. Everything else is returned untouched (raw).
6833
+ *
6834
+ * Rationale: a Proxy can only safely intercept plain data containers. Built-in
6835
+ * and host objects — `Date`, `RegExp`, `File`, `Blob`, `FileList`, `Promise`,
6836
+ * `WeakMap`/`WeakSet`, typed arrays, DOM nodes, etc. — carry private internal
6837
+ * slots, and invoking their native methods with a Proxy as the `this`/receiver
6838
+ * throws "Illegal invocation". They must therefore never be wrapped. This is an
6839
+ * allow-list rather than a deny-list so that *every* such exotic type is
6840
+ * excluded by default, not just the handful we happened to enumerate.
6841
+ *
6842
+ * Plain user-defined class instances report `[object Object]` (unless they set
6843
+ * `Symbol.toStringTag`) and so remain reactive, preserving prior behaviour.
6844
+ * `Map`/`Set` stay reactive because the proxy specially wraps their mutation
6845
+ * methods (see the `get` trap); `WeakMap`/`WeakSet` are intentionally excluded
6846
+ * since they cannot be wrapped that way and would break the same as `File`.
6847
+ *
6848
+ * Callers that want a plain object kept non-reactive can still opt out
6849
+ * explicitly via {@link markRaw}.
6850
+ */
6851
+ static REACTIVE_TYPE_TAGS = new Set([
6852
+ '[object Object]',
6853
+ '[object Array]',
6854
+ '[object Arguments]',
6855
+ '[object Map]',
6856
+ '[object Set]',
6857
+ ]);
6858
+ /**
6859
+ * Whether the given non-null object value is one we deeply proxy.
6860
+ * See {@link REACTIVE_TYPE_TAGS} for the reasoning.
6861
+ */
6862
+ static isReactivable(value) {
6863
+ return this.REACTIVE_TYPE_TAGS.has(Object.prototype.toString.call(value));
6864
+ }
6865
+ /**
6866
+ * Creates a reactive proxy for the given object.
6867
+ * The proxy will call the onChange callback whenever a property is modified.
6868
+ *
6869
+ * @param target The object to make reactive.
6870
+ * @param onChange Callback function to call when the object changes. Receives the full path of the changed property.
6871
+ * @param path The current path in the object tree (used internally for nested objects).
6872
+ * @param inheritedDispatcher Internal: the dispatcher inherited from an enclosing create() call when wrapping a nested target. External callers must omit this.
6873
+ * @returns A reactive proxy of the target object.
6874
+ */
6875
+ static create(target, onChange, path = '', inheritedDispatcher) {
6876
+ // If the target is not an object or is null, return it as-is
6877
+ if (typeof target !== 'object' || target === null) {
6878
+ return target;
6879
+ }
6880
+ // Don't wrap objects marked as raw (non-reactive)
6881
+ if (this.rawObjects.has(target)) {
6882
+ return target;
6883
+ }
6884
+ // Only deeply proxy plain data containers; return every built-in/host
6885
+ // object (Date, File, Blob, Promise, WeakMap, DOM nodes, …) as-is, since
6886
+ // their native methods break when invoked through a Proxy receiver.
6887
+ if (!this.isReactivable(target)) {
6888
+ return target;
6889
+ }
6890
+ // Check if the target is already a proxy - if so, return it as-is to prevent double-wrapping
6891
+ if (this.proxyToTarget.has(target)) {
6892
+ return target;
6893
+ }
6894
+ // Resolve (and register) the dispatcher for this (target, path).
6895
+ // Nested create() calls inherit the dispatcher from the enclosing call so
6896
+ // that a single dispatcher fans out changes at any depth of the subtree.
6897
+ const dispatcher = this.resolveDispatcher(target, path, inheritedDispatcher);
6898
+ if (onChange) {
6899
+ dispatcher.listeners.add(onChange);
6900
+ }
6901
+ // Check if we already have a proxy for this target with this path
6902
+ let pathMap = this.proxyCache.get(target);
6903
+ if (pathMap) {
6904
+ const existingProxy = pathMap.get(path);
6905
+ if (existingProxy) {
6906
+ return existingProxy;
6907
+ }
6908
+ }
6909
+ else {
6910
+ pathMap = new Map();
6911
+ this.proxyCache.set(target, pathMap);
6912
+ }
6913
+ // Create the proxy with path captured in closure
6914
+ const proxy = new Proxy(target, {
6915
+ get(obj, key, receiver) {
6916
+ const value = Reflect.get(obj, key);
6917
+ // Record the read for an expression being tracked (see track()).
6918
+ // Function-valued members (push, Map#get, …) are accesses to behaviour,
6919
+ // not data, so they are left out.
6920
+ if (ReactiveProxy.tracker && typeof key !== 'symbol' && typeof value !== 'function') {
6921
+ ReactiveProxy.tracker.add(ReactiveProxy.joinPath(path, key));
6922
+ }
6923
+ // If the value is an object or array, make it reactive too
6924
+ if (typeof value === 'object' && value !== null) {
6925
+ // Don't wrap objects marked as raw (non-reactive)
6926
+ if (ReactiveProxy.rawObjects.has(value)) {
6927
+ return value;
6928
+ }
6929
+ // Only deeply proxy plain data containers; hand back built-in
6930
+ // and host objects (Date, File, Blob, Promise, WeakMap, DOM
6931
+ // nodes, …) untouched so their native methods keep working.
6932
+ if (!ReactiveProxy.isReactivable(value)) {
6933
+ return value;
6934
+ }
6935
+ // Build the nested path
6936
+ const nestedPath = ReactiveProxy.joinPath(path, key);
6937
+ return ReactiveProxy.create(value, undefined, nestedPath, dispatcher);
6938
+ }
6939
+ // If the value is a function, we need to wrap it to ensure that any mutations it performs also trigger onChange
6940
+ if (typeof value === 'function') {
6941
+ // For arrays, we only want to wrap mutation methods, not read methods like 'slice', 'concat', etc.
6942
+ if (Array.isArray(obj)) {
6943
+ const arrayMutationMethods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'];
6944
+ if (!arrayMutationMethods.includes(key)) {
6945
+ return value;
6946
+ }
6947
+ return function (...args) {
6948
+ const result = value.apply(this === receiver ? obj : this, args);
6949
+ ReactiveProxy.dispatch(dispatcher, path || undefined);
6950
+ return result;
6951
+ };
6952
+ }
6953
+ // For Map, we only want to wrap mutation methods, not read methods like 'get' or 'has'
6954
+ if (obj.constructor.name === 'Map') {
6955
+ const mapMutationMethods = ['set', 'delete', 'clear'];
6956
+ return function (...args) {
6957
+ const result = value.apply(this === receiver ? obj : this, args);
6958
+ if (mapMutationMethods.includes(key)) {
6959
+ ReactiveProxy.dispatch(dispatcher, path || undefined);
6960
+ }
6961
+ return result;
6962
+ };
6963
+ }
6964
+ // For Set, we only want to wrap mutation methods, not read methods like 'has'
6965
+ if (obj.constructor.name === 'Set') {
6966
+ const setMutationMethods = ['add', 'delete', 'clear'];
6967
+ return function (...args) {
6968
+ const result = value.apply(this === receiver ? obj : this, args);
6969
+ if (setMutationMethods.includes(key)) {
6970
+ ReactiveProxy.dispatch(dispatcher, path || undefined);
6971
+ }
6972
+ return result;
6973
+ };
6974
+ }
6975
+ }
6976
+ return value;
6977
+ },
6978
+ set(obj, key, value) {
6979
+ const oldValue = Reflect.get(obj, key);
6980
+ const result = Reflect.set(obj, key, value);
6981
+ // Only trigger onChange if the value actually changed
6982
+ if (oldValue !== value) {
6983
+ ReactiveProxy.dispatch(dispatcher, ReactiveProxy.joinPath(path, key));
6984
+ }
6985
+ return result;
6986
+ },
6987
+ deleteProperty(obj, key) {
6988
+ const result = Reflect.deleteProperty(obj, key);
6989
+ ReactiveProxy.dispatch(dispatcher, ReactiveProxy.joinPath(path, key));
6990
+ return result;
6991
+ }
6992
+ });
6993
+ // Cache the proxy for this path
6994
+ pathMap.set(path, proxy);
6995
+ // Track that this proxy wraps the target to prevent double-wrapping
6996
+ this.proxyToTarget.set(proxy, target);
6997
+ // Store the path for this proxy (for computed property mapping)
6998
+ if (path) {
6999
+ this.proxyPaths.set(proxy, path);
7000
+ }
7001
+ return proxy;
7002
+ }
7003
+ /**
7004
+ * Looks up the dispatcher associated with (target, path), or installs a new one.
7005
+ * When called for a nested target during proxy walking, the enclosing dispatcher
7006
+ * is reused so a single subtree fans out changes through one notification path.
7007
+ */
7008
+ static resolveDispatcher(target, path, inheritedDispatcher) {
7009
+ let pathMap = this.dispatchers.get(target);
7010
+ if (!pathMap) {
7011
+ pathMap = new Map();
7012
+ this.dispatchers.set(target, pathMap);
7013
+ }
7014
+ const existing = pathMap.get(path);
7015
+ if (existing) {
7016
+ return existing;
7017
+ }
7018
+ const dispatcher = inheritedDispatcher ?? { listeners: new Set() };
7019
+ pathMap.set(path, dispatcher);
7020
+ return dispatcher;
7021
+ }
7022
+ /**
7023
+ * Invokes every listener attached to the dispatcher.
7024
+ * Iterates a snapshot of the listener set so unsubscribing during dispatch is safe.
7025
+ */
7026
+ static dispatch(dispatcher, changedPath) {
7027
+ if (dispatcher.listeners.size === 0) {
7028
+ return;
7029
+ }
7030
+ const snapshot = Array.from(dispatcher.listeners);
7031
+ for (const listener of snapshot) {
7032
+ listener(changedPath);
7033
+ }
7034
+ }
7035
+ /**
7036
+ * Subscribes a listener to changes inside the subtree of an existing reactive proxy.
7037
+ *
7038
+ * The listener is scoped by the proxy's source path: only changes at or below that
7039
+ * path are delivered, which lets a child component receive notifications when the
7040
+ * nested contents of a prop change even though the prop reference itself is unchanged.
7041
+ *
7042
+ * @param proxyOrTarget A proxy returned from create(), or the underlying target object.
7043
+ * @param listener Called with the full source path of every relevant change.
7044
+ * @returns A function that removes the subscription.
7045
+ */
7046
+ static subscribe(proxyOrTarget, listener) {
7047
+ if (typeof proxyOrTarget !== 'object' || proxyOrTarget === null) {
7048
+ return () => { };
7049
+ }
7050
+ const target = (this.proxyToTarget.get(proxyOrTarget) ?? proxyOrTarget);
7051
+ const scopePath = this.proxyPaths.get(proxyOrTarget) ?? '';
7052
+ const pathMap = this.dispatchers.get(target);
7053
+ const dispatcher = pathMap?.get(scopePath);
7054
+ if (!dispatcher) {
7055
+ return () => { };
7056
+ }
7057
+ const wrapper = scopePath
7058
+ ? (changedPath) => {
7059
+ if (changedPath === scopePath ||
7060
+ (typeof changedPath === 'string' && (changedPath.startsWith(scopePath + '.') ||
7061
+ changedPath.startsWith(scopePath + '[')))) {
7062
+ listener(changedPath);
7063
+ }
7064
+ }
7065
+ : listener;
7066
+ dispatcher.listeners.add(wrapper);
7067
+ return () => {
7068
+ dispatcher.listeners.delete(wrapper);
7069
+ };
7070
+ }
7071
+ /**
7072
+ * Checks if the given object is a reactive proxy.
7073
+ *
7074
+ * @param obj The object to check.
7075
+ * @returns True if the object is a reactive proxy, false otherwise.
7076
+ */
7077
+ static isReactive(obj) {
7078
+ return this.proxyCache.has(obj);
7079
+ }
7080
+ /**
7081
+ * Unwraps a reactive proxy to get the original object.
7082
+ * If the object is not a proxy, returns it as-is.
7083
+ *
7084
+ * @param obj The object to unwrap.
7085
+ * @returns The original object.
7086
+ */
7087
+ static unwrap(obj) {
7088
+ // This is a simplified implementation
7089
+ // In a full implementation, we'd need to store a reverse mapping
7090
+ return obj;
7091
+ }
7092
+ /**
7093
+ * Marks an object as "raw" (non-reactive).
7094
+ * Objects marked as raw will not be wrapped with Proxy when accessed from reactive objects.
7095
+ * This is useful for objects that should not be reactive, such as:
7096
+ * - Objects with private fields (class instances with # fields)
7097
+ * - Third-party library instances
7098
+ * - Objects used only for method calls
7099
+ *
7100
+ * @param obj The object to mark as raw.
7101
+ * @returns The same object (for chaining).
7102
+ */
7103
+ static markRaw(obj) {
7104
+ if (typeof obj === 'object' && obj !== null) {
7105
+ this.rawObjects.add(obj);
7106
+ }
7107
+ return obj;
7108
+ }
7109
+ /**
7110
+ * Checks if an object is marked as raw (non-reactive).
7111
+ *
7112
+ * @param obj The object to check.
7113
+ * @returns True if the object is marked as raw, false otherwise.
7114
+ */
7115
+ static isRaw(obj) {
7116
+ return typeof obj === 'object' && obj !== null && this.rawObjects.has(obj);
7117
+ }
7118
+ /**
7119
+ * Gets the source path for a proxy object.
7120
+ * This is used to map computed property values back to their source paths.
7121
+ * For example, if a computed property returns `model.elements[0]`,
7122
+ * this method returns "model.elements[0]" for that object.
7123
+ *
7124
+ * @param obj The proxy object to get the path for.
7125
+ * @returns The source path, or undefined if not found.
7126
+ */
7127
+ static getPath(obj) {
7128
+ if (typeof obj !== 'object' || obj === null) {
7129
+ return undefined;
7130
+ }
7131
+ return this.proxyPaths.get(obj);
7132
+ }
7133
+ /**
7134
+ * Registers a path alias.
7135
+ * This is used when a variable is assigned to reference an existing reactive object.
7136
+ * For example, when `editingNestedStep = routes[0].steps[0]`, this creates an alias
7137
+ * so that changes to "routes[0].steps[0].steps" also match "editingNestedStep.steps".
7138
+ *
7139
+ * @param aliasPath The alias path (e.g., "editingNestedStep")
7140
+ * @param sourcePath The source path (e.g., "routes[0].steps[0]")
7141
+ */
7142
+ static registerPathAlias(aliasPath, sourcePath) {
7143
+ if (aliasPath && sourcePath && aliasPath !== sourcePath) {
7144
+ this.pathAliases.set(aliasPath, sourcePath);
7145
+ }
7146
+ }
7147
+ /**
7148
+ * Unregisters a path alias.
7149
+ *
7150
+ * @param aliasPath The alias path to remove
7151
+ */
7152
+ static unregisterPathAlias(aliasPath) {
7153
+ // Remove all aliases that start with the given path
7154
+ for (const key of this.pathAliases.keys()) {
7155
+ if (key === aliasPath || key.startsWith(aliasPath + '.') || key.startsWith(aliasPath + '[')) {
7156
+ this.pathAliases.delete(key);
7157
+ }
7158
+ }
7159
+ }
7160
+ /**
7161
+ * Resolves an alias path to its source path.
7162
+ * Also handles nested paths (e.g., "editingNestedStep.steps" -> "routes[0].steps[0].steps").
7163
+ *
7164
+ * @param aliasPath The alias path to resolve
7165
+ * @returns The source path, or undefined if no alias exists
7166
+ */
7167
+ static resolvePathAlias(aliasPath) {
7168
+ // Direct match
7169
+ if (this.pathAliases.has(aliasPath)) {
7170
+ return this.pathAliases.get(aliasPath);
7171
+ }
7172
+ // Check if this is a nested path of an aliased variable
7173
+ // e.g., "editingNestedStep.steps" when "editingNestedStep" is aliased
7174
+ for (const [alias, source] of this.pathAliases.entries()) {
7175
+ if (aliasPath.startsWith(alias + '.') || aliasPath.startsWith(alias + '[')) {
7176
+ const suffix = aliasPath.substring(alias.length);
7177
+ return source + suffix;
7178
+ }
7179
+ }
7180
+ return undefined;
7181
+ }
7182
+ /**
7183
+ * Checks if a change path matches an identifier, considering path aliases.
7184
+ * For example, if "editingNestedStep" is aliased to "routes[0].steps[0]",
7185
+ * then a change to "routes[0].steps[0].steps" should match "editingNestedStep.steps".
7186
+ *
7187
+ * @param changePath The path that was changed (e.g., "routes[0].steps[0].steps")
7188
+ * @param identifier The identifier to check (e.g., "editingNestedStep.steps")
7189
+ * @returns True if the change path matches the identifier
7190
+ */
7191
+ static doesChangeMatchIdentifier(changePath, identifier) {
7192
+ // Direct match
7193
+ if (this.pathMatches(changePath, identifier)) {
7194
+ return true;
7195
+ }
7196
+ // Check if the change path is the resolved identifier or lies below it
7197
+ // (handles cases like array item changes)
7198
+ const resolvedIdentifier = this.resolvePathAlias(identifier);
7199
+ if (resolvedIdentifier && this.pathMatches(changePath, resolvedIdentifier, true)) {
7200
+ return true;
7201
+ }
7202
+ return false;
7203
+ }
7204
+ /**
7205
+ * Checks whether a change path names the location an identifier refers to.
7206
+ * A "[*]" index in the identifier (e.g. "items[*].name" for `items[i].name`)
7207
+ * stands for an index known only when the expression runs and matches any
7208
+ * one index or key.
7209
+ *
7210
+ * @param changePath The path that was changed (e.g., "items[2].name")
7211
+ * @param identifier The identifier to check (e.g., "items[*].name")
7212
+ * @param descendants Whether a change below the identifier also matches
7213
+ * @returns True if the change path matches the identifier
7214
+ */
7215
+ static pathMatches(changePath, identifier, descendants = false) {
7216
+ if (changePath === identifier) {
7217
+ return true;
7218
+ }
7219
+ if (!identifier.includes('[*]')) {
7220
+ return descendants && (changePath.startsWith(identifier + '.') || changePath.startsWith(identifier + '['));
7221
+ }
7222
+ const cacheKey = descendants ? identifier + ' ' : identifier;
7223
+ let pattern = this.pathPatterns.get(cacheKey);
7224
+ if (!pattern) {
7225
+ const source = identifier
7226
+ .split('[*]')
7227
+ .map(part => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
7228
+ .join('(?:\\[[^\\]]*\\]|\\.[^.[]+)');
7229
+ pattern = new RegExp(`^${source}${descendants ? '(?:[.[].*)?' : ''}$`);
7230
+ this.pathPatterns.set(cacheKey, pattern);
7231
+ }
7232
+ return pattern.test(changePath);
7233
+ }
7234
+ }
7235
+
6735
7236
  // Copyright (c) 2025 MintJams Inc. Licensed under MIT License.
6736
7237
  /**
6737
7238
  * Utility class for analyzing JavaScript expressions to extract variable and function dependencies.
@@ -6766,28 +7267,28 @@
6766
7267
  }
6767
7268
  }
6768
7269
  else if (node.type === 'MemberExpression') {
6769
- // Reconstruct chain parts from a MemberExpression (e.g. a.b.c)
7270
+ // Reconstruct chain parts from a MemberExpression (e.g. a.b.c). Each part
7271
+ // carries its own separator so that the chain is spelled the way the reactive
7272
+ // proxy reports changes: array indices in brackets, properties after a dot
7273
+ // (e.g. "items[0].name").
6770
7274
  const parts = [];
6771
7275
  let cur = node;
6772
- let stop = false;
6773
- while (cur && !stop) {
7276
+ while (cur) {
6774
7277
  // property part (right side)
6775
7278
  if (cur.property) {
6776
7279
  if (!cur.computed && cur.property.type === 'Identifier') {
6777
- parts.unshift(cur.property.name);
7280
+ parts.unshift(`.${cur.property.name}`);
6778
7281
  }
6779
7282
  else if (cur.computed && cur.property.type === 'Literal') {
6780
- parts.unshift(String(cur.property.value));
6781
- }
6782
- else if (cur.computed && cur.property.type === 'Identifier') {
6783
- // e.g. obj[prop] -> include the prop identifier (it will also be added separately)
6784
- parts.unshift(cur.property.name);
7283
+ // items[0] and items["0"] both name an index; labels['a-b'] names a key.
7284
+ const key = String(cur.property.value);
7285
+ parts.unshift(ReactiveProxy.isIndexKey(key) ? `[${key}]` : `.${key}`);
6785
7286
  }
6786
- else {
6787
- // unknown property shape (e.g. expression) — stop adding chain here
6788
- // inner expressions will be visited separately by walk.full
6789
- stop = true;
6790
- break;
7287
+ else if (cur.computed) {
7288
+ // e.g. items[i] or items[i + 1]: the index is only known when the
7289
+ // expression runs, so "[*]" stands for any index or key. Identifiers
7290
+ // inside the index expression are visited separately by walk.full.
7291
+ parts.unshift('[*]');
6791
7292
  }
6792
7293
  }
6793
7294
  // object part (left side)
@@ -6807,7 +7308,6 @@
6807
7308
  }
6808
7309
  else {
6809
7310
  // other object types (CallExpression, etc.) — stop here
6810
- stop = true;
6811
7311
  break;
6812
7312
  }
6813
7313
  }
@@ -6815,10 +7315,15 @@
6815
7315
  break;
6816
7316
  }
6817
7317
  }
6818
- if (parts.length > 0) {
6819
- // Add progressive chains: 'a', 'a.b', 'a.b.c'
7318
+ // A chain that does not start at a name (e.g. `this.a.b` or `f().b`) begins
7319
+ // with its first property; one that begins with an index names no binding.
7320
+ if (parts.length > 0 && parts[0].startsWith('.')) {
7321
+ parts[0] = parts[0].substring(1);
7322
+ }
7323
+ if (parts.length > 0 && !parts[0].startsWith('[')) {
7324
+ // Add progressive chains: 'a', 'a.b', 'a.b[0]', 'a.b[0].c'
6820
7325
  for (let i = 0; i < parts.length; i++) {
6821
- const chain = parts.slice(0, i + 1).join('.');
7326
+ const chain = parts.slice(0, i + 1).join('');
6822
7327
  identifiers.add(chain);
6823
7328
  // Also apply functionDependencies lookup for the chain key
6824
7329
  if (functionDependencies[chain]) {
@@ -6832,6 +7337,16 @@
6832
7337
  });
6833
7338
  return Array.from(identifiers);
6834
7339
  }
7340
+ /**
7341
+ * Returns the binding name an identifier chain starts with
7342
+ * (e.g. "items" for "items[0].name" or "user.name").
7343
+ * @param identifier An identifier or member chain returned by extractIdentifiers.
7344
+ * @returns The left-most name of the chain.
7345
+ */
7346
+ static baseIdentifier(identifier) {
7347
+ const end = identifier.search(/[.[]/);
7348
+ return end === -1 ? identifier : identifier.substring(0, end);
7349
+ }
6835
7350
  /**
6836
7351
  * Analyzes the dependencies of functions in the provided dictionary.
6837
7352
  * @param functions A dictionary mapping function names to their implementations.
@@ -7118,718 +7633,336 @@
7118
7633
  return;
7119
7634
  }
7120
7635
  // Skip identifiers that are the property (not the object) of a non-computed
7121
- // member access (e.g., the `prop` in `obj.prop`).
7122
- if (parentNode?.type === 'MemberExpression' && !parentNode.computed && parentNode.property === node) {
7123
- return;
7124
- }
7125
- // Add to replacements list (adjust for the wrapping parentheses in expression mode)
7126
- replacements.push({
7127
- start: node.start - offsetShift,
7128
- end: node.end - offsetShift,
7129
- name: node.name
7130
- });
7131
- });
7132
- // Sort replacements by start position (descending) to replace from end to start
7133
- replacements.sort((a, b) => b.start - a.start);
7134
- // Apply replacements
7135
- let result = expression;
7136
- for (const replacement of replacements) {
7137
- const insertion = replacement.asThisAlias ? 'this' : `this.${replacement.name}`;
7138
- result = result.substring(0, replacement.start) + insertion + result.substring(replacement.end);
7139
- }
7140
- return result;
7141
- }
7142
- catch (error) {
7143
- // If AST parsing fails, fall back to the original expression
7144
- console.warn('Failed to rewrite expression:', expression, error);
7145
- return expression;
7146
- }
7147
- }
7148
- }
7149
-
7150
- // Copyright (c) 2025 MintJams Inc. Licensed under MIT License.
7151
- /**
7152
- * A centralized expression evaluator that normalizes expression evaluation across all directives.
7153
- *
7154
- * This class provides:
7155
- * - Consistent identifier extraction and binding resolution
7156
- * - Unified global object whitelisting (Math, Date, JSON, etc.)
7157
- * - Expression caching for performance
7158
- * - Centralized error handling
7159
- * - Support for custom expression rewriting (for VOnDirective)
7160
- *
7161
- * Example usage:
7162
- * ```typescript
7163
- * const evaluator = ExpressionEvaluator.create(
7164
- * "count + 1",
7165
- * bindings,
7166
- * functionDependencies
7167
- * );
7168
- * const result = evaluator.evaluate();
7169
- * ```
7170
- */
7171
- class ExpressionEvaluator {
7172
- /**
7173
- * Cache for compiled expression functions.
7174
- * Key: expression string
7175
- * Value: compiled function
7176
- */
7177
- static cache = new Map();
7178
- /**
7179
- * Global objects that are whitelisted for use in expressions.
7180
- * These objects are available to all expressions for convenience and compatibility.
7181
- */
7182
- static GLOBAL_WHITELIST = {
7183
- Math,
7184
- Date,
7185
- JSON,
7186
- Array,
7187
- Object,
7188
- String,
7189
- Number,
7190
- Boolean,
7191
- parseInt,
7192
- parseFloat,
7193
- isNaN,
7194
- isFinite,
7195
- encodeURI,
7196
- decodeURI,
7197
- encodeURIComponent,
7198
- decodeURIComponent
7199
- };
7200
- /**
7201
- * The original expression string.
7202
- */
7203
- expression;
7204
- /**
7205
- * The identifiers extracted from the expression.
7206
- */
7207
- identifiers;
7208
- // The original extracted identifiers (may include member chains like 'a.b')
7209
- originalIdentifiers;
7210
- /**
7211
- * The bindings to use for evaluating the expression.
7212
- */
7213
- bindings;
7214
- /**
7215
- * The compiled function for evaluating the expression.
7216
- */
7217
- evaluatorFunc;
7218
- /**
7219
- * Additional context variables (e.g., 'event', '$ctx').
7220
- */
7221
- additionalContext;
7222
- /**
7223
- * Private constructor. Use ExpressionEvaluator.create() to create instances.
7224
- */
7225
- constructor(expression, bindings, identifiers, originalIdentifiers, evaluatorFunc, additionalContext) {
7226
- this.expression = expression;
7227
- this.bindings = bindings;
7228
- this.identifiers = identifiers;
7229
- this.originalIdentifiers = originalIdentifiers;
7230
- this.evaluatorFunc = evaluatorFunc;
7231
- this.additionalContext = additionalContext;
7232
- }
7233
- /**
7234
- * Creates an ExpressionEvaluator instance.
7235
- *
7236
- * @param expression The expression string to evaluate.
7237
- * @param bindings The bindings to use for evaluating the expression.
7238
- * @param functionDependencies A dictionary mapping function names to their dependencies.
7239
- * @param options Optional configuration for the evaluator.
7240
- * @returns An ExpressionEvaluator instance.
7241
- */
7242
- static create(expression, bindings, functionDependencies, options) {
7243
- // Extract identifiers from the expression (may include member chains like 'a.b')
7244
- const extractedIdentifiers = ExpressionUtils.extractIdentifiers(expression, functionDependencies);
7245
- // For compilation we must only pass simple identifier names (no dots).
7246
- // Use the base (left-most) segment of member chains and preserve order.
7247
- const baseIdentifiers = [];
7248
- for (const id of extractedIdentifiers) {
7249
- const base = id.split('.')[0];
7250
- if (!baseIdentifiers.includes(base)) {
7251
- baseIdentifiers.push(base);
7252
- }
7253
- }
7254
- // Apply custom rewrite if provided (used by VOnDirective)
7255
- let processedExpression = expression;
7256
- if (options?.rewriteExpression) {
7257
- // Pass the original extracted identifiers (may include member chains)
7258
- processedExpression = options.rewriteExpression(expression, extractedIdentifiers);
7259
- }
7260
- // Build cache key including options that affect compilation
7261
- const cacheKey = JSON.stringify({
7262
- expr: processedExpression,
7263
- additionalCtx: options?.additionalContext ? Object.keys(options.additionalContext).sort() : []
7264
- });
7265
- // Check cache
7266
- let evaluatorFunc = this.cache.get(cacheKey);
7267
- if (!evaluatorFunc) {
7268
- // Compile the expression using base identifiers (no dots)
7269
- evaluatorFunc = this.compileExpression(processedExpression, baseIdentifiers, options);
7270
- // Cache the compiled function
7271
- this.cache.set(cacheKey, evaluatorFunc);
7272
- }
7273
- return new ExpressionEvaluator(expression, bindings, baseIdentifiers, extractedIdentifiers, evaluatorFunc, options?.additionalContext);
7274
- }
7275
- /**
7276
- * Compiles an expression into a function.
7277
- *
7278
- * @param expression The expression string to compile.
7279
- * @param identifiers The identifiers extracted from the expression.
7280
- * @param options Optional configuration.
7281
- * @returns A compiled function.
7282
- */
7283
- static compileExpression(expression, identifiers, options) {
7284
- // Build parameter list: globals first, then identifiers (base names), then additional context
7285
- // Filter out identifiers that are already in the global whitelist to avoid duplicate parameters
7286
- const globalKeys = new Set(Object.keys(ExpressionEvaluator.GLOBAL_WHITELIST));
7287
- const filteredIdentifiers = identifiers.filter(id => !globalKeys.has(id));
7288
- const params = [
7289
- ...Object.keys(ExpressionEvaluator.GLOBAL_WHITELIST),
7290
- ...filteredIdentifiers
7291
- ];
7292
- // Add additional context parameters if provided
7293
- if (options?.additionalContext) {
7294
- params.push(...Object.keys(options.additionalContext));
7295
- }
7296
- const args = params.join(", ");
7297
- const funcBody = `return (${expression});`;
7298
- try {
7299
- // Create the function with error handling
7300
- return new Function(args, funcBody);
7301
- }
7302
- catch (error) {
7303
- throw new Error(`Failed to compile expression: ${expression}\nError: ${error}`);
7304
- }
7305
- }
7306
- /**
7307
- * Evaluates the expression with the current bindings.
7308
- *
7309
- * @returns The result of the expression evaluation.
7310
- */
7311
- evaluate() {
7312
- try {
7313
- // Build arguments: globals first, then identifiers, then additional context
7314
- // Filter out identifiers that are already in the global whitelist to match compileExpression
7315
- const globalKeys = new Set(Object.keys(ExpressionEvaluator.GLOBAL_WHITELIST));
7316
- const filteredIdentifiers = this.identifiers.filter(id => !globalKeys.has(id));
7317
- const values = [
7318
- // Global whitelist values
7319
- ...Object.values(ExpressionEvaluator.GLOBAL_WHITELIST),
7320
- // Identifier values from bindings (excluding globals)
7321
- ...filteredIdentifiers.map(id => this.bindings.get(id))
7322
- ];
7323
- // Add additional context values if provided
7324
- if (this.additionalContext) {
7325
- values.push(...Object.values(this.additionalContext));
7636
+ // member access (e.g., the `prop` in `obj.prop`).
7637
+ if (parentNode?.type === 'MemberExpression' && !parentNode.computed && parentNode.property === node) {
7638
+ return;
7639
+ }
7640
+ // Add to replacements list (adjust for the wrapping parentheses in expression mode)
7641
+ replacements.push({
7642
+ start: node.start - offsetShift,
7643
+ end: node.end - offsetShift,
7644
+ name: node.name
7645
+ });
7646
+ });
7647
+ // Sort replacements by start position (descending) to replace from end to start
7648
+ replacements.sort((a, b) => b.start - a.start);
7649
+ // Apply replacements
7650
+ let result = expression;
7651
+ for (const replacement of replacements) {
7652
+ const insertion = replacement.asThisAlias ? 'this' : `this.${replacement.name}`;
7653
+ result = result.substring(0, replacement.start) + insertion + result.substring(replacement.end);
7326
7654
  }
7327
- // Call the compiled function with the gathered values
7328
- return this.evaluatorFunc(...values);
7655
+ return result;
7329
7656
  }
7330
7657
  catch (error) {
7331
- console.error(`[ichigo.js] Error evaluating expression: ${this.expression}`, error);
7332
- throw error;
7658
+ // If AST parsing fails, fall back to the original expression
7659
+ console.warn('Failed to rewrite expression:', expression, error);
7660
+ return expression;
7333
7661
  }
7334
7662
  }
7335
- /**
7336
- * Evaluates the expression and returns the result as a boolean.
7337
- *
7338
- * @returns The boolean result of the expression evaluation.
7339
- */
7340
- evaluateAsBoolean() {
7341
- return Boolean(this.evaluate());
7342
- }
7343
- /**
7344
- * Gets the list of identifiers extracted from the expression.
7345
- */
7346
- get dependentIdentifiers() {
7347
- // Return the original extracted identifiers (may include member chains like 'a.b')
7348
- return this.originalIdentifiers;
7349
- }
7350
- /**
7351
- * Clears the expression cache.
7352
- * This should be called sparingly, as it will force recompilation of all expressions.
7353
- */
7354
- static clearCache() {
7355
- this.cache.clear();
7356
- }
7357
7663
  }
7358
7664
 
7359
7665
  // Copyright (c) 2025 MintJams Inc. Licensed under MIT License.
7360
7666
  /**
7361
- * Directive for binding dynamic attributes to DOM elements.
7362
- * The `v-bind` directive allows you to bind HTML attributes to expressions in your data model.
7363
- * The syntax for using the `v-bind` directive is `v-bind:attribute="expression"`, where `attribute` is the name of the HTML attribute you want to bind (e.g., `src`, `href`, `class`, etc.), and `expression` is a JavaScript expression that evaluates to the value you want to assign to that attribute.
7667
+ * A centralized expression evaluator that normalizes expression evaluation across all directives.
7668
+ *
7669
+ * This class provides:
7670
+ * - Consistent identifier extraction and binding resolution
7671
+ * - Unified global object whitelisting (Math, Date, JSON, etc.)
7672
+ * - Expression caching for performance
7673
+ * - Centralized error handling
7674
+ * - Support for custom expression rewriting (for VOnDirective)
7675
+ *
7364
7676
  * Example usage:
7365
- * <img v-bind:src="imageSrc" />
7366
- * The value of `imageSrc` should be defined in the component's data object.
7367
- * When the value of `imageSrc` changes, the `src` attribute of the `<img>` element will automatically update to reflect the new value.
7368
- * This directive is particularly useful for dynamically updating attributes based on user interactions or other data changes in your application.
7369
- * It helps keep the DOM in sync with the underlying data model, making it easier to build reactive user interfaces.
7370
- * The `v-bind` directive can also be used with shorthand syntax using a colon (:).
7371
- * For example, `:src="imageSrc"` is equivalent to `v-bind:src="imageSrc"`.
7372
- * This shorthand syntax is often used for brevity and improved readability in templates.
7373
- * Overall, the `v-bind` directive is a powerful tool for creating dynamic and responsive web applications by allowing seamless integration between the data model and the DOM.
7677
+ * ```typescript
7678
+ * const evaluator = ExpressionEvaluator.create(
7679
+ * "count + 1",
7680
+ * bindings,
7681
+ * functionDependencies
7682
+ * );
7683
+ * const result = evaluator.evaluate();
7684
+ * ```
7374
7685
  */
7375
- class VBindDirective {
7376
- /**
7377
- * The virtual node to which this directive is applied.
7378
- */
7379
- #vNode;
7380
- /**
7381
- * The expression evaluator for this directive.
7382
- */
7383
- #evaluator;
7384
- /**
7385
- * The name of the attribute to bind (e.g., "src", "class", "style").
7386
- */
7387
- #attributeName;
7388
- /**
7389
- * The original expression string from the directive.
7390
- */
7391
- #expression;
7392
- /**
7393
- * The set of class names managed by this directive (used when binding to the "class" attribute).
7394
- * This helps in tracking which classes were added by this directive to avoid conflicts with other class manipulations.
7395
- */
7396
- #managedClasses = new Set();
7397
- /**
7398
- * The set of style properties managed by this directive (used when binding to the "style" attribute).
7399
- * This helps in tracking which styles were added by this directive to avoid conflicts with other style manipulations.
7400
- */
7401
- #managedStyles = new Set();
7402
- /**
7403
- * @param context The context for parsing the directive.
7404
- */
7405
- constructor(context) {
7406
- this.#vNode = context.vNode;
7407
- // Extract the attribute name from the directive
7408
- // e.g., "v-bind:src" -> "src", ":href" -> "href"
7409
- const attrName = context.attribute.name;
7410
- if (attrName.startsWith('v-bind:')) {
7411
- this.#attributeName = attrName.substring(7);
7412
- }
7413
- else if (attrName.startsWith(':')) {
7414
- this.#attributeName = attrName.substring(1);
7415
- }
7416
- // Parse the expression and create the evaluator
7417
- this.#expression = context.attribute.value;
7418
- if (this.#expression && context.vNode.bindings) {
7419
- this.#evaluator = ExpressionEvaluator.create(this.#expression, context.vNode.bindings, context.vNode.vApplication.functionDependencies);
7420
- }
7421
- // Remove the directive attribute from the element
7422
- this.#vNode.node.removeAttribute(context.attribute.name);
7423
- }
7424
- /**
7425
- * @inheritdoc
7426
- */
7427
- get name() {
7428
- return StandardDirectiveName.V_BIND;
7429
- }
7430
- /**
7431
- * @inheritdoc
7432
- */
7433
- get vNode() {
7434
- return this.#vNode;
7435
- }
7436
- /**
7437
- * @inheritdoc
7438
- */
7439
- get needsAnchor() {
7440
- return false;
7441
- }
7442
- /**
7443
- * @inheritdoc
7444
- */
7445
- get bindingsPreparer() {
7446
- return undefined;
7447
- }
7448
- /**
7449
- * @inheritdoc
7450
- */
7451
- get domUpdater() {
7452
- const identifiers = this.#evaluator?.dependentIdentifiers ?? [];
7453
- // Create an updater that handles the attribute binding
7454
- const updater = {
7455
- get dependentIdentifiers() {
7456
- return identifiers;
7457
- },
7458
- applyToDOM: () => {
7459
- this.#render();
7460
- }
7461
- };
7462
- return updater;
7463
- }
7464
- /**
7465
- * @inheritdoc
7466
- */
7467
- get templatize() {
7468
- return false;
7469
- }
7470
- /**
7471
- * @inheritdoc
7472
- */
7473
- get dependentIdentifiers() {
7474
- return this.#evaluator?.dependentIdentifiers ?? [];
7475
- }
7476
- /**
7477
- * Indicates if this directive is binding the "key" attribute.
7478
- * The "key" attribute is special and is used for optimizing rendering of lists.
7479
- * If this directive is binding the "key" attribute, it will be handled by the VForDirective.
7480
- */
7481
- get isKey() {
7482
- return (this.#attributeName === 'key');
7483
- }
7484
- /**
7485
- * Indicates if this directive is binding the "options" attribute or any of its sub-properties (e.g., "options.intersection").
7486
- * The "options" attribute is special and is used for passing options to certain directives like VIntersectionDirective.
7487
- */
7488
- get isOptions() {
7489
- return (this.#attributeName === 'options' || this.#attributeName?.startsWith('options.') === true);
7490
- }
7491
- /**
7492
- * Gets the name of the attribute being bound (e.g., "src", "class", "options", "options.intersection").
7493
- */
7494
- get attributeName() {
7495
- return this.#attributeName;
7496
- }
7497
- /**
7498
- * Gets the original expression string from the directive.
7499
- */
7500
- get expression() {
7501
- return this.#expression;
7502
- }
7503
- /**
7504
- * Evaluates the bound expression and returns the current value.
7505
- * Returns undefined when no evaluator is available (e.g., empty expression).
7506
- */
7507
- evaluate() {
7508
- return this.#evaluator?.evaluate();
7509
- }
7510
- /**
7511
- * @inheritdoc
7512
- */
7513
- get onMount() {
7514
- // A constant expression (e.g. :label="'Apple'") has no reactive
7515
- // identifiers, so the identifier-driven update cycle never applies it.
7516
- // Render it once at mount instead.
7517
- if (this.#evaluator && this.#evaluator.dependentIdentifiers.length === 0) {
7518
- return () => {
7519
- this.#render();
7520
- };
7521
- }
7522
- return undefined;
7523
- }
7686
+ class ExpressionEvaluator {
7524
7687
  /**
7525
- * @inheritdoc
7688
+ * Cache for compiled expression functions.
7689
+ * Key: expression string
7690
+ * Value: compiled function
7526
7691
  */
7527
- get onMounted() {
7528
- return undefined;
7529
- }
7692
+ static cache = new Map();
7530
7693
  /**
7531
- * @inheritdoc
7694
+ * Global objects that are whitelisted for use in expressions.
7695
+ * These objects are available to all expressions for convenience and compatibility.
7532
7696
  */
7533
- get onUpdate() {
7534
- return undefined;
7535
- }
7697
+ static GLOBAL_WHITELIST = {
7698
+ Math,
7699
+ Date,
7700
+ JSON,
7701
+ Array,
7702
+ Object,
7703
+ String,
7704
+ Number,
7705
+ Boolean,
7706
+ parseInt,
7707
+ parseFloat,
7708
+ isNaN,
7709
+ isFinite,
7710
+ encodeURI,
7711
+ decodeURI,
7712
+ encodeURIComponent,
7713
+ decodeURIComponent
7714
+ };
7536
7715
  /**
7537
- * @inheritdoc
7716
+ * The original expression string.
7538
7717
  */
7539
- get onUpdated() {
7540
- return undefined;
7541
- }
7718
+ expression;
7542
7719
  /**
7543
- * @inheritdoc
7720
+ * The identifiers extracted from the expression.
7544
7721
  */
7545
- get onUnmount() {
7546
- return undefined;
7547
- }
7722
+ identifiers;
7723
+ // The original extracted identifiers (may include member chains like 'a.b')
7724
+ originalIdentifiers;
7548
7725
  /**
7549
- * @inheritdoc
7726
+ * The bindings to use for evaluating the expression.
7550
7727
  */
7551
- get onUnmounted() {
7552
- return undefined;
7553
- }
7728
+ bindings;
7554
7729
  /**
7555
- * @inheritdoc
7730
+ * The compiled function for evaluating the expression.
7556
7731
  */
7557
- destroy() {
7558
- // Do nothing. No special cleanup needed.
7559
- }
7732
+ evaluatorFunc;
7560
7733
  /**
7561
- * Renders the bound attribute by evaluating the expression and updating the DOM element.
7734
+ * Additional context variables (e.g., 'event', '$ctx').
7562
7735
  */
7563
- #render() {
7564
- // Do nothing for special attributes
7565
- if (this.isKey || this.isOptions) {
7566
- return;
7567
- }
7568
- const element = this.#vNode.node;
7569
- const attributeName = this.#attributeName;
7570
- // If there's no evaluator or attribute name, do nothing
7571
- if (!this.#evaluator || !attributeName) {
7572
- return;
7573
- }
7574
- // Evaluate the expression to get the value
7575
- const value = this.#evaluator.evaluate();
7576
- // Handle different attribute types
7577
- if (attributeName === 'class') {
7578
- this.#updateClass(element, value);
7579
- }
7580
- else if (attributeName === 'style') {
7581
- this.#updateStyle(element, value);
7582
- }
7583
- else if (this.#isCustomElementProperty(element, attributeName, value)) {
7584
- // Custom elements: set objects/arrays (and declared props) as properties
7585
- // so complex values are not serialized to strings via setAttribute.
7586
- // HTML attributes are lowercased by the browser, so resolve to the
7587
- // actual camelCase property name declared on the custom element.
7588
- const propName = this.#resolveCustomElementPropertyName(element, attributeName);
7589
- element[propName] = value;
7590
- }
7591
- else if (this.#isDOMProperty(attributeName)) {
7592
- this.#updateProperty(element, attributeName, value);
7593
- }
7594
- else if (this.#isBooleanAttribute(attributeName)) {
7595
- this.#updateBooleanAttribute(element, attributeName, value);
7596
- }
7597
- else {
7598
- this.#updateAttribute(element, attributeName, value);
7599
- }
7600
- }
7736
+ additionalContext;
7601
7737
  /**
7602
- * Updates the class attribute with support for string, array, and object formats.
7738
+ * Whether any identifier has an index known only at run time ("items[*].name").
7739
+ * Only then are the paths read during evaluation worth recording.
7603
7740
  */
7604
- #updateClass(element, value) {
7605
- // Determine the new set of classes to apply
7606
- let newClasses = [];
7607
- if (typeof value === 'string') {
7608
- newClasses = value.split(/\s+/).filter(Boolean);
7609
- }
7610
- else if (Array.isArray(value)) {
7611
- // Flatten array elements that may contain strings or objects
7612
- newClasses = value
7613
- .filter(Boolean)
7614
- .flatMap(cls => {
7615
- if (typeof cls === 'string') {
7616
- return cls.split(/\s+/).filter(Boolean);
7617
- }
7618
- else if (typeof cls === 'object' && cls !== null) {
7619
- // Handle object format within array: { className: condition }
7620
- // Keys may contain space-separated class names
7621
- return Object.keys(cls).filter(key => cls[key]).flatMap(key => key.split(/\s+/).filter(Boolean));
7622
- }
7623
- return [];
7624
- });
7625
- }
7626
- else if (typeof value === 'object' && value !== null) {
7627
- // Keys may contain space-separated class names
7628
- newClasses = Object.keys(value).filter(key => value[key]).flatMap(key => key.split(/\s+/).filter(Boolean));
7629
- }
7630
- // Remove previously managed classes
7631
- this.#managedClasses.forEach(cls => element.classList.remove(cls));
7632
- // Add newly managed classes
7633
- newClasses.forEach(cls => element.classList.add(cls));
7634
- // Update managed classes list
7635
- this.#managedClasses = new Set(newClasses);
7636
- }
7741
+ hasDynamicIndex;
7637
7742
  /**
7638
- * Updates the style attribute with support for object format.
7743
+ * The source paths read through reactive proxies during the last evaluation,
7744
+ * or undefined before the first evaluation and when nothing is recorded.
7639
7745
  */
7640
- #updateStyle(element, value) {
7641
- let newStyles = [];
7642
- if (typeof value === 'string') {
7643
- // Directly set the style string
7644
- element.style.cssText = value;
7645
- // Extract managed properties
7646
- newStyles = value.split(';').map(s => s.split(':')[0].trim()).filter(Boolean);
7647
- }
7648
- else if (typeof value === 'object' && value !== null) {
7649
- // Remove all previously managed properties
7650
- this.#managedStyles.forEach(prop => {
7651
- element.style.removeProperty(this.#camelToKebab(prop));
7652
- });
7653
- // Add newly managed properties
7654
- for (const key in value) {
7655
- if (Object.prototype.hasOwnProperty.call(value, key)) {
7656
- const cssKey = this.#camelToKebab(key);
7657
- const cssValue = value[key];
7658
- if (cssValue != null) {
7659
- element.style.setProperty(cssKey, String(cssValue));
7660
- newStyles.push(key);
7661
- }
7662
- }
7663
- }
7664
- }
7665
- // Update managed properties list
7666
- this.#managedStyles = new Set(newStyles);
7667
- }
7746
+ trackedPathSet;
7668
7747
  /**
7669
- * Converts camelCase to kebab-case for CSS properties.
7748
+ * Private constructor. Use ExpressionEvaluator.create() to create instances.
7670
7749
  */
7671
- #camelToKebab(str) {
7672
- return str.replace(/([A-Z])/g, '-$1').toLowerCase();
7750
+ constructor(expression, bindings, identifiers, originalIdentifiers, evaluatorFunc, additionalContext) {
7751
+ this.expression = expression;
7752
+ this.bindings = bindings;
7753
+ this.identifiers = identifiers;
7754
+ this.originalIdentifiers = originalIdentifiers;
7755
+ this.evaluatorFunc = evaluatorFunc;
7756
+ this.additionalContext = additionalContext;
7757
+ this.hasDynamicIndex = originalIdentifiers.some(id => id.includes('[*]'));
7673
7758
  }
7674
7759
  /**
7675
- * Returns true when the target is a custom element and the binding should be
7676
- * delivered as a property rather than an HTML attribute.
7760
+ * Creates an ExpressionEvaluator instance.
7677
7761
  *
7678
- * Three conditions trigger property delivery:
7679
- * 1. The value is an object or array — serialising these to a string attribute
7680
- * would lose type information.
7681
- * 2. The element exposes a matching property accessor (e.g. a prop declared via
7682
- * defineComponent), even when the value is a primitive.
7683
- * 3. The element declares a matching prop in its static _props (case-insensitive
7684
- * and kebab-case → camelCase tolerant), so primitive values reach the
7685
- * reactive binding via the generated setter instead of being lost as a
7686
- * plain HTML attribute that no attributeChangedCallback observes.
7762
+ * @param expression The expression string to evaluate.
7763
+ * @param bindings The bindings to use for evaluating the expression.
7764
+ * @param functionDependencies A dictionary mapping function names to their dependencies.
7765
+ * @param options Optional configuration for the evaluator.
7766
+ * @returns An ExpressionEvaluator instance.
7687
7767
  */
7688
- #isCustomElementProperty(element, name, value) {
7689
- if (!element.tagName.includes('-')) {
7690
- return false;
7768
+ static create(expression, bindings, functionDependencies, options) {
7769
+ // Extract identifiers from the expression (may include member chains like 'a.b')
7770
+ const extractedIdentifiers = ExpressionUtils.extractIdentifiers(expression, functionDependencies);
7771
+ // For compilation we must only pass simple identifier names (no dots).
7772
+ // Use the base (left-most) segment of member chains and preserve order.
7773
+ const baseIdentifiers = [];
7774
+ for (const id of extractedIdentifiers) {
7775
+ const base = ExpressionUtils.baseIdentifier(id);
7776
+ if (!baseIdentifiers.includes(base)) {
7777
+ baseIdentifiers.push(base);
7778
+ }
7691
7779
  }
7692
- const isObjectOrArray = Array.isArray(value) || (typeof value === 'object' && value !== null);
7693
- if (isObjectOrArray || name in element) {
7694
- return true;
7780
+ // Apply custom rewrite if provided (used by VOnDirective)
7781
+ let processedExpression = expression;
7782
+ if (options?.rewriteExpression) {
7783
+ // Pass the original extracted identifiers (may include member chains)
7784
+ processedExpression = options.rewriteExpression(expression, extractedIdentifiers);
7695
7785
  }
7696
- return this.#findDeclaredProp(element, name) !== undefined;
7697
- }
7698
- /**
7699
- * Resolves the actual property name on a custom element for a given
7700
- * (lowercased) HTML attribute name. HTML attributes are always lowercase,
7701
- * but custom element props are typically camelCase. This method checks the
7702
- * element's declared _props (set by defineComponent) for a case-insensitive
7703
- * match against both the raw attribute name and its kebab-case → camelCase
7704
- * conversion (so `user-name` resolves to `userName`), falling back to the
7705
- * original name when no match is found.
7706
- */
7707
- #resolveCustomElementPropertyName(element, name) {
7708
- // Fast path: exact match already exists
7709
- if (name in element) {
7710
- return name;
7786
+ // Build cache key including options that affect compilation
7787
+ const cacheKey = JSON.stringify({
7788
+ expr: processedExpression,
7789
+ additionalCtx: options?.additionalContext ? Object.keys(options.additionalContext).sort() : []
7790
+ });
7791
+ // Check cache
7792
+ let evaluatorFunc = this.cache.get(cacheKey);
7793
+ if (!evaluatorFunc) {
7794
+ // Compile the expression using base identifiers (no dots)
7795
+ evaluatorFunc = this.compileExpression(processedExpression, baseIdentifiers, options);
7796
+ // Cache the compiled function
7797
+ this.cache.set(cacheKey, evaluatorFunc);
7711
7798
  }
7712
- return this.#findDeclaredProp(element, name) ?? name;
7799
+ return new ExpressionEvaluator(expression, bindings, baseIdentifiers, extractedIdentifiers, evaluatorFunc, options?.additionalContext);
7713
7800
  }
7714
7801
  /**
7715
- * Looks up a declared prop on a custom element constructor whose name matches
7716
- * the given attribute name, treating the comparison as case-insensitive and
7717
- * tolerant of kebab-case → camelCase conversion. Returns the canonical prop
7718
- * name (as declared) when a match is found, or undefined otherwise.
7802
+ * Compiles an expression into a function.
7803
+ *
7804
+ * @param expression The expression string to compile.
7805
+ * @param identifiers The identifiers extracted from the expression.
7806
+ * @param options Optional configuration.
7807
+ * @returns A compiled function.
7719
7808
  */
7720
- #findDeclaredProp(element, name) {
7721
- const props = element.constructor._props;
7722
- if (!Array.isArray(props)) {
7723
- return undefined;
7809
+ static compileExpression(expression, identifiers, options) {
7810
+ // Build parameter list: globals first, then identifiers (base names), then additional context
7811
+ // Filter out identifiers that are already in the global whitelist to avoid duplicate parameters
7812
+ const globalKeys = new Set(Object.keys(ExpressionEvaluator.GLOBAL_WHITELIST));
7813
+ const filteredIdentifiers = identifiers.filter(id => !globalKeys.has(id));
7814
+ const params = [
7815
+ ...Object.keys(ExpressionEvaluator.GLOBAL_WHITELIST),
7816
+ ...filteredIdentifiers
7817
+ ];
7818
+ // Add additional context parameters if provided
7819
+ if (options?.additionalContext) {
7820
+ params.push(...Object.keys(options.additionalContext));
7821
+ }
7822
+ const args = params.join(", ");
7823
+ const funcBody = `return (${expression});`;
7824
+ try {
7825
+ // Create the function with error handling
7826
+ return new Function(args, funcBody);
7827
+ }
7828
+ catch (error) {
7829
+ throw new Error(`Failed to compile expression: ${expression}\nError: ${error}`);
7724
7830
  }
7725
- const lowerName = name.toLowerCase();
7726
- const camelLower = this.#kebabToCamel(lowerName).toLowerCase();
7727
- return props.find(p => {
7728
- const lowerProp = p.toLowerCase();
7729
- return lowerProp === lowerName || lowerProp === camelLower;
7730
- });
7731
- }
7732
- /**
7733
- * Converts kebab-case to camelCase (e.g. `user-name` → `userName`).
7734
- */
7735
- #kebabToCamel(str) {
7736
- return str.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase());
7737
7831
  }
7738
7832
  /**
7739
- * Checks if the attribute should be set as a DOM property.
7833
+ * Evaluates the expression with the current bindings.
7834
+ *
7835
+ * @returns The result of the expression evaluation.
7740
7836
  */
7741
- #isDOMProperty(name) {
7742
- const domProperties = ['value', 'checked', 'selected', 'innerHTML', 'textContent'];
7743
- return domProperties.includes(name);
7837
+ evaluate() {
7838
+ try {
7839
+ // Build arguments: globals first, then identifiers, then additional context
7840
+ // Filter out identifiers that are already in the global whitelist to match compileExpression
7841
+ const globalKeys = new Set(Object.keys(ExpressionEvaluator.GLOBAL_WHITELIST));
7842
+ const filteredIdentifiers = this.identifiers.filter(id => !globalKeys.has(id));
7843
+ const values = [
7844
+ // Global whitelist values
7845
+ ...Object.values(ExpressionEvaluator.GLOBAL_WHITELIST),
7846
+ // Identifier values from bindings (excluding globals)
7847
+ ...filteredIdentifiers.map(id => this.bindings.get(id))
7848
+ ];
7849
+ // Add additional context values if provided
7850
+ if (this.additionalContext) {
7851
+ values.push(...Object.values(this.additionalContext));
7852
+ }
7853
+ // Call the compiled function with the gathered values. When an index is
7854
+ // known only at run time, record which concrete paths this evaluation
7855
+ // read so that change matching can be narrowed to them (see trackedPaths).
7856
+ if (!this.hasDynamicIndex) {
7857
+ return this.evaluatorFunc(...values);
7858
+ }
7859
+ const paths = new Set();
7860
+ this.trackedPathSet = undefined;
7861
+ const result = ReactiveProxy.track(paths, () => this.evaluatorFunc(...values));
7862
+ this.trackedPathSet = paths;
7863
+ return result;
7864
+ }
7865
+ catch (error) {
7866
+ console.error(`[ichigo.js] Error evaluating expression: ${this.expression}`, error);
7867
+ throw error;
7868
+ }
7744
7869
  }
7745
7870
  /**
7746
- * Checks if the attribute is a boolean attribute.
7871
+ * The source paths read through reactive proxies during the last evaluation,
7872
+ * recorded only for an expression with a run-time index ("items[i].name").
7873
+ * Each evaluation replaces the previous set, so a path read for a former
7874
+ * index value is dropped as soon as the expression runs with the new one.
7875
+ * Undefined before the first evaluation and for expressions without such an index.
7747
7876
  */
7748
- #isBooleanAttribute(name) {
7749
- const booleanAttributes = [
7750
- 'disabled', 'readonly', 'required', 'checked', 'selected',
7751
- 'multiple', 'autofocus', 'autoplay', 'controls', 'loop',
7752
- 'muted', 'open', 'hidden'
7753
- ];
7754
- return booleanAttributes.includes(name);
7877
+ get trackedPaths() {
7878
+ return this.trackedPathSet;
7755
7879
  }
7756
7880
  /**
7757
- * Updates a DOM property.
7758
- * For value-like properties (value, textContent, innerHTML), null/undefined is converted to empty string
7759
- * to match Vue.js behavior and prevent "undefined" from being displayed.
7881
+ * Evaluates the expression and returns the result as a boolean.
7882
+ *
7883
+ * @returns The boolean result of the expression evaluation.
7760
7884
  */
7761
- #updateProperty(element, name, value) {
7762
- if (value == null && (name === 'value' || name === 'textContent' || name === 'innerHTML')) {
7763
- element[name] = '';
7764
- if (name === 'value') {
7765
- element._value = null;
7766
- }
7767
- }
7768
- else {
7769
- element[name] = value;
7770
- if (name === 'value') {
7771
- // Store the original typed value so that v-model on radio buttons
7772
- // can preserve the type (e.g., boolean false instead of string "false").
7773
- element._value = value;
7774
- }
7775
- }
7885
+ evaluateAsBoolean() {
7886
+ return Boolean(this.evaluate());
7776
7887
  }
7777
7888
  /**
7778
- * Updates a boolean attribute.
7889
+ * Gets the list of identifiers extracted from the expression.
7779
7890
  */
7780
- #updateBooleanAttribute(element, name, value) {
7781
- if (value) {
7782
- element.setAttribute(name, '');
7783
- }
7784
- else {
7785
- element.removeAttribute(name);
7786
- }
7891
+ get dependentIdentifiers() {
7892
+ // Return the original extracted identifiers (may include member chains like 'a.b')
7893
+ return this.originalIdentifiers;
7787
7894
  }
7788
7895
  /**
7789
- * Updates a regular attribute.
7790
- * An attribute that already holds the value is left alone: setting `src` on
7791
- * an <iframe>, even to the same URL, loads its page again.
7896
+ * Clears the expression cache.
7897
+ * This should be called sparingly, as it will force recompilation of all expressions.
7792
7898
  */
7793
- #updateAttribute(element, name, value) {
7794
- if (value == null || value === false) {
7795
- if (element.hasAttribute(name)) {
7796
- element.removeAttribute(name);
7797
- }
7798
- }
7799
- else {
7800
- const text = String(value);
7801
- if (element.getAttribute(name) !== text) {
7802
- element.setAttribute(name, text);
7803
- }
7804
- }
7899
+ static clearCache() {
7900
+ this.cache.clear();
7805
7901
  }
7806
7902
  }
7807
7903
 
7808
7904
  // Copyright (c) 2025 MintJams Inc. Licensed under MIT License.
7809
7905
  /**
7810
- * Directive for rendering components.
7811
- * Usage: <div v-component="componentId" :options="props"></div>
7812
- *
7813
- * The :options binding is used to pass properties to the component.
7814
- * Example:
7815
- * <div v-component="my-component" :options="{message: 'Hello'}"></div>
7906
+ * Directive for binding dynamic attributes to DOM elements.
7907
+ * The `v-bind` directive allows you to bind HTML attributes to expressions in your data model.
7908
+ * The syntax for using the `v-bind` directive is `v-bind:attribute="expression"`, where `attribute` is the name of the HTML attribute you want to bind (e.g., `src`, `href`, `class`, etc.), and `expression` is a JavaScript expression that evaluates to the value you want to assign to that attribute.
7909
+ * Example usage:
7910
+ * <img v-bind:src="imageSrc" />
7911
+ * The value of `imageSrc` should be defined in the component's data object.
7912
+ * When the value of `imageSrc` changes, the `src` attribute of the `<img>` element will automatically update to reflect the new value.
7913
+ * This directive is particularly useful for dynamically updating attributes based on user interactions or other data changes in your application.
7914
+ * It helps keep the DOM in sync with the underlying data model, making it easier to build reactive user interfaces.
7915
+ * The `v-bind` directive can also be used with shorthand syntax using a colon (:).
7916
+ * For example, `:src="imageSrc"` is equivalent to `v-bind:src="imageSrc"`.
7917
+ * This shorthand syntax is often used for brevity and improved readability in templates.
7918
+ * Overall, the `v-bind` directive is a powerful tool for creating dynamic and responsive web applications by allowing seamless integration between the data model and the DOM.
7816
7919
  */
7817
- class VComponentDirective {
7920
+ class VBindDirective {
7818
7921
  /**
7819
7922
  * The virtual node to which this directive is applied.
7820
7923
  */
7821
7924
  #vNode;
7822
7925
  /**
7823
- * The component ID to render.
7926
+ * The expression evaluator for this directive.
7824
7927
  */
7825
- #componentId;
7928
+ #evaluator;
7826
7929
  /**
7827
- * The application instance for the component.
7930
+ * The name of the attribute to bind (e.g., "src", "class", "style").
7931
+ */
7932
+ #attributeName;
7933
+ /**
7934
+ * The original expression string from the directive.
7935
+ */
7936
+ #expression;
7937
+ /**
7938
+ * The set of class names managed by this directive (used when binding to the "class" attribute).
7939
+ * This helps in tracking which classes were added by this directive to avoid conflicts with other class manipulations.
7940
+ */
7941
+ #managedClasses = new Set();
7942
+ /**
7943
+ * The set of style properties managed by this directive (used when binding to the "style" attribute).
7944
+ * This helps in tracking which styles were added by this directive to avoid conflicts with other style manipulations.
7945
+ */
7946
+ #managedStyles = new Set();
7947
+ /**
7948
+ * @param context The context for parsing the directive.
7828
7949
  */
7829
- #componentApp;
7830
7950
  constructor(context) {
7831
7951
  this.#vNode = context.vNode;
7832
- this.#componentId = context.attribute.value.trim();
7952
+ // Extract the attribute name from the directive
7953
+ // e.g., "v-bind:src" -> "src", ":href" -> "href"
7954
+ const attrName = context.attribute.name;
7955
+ if (attrName.startsWith('v-bind:')) {
7956
+ this.#attributeName = attrName.substring(7);
7957
+ }
7958
+ else if (attrName.startsWith(':')) {
7959
+ this.#attributeName = attrName.substring(1);
7960
+ }
7961
+ // Parse the expression and create the evaluator
7962
+ this.#expression = context.attribute.value;
7963
+ if (this.#expression && context.vNode.bindings) {
7964
+ this.#evaluator = ExpressionEvaluator.create(this.#expression, context.vNode.bindings, context.vNode.vApplication.functionDependencies);
7965
+ }
7833
7966
  // Remove the directive attribute from the element
7834
7967
  this.#vNode.node.removeAttribute(context.attribute.name);
7835
7968
  }
@@ -7837,7 +7970,7 @@
7837
7970
  * @inheritdoc
7838
7971
  */
7839
7972
  get name() {
7840
- return StandardDirectiveName.V_COMPONENT;
7973
+ return StandardDirectiveName.V_BIND;
7841
7974
  }
7842
7975
  /**
7843
7976
  * @inheritdoc
@@ -7849,7 +7982,7 @@
7849
7982
  * @inheritdoc
7850
7983
  */
7851
7984
  get needsAnchor() {
7852
- return true;
7985
+ return false;
7853
7986
  }
7854
7987
  /**
7855
7988
  * @inheritdoc
@@ -7861,10 +7994,15 @@
7861
7994
  * @inheritdoc
7862
7995
  */
7863
7996
  get domUpdater() {
7864
- // Create and return the DOM updater
7997
+ const identifiers = this.#evaluator?.dependentIdentifiers ?? [];
7998
+ const evaluator = this.#evaluator;
7999
+ // Create an updater that handles the attribute binding
7865
8000
  const updater = {
7866
8001
  get dependentIdentifiers() {
7867
- return [];
8002
+ return identifiers;
8003
+ },
8004
+ get trackedPaths() {
8005
+ return evaluator?.trackedPaths;
7868
8006
  },
7869
8007
  applyToDOM: () => {
7870
8008
  this.#render();
@@ -7876,18 +8014,60 @@
7876
8014
  * @inheritdoc
7877
8015
  */
7878
8016
  get templatize() {
7879
- return true;
8017
+ return false;
7880
8018
  }
7881
8019
  /**
7882
8020
  * @inheritdoc
7883
8021
  */
7884
8022
  get dependentIdentifiers() {
7885
- return [];
8023
+ return this.#evaluator?.dependentIdentifiers ?? [];
8024
+ }
8025
+ /**
8026
+ * Indicates if this directive is binding the "key" attribute.
8027
+ * The "key" attribute is special and is used for optimizing rendering of lists.
8028
+ * If this directive is binding the "key" attribute, it will be handled by the VForDirective.
8029
+ */
8030
+ get isKey() {
8031
+ return (this.#attributeName === 'key');
8032
+ }
8033
+ /**
8034
+ * Indicates if this directive is binding the "options" attribute or any of its sub-properties (e.g., "options.intersection").
8035
+ * The "options" attribute is special and is used for passing options to certain directives like VIntersectionDirective.
8036
+ */
8037
+ get isOptions() {
8038
+ return (this.#attributeName === 'options' || this.#attributeName?.startsWith('options.') === true);
8039
+ }
8040
+ /**
8041
+ * Gets the name of the attribute being bound (e.g., "src", "class", "options", "options.intersection").
8042
+ */
8043
+ get attributeName() {
8044
+ return this.#attributeName;
8045
+ }
8046
+ /**
8047
+ * Gets the original expression string from the directive.
8048
+ */
8049
+ get expression() {
8050
+ return this.#expression;
8051
+ }
8052
+ /**
8053
+ * Evaluates the bound expression and returns the current value.
8054
+ * Returns undefined when no evaluator is available (e.g., empty expression).
8055
+ */
8056
+ evaluate() {
8057
+ return this.#evaluator?.evaluate();
7886
8058
  }
7887
8059
  /**
7888
8060
  * @inheritdoc
7889
8061
  */
7890
8062
  get onMount() {
8063
+ // A constant expression (e.g. :label="'Apple'") has no reactive
8064
+ // identifiers, so the identifier-driven update cycle never applies it.
8065
+ // Render it once at mount instead.
8066
+ if (this.#evaluator && this.#evaluator.dependentIdentifiers.length === 0) {
8067
+ return () => {
8068
+ this.#render();
8069
+ };
8070
+ }
7891
8071
  return undefined;
7892
8072
  }
7893
8073
  /**
@@ -7924,501 +8104,449 @@
7924
8104
  * @inheritdoc
7925
8105
  */
7926
8106
  destroy() {
7927
- if (!this.#componentApp) {
7928
- // Not rendered, no action needed
8107
+ // Do nothing. No special cleanup needed.
8108
+ }
8109
+ /**
8110
+ * Renders the bound attribute by evaluating the expression and updating the DOM element.
8111
+ */
8112
+ #render() {
8113
+ // Do nothing for special attributes
8114
+ if (this.isKey || this.isOptions) {
7929
8115
  return;
7930
8116
  }
7931
- // Get the component's root VNode
7932
- const componentVNode = this.#componentApp.rootVNode;
7933
- // Destroy component application first (calls @unmount hooks while DOM is still accessible)
7934
- this.#componentApp.unmount();
7935
- // Remove the component's root node from the DOM
7936
- if (componentVNode?.node.parentNode) {
7937
- componentVNode.node.parentNode.removeChild(componentVNode.node);
8117
+ const element = this.#vNode.node;
8118
+ const attributeName = this.#attributeName;
8119
+ // If there's no evaluator or attribute name, do nothing
8120
+ if (!this.#evaluator || !attributeName) {
8121
+ return;
8122
+ }
8123
+ // Evaluate the expression to get the value
8124
+ const value = this.#evaluator.evaluate();
8125
+ // Handle different attribute types
8126
+ if (attributeName === 'class') {
8127
+ this.#updateClass(element, value);
8128
+ }
8129
+ else if (attributeName === 'style') {
8130
+ this.#updateStyle(element, value);
8131
+ }
8132
+ else if (this.#isCustomElementProperty(element, attributeName, value)) {
8133
+ // Custom elements: set objects/arrays (and declared props) as properties
8134
+ // so complex values are not serialized to strings via setAttribute.
8135
+ // HTML attributes are lowercased by the browser, so resolve to the
8136
+ // actual camelCase property name declared on the custom element.
8137
+ const propName = this.#resolveCustomElementPropertyName(element, attributeName);
8138
+ element[propName] = value;
8139
+ }
8140
+ else if (this.#isDOMProperty(attributeName)) {
8141
+ this.#updateProperty(element, attributeName, value);
8142
+ }
8143
+ else if (this.#isBooleanAttribute(attributeName)) {
8144
+ this.#updateBooleanAttribute(element, attributeName, value);
8145
+ }
8146
+ else {
8147
+ this.#updateAttribute(element, attributeName, value);
7938
8148
  }
7939
- this.#componentApp = undefined;
7940
8149
  }
7941
8150
  /**
7942
- * Renders the component.
8151
+ * Updates the class attribute with support for string, array, and object formats.
7943
8152
  */
7944
- #render() {
7945
- if (this.#componentApp) {
7946
- // Already rendered, no action needed
7947
- return;
8153
+ #updateClass(element, value) {
8154
+ // Determine the new set of classes to apply
8155
+ let newClasses = [];
8156
+ if (typeof value === 'string') {
8157
+ newClasses = value.split(/\s+/).filter(Boolean);
7948
8158
  }
7949
- // Clone the component's template and replace the original node
7950
- const clone = this.#cloneNode();
7951
- this.#vNode.anchorNode?.parentNode?.insertBefore(clone, this.#vNode.anchorNode.nextSibling);
7952
- // Get properties from :options or :options.component directive
7953
- let properties = {};
7954
- const optionsDirective = this.#vNode.directiveManager?.optionsDirective('component');
7955
- if (optionsDirective && optionsDirective.expression && this.#vNode.bindings) {
7956
- // Evaluate the options expression using ExpressionEvaluator
7957
- const evaluator = ExpressionEvaluator.create(optionsDirective.expression, this.#vNode.bindings, this.#vNode.vApplication.functionDependencies);
7958
- const result = evaluator.evaluate();
7959
- if (typeof result === 'object' && result !== null) {
7960
- properties = result;
7961
- }
8159
+ else if (Array.isArray(value)) {
8160
+ // Flatten array elements that may contain strings or objects
8161
+ newClasses = value
8162
+ .filter(Boolean)
8163
+ .flatMap(cls => {
8164
+ if (typeof cls === 'string') {
8165
+ return cls.split(/\s+/).filter(Boolean);
8166
+ }
8167
+ else if (typeof cls === 'object' && cls !== null) {
8168
+ // Handle object format within array: { className: condition }
8169
+ // Keys may contain space-separated class names
8170
+ return Object.keys(cls).filter(key => cls[key]).flatMap(key => key.split(/\s+/).filter(Boolean));
8171
+ }
8172
+ return [];
8173
+ });
7962
8174
  }
7963
- // Get component definition from the application's component registry
7964
- const component = this.#vNode.vApplication.componentRegistry.get(this.#componentId);
7965
- if (!component) {
7966
- throw new Error(`Component '${this.#componentId}' not found in registry`);
8175
+ else if (typeof value === 'object' && value !== null) {
8176
+ // Keys may contain space-separated class names
8177
+ newClasses = Object.keys(value).filter(key => value[key]).flatMap(key => key.split(/\s+/).filter(Boolean));
7967
8178
  }
7968
- // Create component instance
7969
- const instance = component.createInstance(properties);
7970
- // Create and mount child application using the parent application's registries
7971
- this.#componentApp = this.#vNode.vApplication.createChildApp(instance);
7972
- this.#componentApp.mount(clone);
8179
+ // Remove previously managed classes
8180
+ this.#managedClasses.forEach(cls => element.classList.remove(cls));
8181
+ // Add newly managed classes
8182
+ newClasses.forEach(cls => element.classList.add(cls));
8183
+ // Update managed classes list
8184
+ this.#managedClasses = new Set(newClasses);
7973
8185
  }
7974
8186
  /**
7975
- * Clones the component's template and returns the root element.
7976
- * @returns The cloned root HTMLElement of the component.
7977
- * @throws Error if the component or its template is not found.
8187
+ * Updates the style attribute with support for object format.
7978
8188
  */
7979
- #cloneNode() {
7980
- // Get component definition from the application's component registry
7981
- const component = this.#vNode.vApplication.componentRegistry.get(this.#componentId);
7982
- if (!component) {
7983
- throw new Error(`Component '${this.#componentId}' not found in registry`);
7984
- }
7985
- // Get template element
7986
- const finalTemplateID = component.templateID || component.id;
7987
- const templateElement = document.querySelector(`#${finalTemplateID}`);
7988
- if (!templateElement || !(templateElement instanceof HTMLTemplateElement)) {
7989
- throw new Error(`Template element '#${finalTemplateID}' not found`);
8189
+ #updateStyle(element, value) {
8190
+ let newStyles = [];
8191
+ if (typeof value === 'string') {
8192
+ // Directly set the style string
8193
+ element.style.cssText = value;
8194
+ // Extract managed properties
8195
+ newStyles = value.split(';').map(s => s.split(':')[0].trim()).filter(Boolean);
7990
8196
  }
7991
- // Clone template content
7992
- const fragment = templateElement.content.cloneNode(true);
7993
- const childNodes = Array.from(fragment.childNodes);
7994
- // Find the first element node
7995
- for (const node of childNodes) {
7996
- if (node.nodeType === Node.ELEMENT_NODE) {
7997
- return node;
8197
+ else if (typeof value === 'object' && value !== null) {
8198
+ // Remove all previously managed properties
8199
+ this.#managedStyles.forEach(prop => {
8200
+ element.style.removeProperty(this.#camelToKebab(prop));
8201
+ });
8202
+ // Add newly managed properties
8203
+ for (const key in value) {
8204
+ if (Object.prototype.hasOwnProperty.call(value, key)) {
8205
+ const cssKey = this.#camelToKebab(key);
8206
+ const cssValue = value[key];
8207
+ if (cssValue != null) {
8208
+ element.style.setProperty(cssKey, String(cssValue));
8209
+ newStyles.push(key);
8210
+ }
8211
+ }
7998
8212
  }
7999
8213
  }
8000
- throw new Error(`No element found in template '#${finalTemplateID}'`);
8214
+ // Update managed properties list
8215
+ this.#managedStyles = new Set(newStyles);
8001
8216
  }
8002
- }
8003
-
8004
- // Copyright (c) 2025 MintJams Inc. Licensed under MIT License.
8005
- /**
8006
- * Utility class for creating reactive proxies that automatically track changes.
8007
- */
8008
- class ReactiveProxy {
8009
8217
  /**
8010
- * A WeakMap to store the proxy for each target object and path combination.
8011
- * This prevents creating multiple proxies for the same object accessed from different paths.
8218
+ * Converts camelCase to kebab-case for CSS properties.
8012
8219
  */
8013
- static proxyCache = new WeakMap();
8220
+ #camelToKebab(str) {
8221
+ return str.replace(/([A-Z])/g, '-$1').toLowerCase();
8222
+ }
8014
8223
  /**
8015
- * A WeakMap to track which objects are proxies, mapping proxy -> original target.
8016
- * This prevents double-wrapping of already proxied objects.
8224
+ * Returns true when the target is a custom element and the binding should be
8225
+ * delivered as a property rather than an HTML attribute.
8226
+ *
8227
+ * Three conditions trigger property delivery:
8228
+ * 1. The value is an object or array — serialising these to a string attribute
8229
+ * would lose type information.
8230
+ * 2. The element exposes a matching property accessor (e.g. a prop declared via
8231
+ * defineComponent), even when the value is a primitive.
8232
+ * 3. The element declares a matching prop in its static _props (case-insensitive
8233
+ * and kebab-case → camelCase tolerant), so primitive values reach the
8234
+ * reactive binding via the generated setter instead of being lost as a
8235
+ * plain HTML attribute that no attributeChangedCallback observes.
8017
8236
  */
8018
- static proxyToTarget = new WeakMap();
8237
+ #isCustomElementProperty(element, name, value) {
8238
+ if (!element.tagName.includes('-')) {
8239
+ return false;
8240
+ }
8241
+ const isObjectOrArray = Array.isArray(value) || (typeof value === 'object' && value !== null);
8242
+ if (isObjectOrArray || name in element) {
8243
+ return true;
8244
+ }
8245
+ return this.#findDeclaredProp(element, name) !== undefined;
8246
+ }
8019
8247
  /**
8020
- * A WeakSet to track objects marked as "raw" (non-reactive).
8021
- * These objects will not be wrapped with Proxy.
8248
+ * Resolves the actual property name on a custom element for a given
8249
+ * (lowercased) HTML attribute name. HTML attributes are always lowercase,
8250
+ * but custom element props are typically camelCase. This method checks the
8251
+ * element's declared _props (set by defineComponent) for a case-insensitive
8252
+ * match against both the raw attribute name and its kebab-case → camelCase
8253
+ * conversion (so `user-name` resolves to `userName`), falling back to the
8254
+ * original name when no match is found.
8022
8255
  */
8023
- static rawObjects = new WeakSet();
8256
+ #resolveCustomElementPropertyName(element, name) {
8257
+ // Fast path: exact match already exists
8258
+ if (name in element) {
8259
+ return name;
8260
+ }
8261
+ return this.#findDeclaredProp(element, name) ?? name;
8262
+ }
8024
8263
  /**
8025
- * A WeakMap to store the path for each proxy object.
8026
- * This allows retrieving the source path of an object for computed property mapping.
8264
+ * Looks up a declared prop on a custom element constructor whose name matches
8265
+ * the given attribute name, treating the comparison as case-insensitive and
8266
+ * tolerant of kebab-case → camelCase conversion. Returns the canonical prop
8267
+ * name (as declared) when a match is found, or undefined otherwise.
8027
8268
  */
8028
- static proxyPaths = new WeakMap();
8269
+ #findDeclaredProp(element, name) {
8270
+ const props = element.constructor._props;
8271
+ if (!Array.isArray(props)) {
8272
+ return undefined;
8273
+ }
8274
+ const lowerName = name.toLowerCase();
8275
+ const camelLower = this.#kebabToCamel(lowerName).toLowerCase();
8276
+ return props.find(p => {
8277
+ const lowerProp = p.toLowerCase();
8278
+ return lowerProp === lowerName || lowerProp === camelLower;
8279
+ });
8280
+ }
8029
8281
  /**
8030
- * Dispatchers per (target, path). Every target reached while walking a proxy
8031
- * subtree registers an entry pointing at the same dispatcher as the outermost
8032
- * proxy of that subtree, so callers can look up the dispatcher from any
8033
- * intermediate proxy when subscribing.
8282
+ * Converts kebab-case to camelCase (e.g. `user-name` → `userName`).
8034
8283
  */
8035
- static dispatchers = new WeakMap();
8284
+ #kebabToCamel(str) {
8285
+ return str.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase());
8286
+ }
8036
8287
  /**
8037
- * A Map to store path aliases.
8038
- * Key: alias path (e.g., "editingNestedStep.steps")
8039
- * Value: source path (e.g., "routes[0].steps[0].steps")
8040
- * This allows mapping variable names to their actual source paths for dependency tracking.
8288
+ * Checks if the attribute should be set as a DOM property.
8041
8289
  */
8042
- static pathAliases = new Map();
8290
+ #isDOMProperty(name) {
8291
+ const domProperties = ['value', 'checked', 'selected', 'innerHTML', 'textContent'];
8292
+ return domProperties.includes(name);
8293
+ }
8043
8294
  /**
8044
- * The `Object.prototype.toString` tags of the *only* value types we deeply
8045
- * proxy. Everything else is returned untouched (raw).
8046
- *
8047
- * Rationale: a Proxy can only safely intercept plain data containers. Built-in
8048
- * and host objects — `Date`, `RegExp`, `File`, `Blob`, `FileList`, `Promise`,
8049
- * `WeakMap`/`WeakSet`, typed arrays, DOM nodes, etc. — carry private internal
8050
- * slots, and invoking their native methods with a Proxy as the `this`/receiver
8051
- * throws "Illegal invocation". They must therefore never be wrapped. This is an
8052
- * allow-list rather than a deny-list so that *every* such exotic type is
8053
- * excluded by default, not just the handful we happened to enumerate.
8054
- *
8055
- * Plain user-defined class instances report `[object Object]` (unless they set
8056
- * `Symbol.toStringTag`) and so remain reactive, preserving prior behaviour.
8057
- * `Map`/`Set` stay reactive because the proxy specially wraps their mutation
8058
- * methods (see the `get` trap); `WeakMap`/`WeakSet` are intentionally excluded
8059
- * since they cannot be wrapped that way and would break the same as `File`.
8060
- *
8061
- * Callers that want a plain object kept non-reactive can still opt out
8062
- * explicitly via {@link markRaw}.
8295
+ * Checks if the attribute is a boolean attribute.
8063
8296
  */
8064
- static REACTIVE_TYPE_TAGS = new Set([
8065
- '[object Object]',
8066
- '[object Array]',
8067
- '[object Arguments]',
8068
- '[object Map]',
8069
- '[object Set]',
8070
- ]);
8297
+ #isBooleanAttribute(name) {
8298
+ const booleanAttributes = [
8299
+ 'disabled', 'readonly', 'required', 'checked', 'selected',
8300
+ 'multiple', 'autofocus', 'autoplay', 'controls', 'loop',
8301
+ 'muted', 'open', 'hidden'
8302
+ ];
8303
+ return booleanAttributes.includes(name);
8304
+ }
8071
8305
  /**
8072
- * Whether the given non-null object value is one we deeply proxy.
8073
- * See {@link REACTIVE_TYPE_TAGS} for the reasoning.
8306
+ * Updates a DOM property.
8307
+ * For value-like properties (value, textContent, innerHTML), null/undefined is converted to empty string
8308
+ * to match Vue.js behavior and prevent "undefined" from being displayed.
8074
8309
  */
8075
- static isReactivable(value) {
8076
- return this.REACTIVE_TYPE_TAGS.has(Object.prototype.toString.call(value));
8310
+ #updateProperty(element, name, value) {
8311
+ if (value == null && (name === 'value' || name === 'textContent' || name === 'innerHTML')) {
8312
+ element[name] = '';
8313
+ if (name === 'value') {
8314
+ element._value = null;
8315
+ }
8316
+ }
8317
+ else {
8318
+ element[name] = value;
8319
+ if (name === 'value') {
8320
+ // Store the original typed value so that v-model on radio buttons
8321
+ // can preserve the type (e.g., boolean false instead of string "false").
8322
+ element._value = value;
8323
+ }
8324
+ }
8077
8325
  }
8078
8326
  /**
8079
- * Creates a reactive proxy for the given object.
8080
- * The proxy will call the onChange callback whenever a property is modified.
8081
- *
8082
- * @param target The object to make reactive.
8083
- * @param onChange Callback function to call when the object changes. Receives the full path of the changed property.
8084
- * @param path The current path in the object tree (used internally for nested objects).
8085
- * @param inheritedDispatcher Internal: the dispatcher inherited from an enclosing create() call when wrapping a nested target. External callers must omit this.
8086
- * @returns A reactive proxy of the target object.
8327
+ * Updates a boolean attribute.
8087
8328
  */
8088
- static create(target, onChange, path = '', inheritedDispatcher) {
8089
- // If the target is not an object or is null, return it as-is
8090
- if (typeof target !== 'object' || target === null) {
8091
- return target;
8092
- }
8093
- // Don't wrap objects marked as raw (non-reactive)
8094
- if (this.rawObjects.has(target)) {
8095
- return target;
8096
- }
8097
- // Only deeply proxy plain data containers; return every built-in/host
8098
- // object (Date, File, Blob, Promise, WeakMap, DOM nodes, …) as-is, since
8099
- // their native methods break when invoked through a Proxy receiver.
8100
- if (!this.isReactivable(target)) {
8101
- return target;
8102
- }
8103
- // Check if the target is already a proxy - if so, return it as-is to prevent double-wrapping
8104
- if (this.proxyToTarget.has(target)) {
8105
- return target;
8329
+ #updateBooleanAttribute(element, name, value) {
8330
+ if (value) {
8331
+ element.setAttribute(name, '');
8106
8332
  }
8107
- // Resolve (and register) the dispatcher for this (target, path).
8108
- // Nested create() calls inherit the dispatcher from the enclosing call so
8109
- // that a single dispatcher fans out changes at any depth of the subtree.
8110
- const dispatcher = this.resolveDispatcher(target, path, inheritedDispatcher);
8111
- if (onChange) {
8112
- dispatcher.listeners.add(onChange);
8333
+ else {
8334
+ element.removeAttribute(name);
8113
8335
  }
8114
- // Check if we already have a proxy for this target with this path
8115
- let pathMap = this.proxyCache.get(target);
8116
- if (pathMap) {
8117
- const existingProxy = pathMap.get(path);
8118
- if (existingProxy) {
8119
- return existingProxy;
8336
+ }
8337
+ /**
8338
+ * Updates a regular attribute.
8339
+ * An attribute that already holds the value is left alone: setting `src` on
8340
+ * an <iframe>, even to the same URL, loads its page again.
8341
+ */
8342
+ #updateAttribute(element, name, value) {
8343
+ if (value == null || value === false) {
8344
+ if (element.hasAttribute(name)) {
8345
+ element.removeAttribute(name);
8120
8346
  }
8121
8347
  }
8122
8348
  else {
8123
- pathMap = new Map();
8124
- this.proxyCache.set(target, pathMap);
8125
- }
8126
- // Create the proxy with path captured in closure
8127
- const proxy = new Proxy(target, {
8128
- get(obj, key, receiver) {
8129
- const value = Reflect.get(obj, key);
8130
- // If the value is an object or array, make it reactive too
8131
- if (typeof value === 'object' && value !== null) {
8132
- // Don't wrap objects marked as raw (non-reactive)
8133
- if (ReactiveProxy.rawObjects.has(value)) {
8134
- return value;
8135
- }
8136
- // Only deeply proxy plain data containers; hand back built-in
8137
- // and host objects (Date, File, Blob, Promise, WeakMap, DOM
8138
- // nodes, …) untouched so their native methods keep working.
8139
- if (!ReactiveProxy.isReactivable(value)) {
8140
- return value;
8141
- }
8142
- // Build the nested path
8143
- const keyStr = String(key);
8144
- const nestedPath = path ? (Array.isArray(obj) ? `${path}[${keyStr}]` : `${path}.${keyStr}`) : keyStr;
8145
- return ReactiveProxy.create(value, undefined, nestedPath, dispatcher);
8146
- }
8147
- // If the value is a function, we need to wrap it to ensure that any mutations it performs also trigger onChange
8148
- if (typeof value === 'function') {
8149
- // For arrays, we only want to wrap mutation methods, not read methods like 'slice', 'concat', etc.
8150
- if (Array.isArray(obj)) {
8151
- const arrayMutationMethods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'];
8152
- if (!arrayMutationMethods.includes(key)) {
8153
- return value;
8154
- }
8155
- return function (...args) {
8156
- const result = value.apply(this === receiver ? obj : this, args);
8157
- ReactiveProxy.dispatch(dispatcher, path || undefined);
8158
- return result;
8159
- };
8160
- }
8161
- // For Map, we only want to wrap mutation methods, not read methods like 'get' or 'has'
8162
- if (obj.constructor.name === 'Map') {
8163
- const mapMutationMethods = ['set', 'delete', 'clear'];
8164
- return function (...args) {
8165
- const result = value.apply(this === receiver ? obj : this, args);
8166
- if (mapMutationMethods.includes(key)) {
8167
- ReactiveProxy.dispatch(dispatcher, path || undefined);
8168
- }
8169
- return result;
8170
- };
8171
- }
8172
- // For Set, we only want to wrap mutation methods, not read methods like 'has'
8173
- if (obj.constructor.name === 'Set') {
8174
- const setMutationMethods = ['add', 'delete', 'clear'];
8175
- return function (...args) {
8176
- const result = value.apply(this === receiver ? obj : this, args);
8177
- if (setMutationMethods.includes(key)) {
8178
- ReactiveProxy.dispatch(dispatcher, path || undefined);
8179
- }
8180
- return result;
8181
- };
8182
- }
8183
- }
8184
- return value;
8185
- },
8186
- set(obj, key, value) {
8187
- const oldValue = Reflect.get(obj, key);
8188
- const result = Reflect.set(obj, key, value);
8189
- // Only trigger onChange if the value actually changed
8190
- if (oldValue !== value) {
8191
- const keyStr = String(key);
8192
- const fullPath = path ? (Array.isArray(obj) ? `${path}[${keyStr}]` : `${path}.${keyStr}`) : keyStr;
8193
- ReactiveProxy.dispatch(dispatcher, fullPath);
8194
- }
8195
- return result;
8196
- },
8197
- deleteProperty(obj, key) {
8198
- const result = Reflect.deleteProperty(obj, key);
8199
- const keyStr = String(key);
8200
- const fullPath = path ? (Array.isArray(obj) ? `${path}[${keyStr}]` : `${path}.${keyStr}`) : keyStr;
8201
- ReactiveProxy.dispatch(dispatcher, fullPath);
8202
- return result;
8349
+ const text = String(value);
8350
+ if (element.getAttribute(name) !== text) {
8351
+ element.setAttribute(name, text);
8203
8352
  }
8204
- });
8205
- // Cache the proxy for this path
8206
- pathMap.set(path, proxy);
8207
- // Track that this proxy wraps the target to prevent double-wrapping
8208
- this.proxyToTarget.set(proxy, target);
8209
- // Store the path for this proxy (for computed property mapping)
8210
- if (path) {
8211
- this.proxyPaths.set(proxy, path);
8212
8353
  }
8213
- return proxy;
8214
8354
  }
8355
+ }
8356
+
8357
+ // Copyright (c) 2025 MintJams Inc. Licensed under MIT License.
8358
+ /**
8359
+ * Directive for rendering components.
8360
+ * Usage: <div v-component="componentId" :options="props"></div>
8361
+ *
8362
+ * The :options binding is used to pass properties to the component.
8363
+ * Example:
8364
+ * <div v-component="my-component" :options="{message: 'Hello'}"></div>
8365
+ */
8366
+ class VComponentDirective {
8215
8367
  /**
8216
- * Looks up the dispatcher associated with (target, path), or installs a new one.
8217
- * When called for a nested target during proxy walking, the enclosing dispatcher
8218
- * is reused so a single subtree fans out changes through one notification path.
8368
+ * The virtual node to which this directive is applied.
8219
8369
  */
8220
- static resolveDispatcher(target, path, inheritedDispatcher) {
8221
- let pathMap = this.dispatchers.get(target);
8222
- if (!pathMap) {
8223
- pathMap = new Map();
8224
- this.dispatchers.set(target, pathMap);
8225
- }
8226
- const existing = pathMap.get(path);
8227
- if (existing) {
8228
- return existing;
8229
- }
8230
- const dispatcher = inheritedDispatcher ?? { listeners: new Set() };
8231
- pathMap.set(path, dispatcher);
8232
- return dispatcher;
8370
+ #vNode;
8371
+ /**
8372
+ * The component ID to render.
8373
+ */
8374
+ #componentId;
8375
+ /**
8376
+ * The application instance for the component.
8377
+ */
8378
+ #componentApp;
8379
+ constructor(context) {
8380
+ this.#vNode = context.vNode;
8381
+ this.#componentId = context.attribute.value.trim();
8382
+ // Remove the directive attribute from the element
8383
+ this.#vNode.node.removeAttribute(context.attribute.name);
8233
8384
  }
8234
8385
  /**
8235
- * Invokes every listener attached to the dispatcher.
8236
- * Iterates a snapshot of the listener set so unsubscribing during dispatch is safe.
8386
+ * @inheritdoc
8237
8387
  */
8238
- static dispatch(dispatcher, changedPath) {
8239
- if (dispatcher.listeners.size === 0) {
8240
- return;
8241
- }
8242
- const snapshot = Array.from(dispatcher.listeners);
8243
- for (const listener of snapshot) {
8244
- listener(changedPath);
8245
- }
8388
+ get name() {
8389
+ return StandardDirectiveName.V_COMPONENT;
8246
8390
  }
8247
8391
  /**
8248
- * Subscribes a listener to changes inside the subtree of an existing reactive proxy.
8249
- *
8250
- * The listener is scoped by the proxy's source path: only changes at or below that
8251
- * path are delivered, which lets a child component receive notifications when the
8252
- * nested contents of a prop change even though the prop reference itself is unchanged.
8253
- *
8254
- * @param proxyOrTarget A proxy returned from create(), or the underlying target object.
8255
- * @param listener Called with the full source path of every relevant change.
8256
- * @returns A function that removes the subscription.
8392
+ * @inheritdoc
8257
8393
  */
8258
- static subscribe(proxyOrTarget, listener) {
8259
- if (typeof proxyOrTarget !== 'object' || proxyOrTarget === null) {
8260
- return () => { };
8261
- }
8262
- const target = (this.proxyToTarget.get(proxyOrTarget) ?? proxyOrTarget);
8263
- const scopePath = this.proxyPaths.get(proxyOrTarget) ?? '';
8264
- const pathMap = this.dispatchers.get(target);
8265
- const dispatcher = pathMap?.get(scopePath);
8266
- if (!dispatcher) {
8267
- return () => { };
8268
- }
8269
- const wrapper = scopePath
8270
- ? (changedPath) => {
8271
- if (changedPath === scopePath ||
8272
- (typeof changedPath === 'string' && (changedPath.startsWith(scopePath + '.') ||
8273
- changedPath.startsWith(scopePath + '[')))) {
8274
- listener(changedPath);
8275
- }
8394
+ get vNode() {
8395
+ return this.#vNode;
8396
+ }
8397
+ /**
8398
+ * @inheritdoc
8399
+ */
8400
+ get needsAnchor() {
8401
+ return true;
8402
+ }
8403
+ /**
8404
+ * @inheritdoc
8405
+ */
8406
+ get bindingsPreparer() {
8407
+ return undefined;
8408
+ }
8409
+ /**
8410
+ * @inheritdoc
8411
+ */
8412
+ get domUpdater() {
8413
+ // Create and return the DOM updater
8414
+ const updater = {
8415
+ get dependentIdentifiers() {
8416
+ return [];
8417
+ },
8418
+ applyToDOM: () => {
8419
+ this.#render();
8276
8420
  }
8277
- : listener;
8278
- dispatcher.listeners.add(wrapper);
8279
- return () => {
8280
- dispatcher.listeners.delete(wrapper);
8281
8421
  };
8422
+ return updater;
8423
+ }
8424
+ /**
8425
+ * @inheritdoc
8426
+ */
8427
+ get templatize() {
8428
+ return true;
8282
8429
  }
8283
8430
  /**
8284
- * Checks if the given object is a reactive proxy.
8285
- *
8286
- * @param obj The object to check.
8287
- * @returns True if the object is a reactive proxy, false otherwise.
8431
+ * @inheritdoc
8288
8432
  */
8289
- static isReactive(obj) {
8290
- return this.proxyCache.has(obj);
8433
+ get dependentIdentifiers() {
8434
+ return [];
8291
8435
  }
8292
8436
  /**
8293
- * Unwraps a reactive proxy to get the original object.
8294
- * If the object is not a proxy, returns it as-is.
8295
- *
8296
- * @param obj The object to unwrap.
8297
- * @returns The original object.
8437
+ * @inheritdoc
8298
8438
  */
8299
- static unwrap(obj) {
8300
- // This is a simplified implementation
8301
- // In a full implementation, we'd need to store a reverse mapping
8302
- return obj;
8439
+ get onMount() {
8440
+ return undefined;
8303
8441
  }
8304
8442
  /**
8305
- * Marks an object as "raw" (non-reactive).
8306
- * Objects marked as raw will not be wrapped with Proxy when accessed from reactive objects.
8307
- * This is useful for objects that should not be reactive, such as:
8308
- * - Objects with private fields (class instances with # fields)
8309
- * - Third-party library instances
8310
- * - Objects used only for method calls
8311
- *
8312
- * @param obj The object to mark as raw.
8313
- * @returns The same object (for chaining).
8443
+ * @inheritdoc
8314
8444
  */
8315
- static markRaw(obj) {
8316
- if (typeof obj === 'object' && obj !== null) {
8317
- this.rawObjects.add(obj);
8318
- }
8319
- return obj;
8445
+ get onMounted() {
8446
+ return undefined;
8320
8447
  }
8321
8448
  /**
8322
- * Checks if an object is marked as raw (non-reactive).
8323
- *
8324
- * @param obj The object to check.
8325
- * @returns True if the object is marked as raw, false otherwise.
8449
+ * @inheritdoc
8326
8450
  */
8327
- static isRaw(obj) {
8328
- return typeof obj === 'object' && obj !== null && this.rawObjects.has(obj);
8451
+ get onUpdate() {
8452
+ return undefined;
8329
8453
  }
8330
8454
  /**
8331
- * Gets the source path for a proxy object.
8332
- * This is used to map computed property values back to their source paths.
8333
- * For example, if a computed property returns `model.elements[0]`,
8334
- * this method returns "model.elements[0]" for that object.
8335
- *
8336
- * @param obj The proxy object to get the path for.
8337
- * @returns The source path, or undefined if not found.
8455
+ * @inheritdoc
8338
8456
  */
8339
- static getPath(obj) {
8340
- if (typeof obj !== 'object' || obj === null) {
8341
- return undefined;
8342
- }
8343
- return this.proxyPaths.get(obj);
8457
+ get onUpdated() {
8458
+ return undefined;
8344
8459
  }
8345
8460
  /**
8346
- * Registers a path alias.
8347
- * This is used when a variable is assigned to reference an existing reactive object.
8348
- * For example, when `editingNestedStep = routes[0].steps[0]`, this creates an alias
8349
- * so that changes to "routes[0].steps[0].steps" also match "editingNestedStep.steps".
8350
- *
8351
- * @param aliasPath The alias path (e.g., "editingNestedStep")
8352
- * @param sourcePath The source path (e.g., "routes[0].steps[0]")
8461
+ * @inheritdoc
8353
8462
  */
8354
- static registerPathAlias(aliasPath, sourcePath) {
8355
- if (aliasPath && sourcePath && aliasPath !== sourcePath) {
8356
- this.pathAliases.set(aliasPath, sourcePath);
8357
- }
8463
+ get onUnmount() {
8464
+ return undefined;
8358
8465
  }
8359
8466
  /**
8360
- * Unregisters a path alias.
8361
- *
8362
- * @param aliasPath The alias path to remove
8467
+ * @inheritdoc
8363
8468
  */
8364
- static unregisterPathAlias(aliasPath) {
8365
- // Remove all aliases that start with the given path
8366
- for (const key of this.pathAliases.keys()) {
8367
- if (key === aliasPath || key.startsWith(aliasPath + '.') || key.startsWith(aliasPath + '[')) {
8368
- this.pathAliases.delete(key);
8369
- }
8469
+ get onUnmounted() {
8470
+ return undefined;
8471
+ }
8472
+ /**
8473
+ * @inheritdoc
8474
+ */
8475
+ destroy() {
8476
+ if (!this.#componentApp) {
8477
+ // Not rendered, no action needed
8478
+ return;
8479
+ }
8480
+ // Get the component's root VNode
8481
+ const componentVNode = this.#componentApp.rootVNode;
8482
+ // Destroy component application first (calls @unmount hooks while DOM is still accessible)
8483
+ this.#componentApp.unmount();
8484
+ // Remove the component's root node from the DOM
8485
+ if (componentVNode?.node.parentNode) {
8486
+ componentVNode.node.parentNode.removeChild(componentVNode.node);
8370
8487
  }
8488
+ this.#componentApp = undefined;
8371
8489
  }
8372
8490
  /**
8373
- * Resolves an alias path to its source path.
8374
- * Also handles nested paths (e.g., "editingNestedStep.steps" -> "routes[0].steps[0].steps").
8375
- *
8376
- * @param aliasPath The alias path to resolve
8377
- * @returns The source path, or undefined if no alias exists
8491
+ * Renders the component.
8378
8492
  */
8379
- static resolvePathAlias(aliasPath) {
8380
- // Direct match
8381
- if (this.pathAliases.has(aliasPath)) {
8382
- return this.pathAliases.get(aliasPath);
8493
+ #render() {
8494
+ if (this.#componentApp) {
8495
+ // Already rendered, no action needed
8496
+ return;
8383
8497
  }
8384
- // Check if this is a nested path of an aliased variable
8385
- // e.g., "editingNestedStep.steps" when "editingNestedStep" is aliased
8386
- for (const [alias, source] of this.pathAliases.entries()) {
8387
- if (aliasPath.startsWith(alias + '.') || aliasPath.startsWith(alias + '[')) {
8388
- const suffix = aliasPath.substring(alias.length);
8389
- return source + suffix;
8498
+ // Clone the component's template and replace the original node
8499
+ const clone = this.#cloneNode();
8500
+ this.#vNode.anchorNode?.parentNode?.insertBefore(clone, this.#vNode.anchorNode.nextSibling);
8501
+ // Get properties from :options or :options.component directive
8502
+ let properties = {};
8503
+ const optionsDirective = this.#vNode.directiveManager?.optionsDirective('component');
8504
+ if (optionsDirective && optionsDirective.expression && this.#vNode.bindings) {
8505
+ // Evaluate the options expression using ExpressionEvaluator
8506
+ const evaluator = ExpressionEvaluator.create(optionsDirective.expression, this.#vNode.bindings, this.#vNode.vApplication.functionDependencies);
8507
+ const result = evaluator.evaluate();
8508
+ if (typeof result === 'object' && result !== null) {
8509
+ properties = result;
8390
8510
  }
8391
8511
  }
8392
- return undefined;
8512
+ // Get component definition from the application's component registry
8513
+ const component = this.#vNode.vApplication.componentRegistry.get(this.#componentId);
8514
+ if (!component) {
8515
+ throw new Error(`Component '${this.#componentId}' not found in registry`);
8516
+ }
8517
+ // Create component instance
8518
+ const instance = component.createInstance(properties);
8519
+ // Create and mount child application using the parent application's registries
8520
+ this.#componentApp = this.#vNode.vApplication.createChildApp(instance);
8521
+ this.#componentApp.mount(clone);
8393
8522
  }
8394
8523
  /**
8395
- * Checks if a change path matches an identifier, considering path aliases.
8396
- * For example, if "editingNestedStep" is aliased to "routes[0].steps[0]",
8397
- * then a change to "routes[0].steps[0].steps" should match "editingNestedStep.steps".
8398
- *
8399
- * @param changePath The path that was changed (e.g., "routes[0].steps[0].steps")
8400
- * @param identifier The identifier to check (e.g., "editingNestedStep.steps")
8401
- * @returns True if the change path matches the identifier
8524
+ * Clones the component's template and returns the root element.
8525
+ * @returns The cloned root HTMLElement of the component.
8526
+ * @throws Error if the component or its template is not found.
8402
8527
  */
8403
- static doesChangeMatchIdentifier(changePath, identifier) {
8404
- // Direct match
8405
- if (changePath === identifier) {
8406
- return true;
8528
+ #cloneNode() {
8529
+ // Get component definition from the application's component registry
8530
+ const component = this.#vNode.vApplication.componentRegistry.get(this.#componentId);
8531
+ if (!component) {
8532
+ throw new Error(`Component '${this.#componentId}' not found in registry`);
8407
8533
  }
8408
- // Check if the identifier has an alias that matches
8409
- const resolvedIdentifier = this.resolvePathAlias(identifier);
8410
- if (resolvedIdentifier && changePath === resolvedIdentifier) {
8411
- return true;
8534
+ // Get template element
8535
+ const finalTemplateID = component.templateID || component.id;
8536
+ const templateElement = document.querySelector(`#${finalTemplateID}`);
8537
+ if (!templateElement || !(templateElement instanceof HTMLTemplateElement)) {
8538
+ throw new Error(`Template element '#${finalTemplateID}' not found`);
8412
8539
  }
8413
- // Check if change path starts with the resolved identifier
8414
- // (handles cases like array item changes)
8415
- if (resolvedIdentifier) {
8416
- if (changePath.startsWith(resolvedIdentifier + '.') ||
8417
- changePath.startsWith(resolvedIdentifier + '[')) {
8418
- return true;
8540
+ // Clone template content
8541
+ const fragment = templateElement.content.cloneNode(true);
8542
+ const childNodes = Array.from(fragment.childNodes);
8543
+ // Find the first element node
8544
+ for (const node of childNodes) {
8545
+ if (node.nodeType === Node.ELEMENT_NODE) {
8546
+ return node;
8419
8547
  }
8420
8548
  }
8421
- return false;
8549
+ throw new Error(`No element found in template '#${finalTemplateID}'`);
8422
8550
  }
8423
8551
  }
8424
8552
 
@@ -8449,6 +8577,13 @@
8449
8577
  * The set of changed identifiers.
8450
8578
  */
8451
8579
  #changes = new Set();
8580
+ /**
8581
+ * On the root bindings: every descendant scope (a v-for row, a component) that
8582
+ * recorded a change since the last clearChanges(). The root clears their change
8583
+ * sets together with its own at the end of the update cycle, so a loop variable
8584
+ * set when a row was created does not count as changed in every later cycle.
8585
+ */
8586
+ #dirtyScopes;
8452
8587
  /**
8453
8588
  * Cache for array lengths to detect length changes when the same object reference is used.
8454
8589
  */
@@ -8591,7 +8726,7 @@
8591
8726
  for (const part of changedPath?.split('.') || []) {
8592
8727
  path = path ? `${path}.${part}` : part;
8593
8728
  this.#logger?.debug(`Binding changed: ${path}`);
8594
- this.#changes.add(path);
8729
+ this.#addChange(path);
8595
8730
  }
8596
8731
  if (!this.#suppressOnChange) {
8597
8732
  this.#onChange?.(changedPath);
@@ -8631,7 +8766,7 @@
8631
8766
  for (const part of changedPath.split('.')) {
8632
8767
  path = path ? `${path}.${part}` : part;
8633
8768
  this.#logger?.debug(`Binding changed (external): ${path}`);
8634
- this.#changes.add(path);
8769
+ this.#addChange(path);
8635
8770
  }
8636
8771
  if (!this.#suppressOnChange) {
8637
8772
  this.#onChange?.(changedPath);
@@ -8658,7 +8793,7 @@
8658
8793
  const newValuePreview = newValueString.length > 100 ? `${newValueString.substring(0, 100)}...` : newValueString;
8659
8794
  this.#logger.debug(`Binding set on ${target === obj ? 'local' : 'parent'}: ${key}: ${oldValuePreview} -> ${newValuePreview}`);
8660
8795
  }
8661
- this.#changes.add(key);
8796
+ this.#addChange(key);
8662
8797
  if (!this.#suppressOnChange) {
8663
8798
  this.#onChange?.(key);
8664
8799
  }
@@ -8673,7 +8808,7 @@
8673
8808
  this.#externalSubscriptions.delete(key);
8674
8809
  }
8675
8810
  this.#logger?.debug(`Binding deleted: ${key}`);
8676
- this.#changes.add(key);
8811
+ this.#addChange(key);
8677
8812
  this.#onChange?.(key);
8678
8813
  return result;
8679
8814
  }
@@ -8714,6 +8849,27 @@
8714
8849
  */
8715
8850
  clearChanges() {
8716
8851
  this.#changes.clear();
8852
+ if (this.#dirtyScopes) {
8853
+ for (const scope of this.#dirtyScopes) {
8854
+ scope.#changes.clear();
8855
+ }
8856
+ this.#dirtyScopes.clear();
8857
+ }
8858
+ }
8859
+ /**
8860
+ * Records a changed identifier on this scope and, for a non-root scope, registers the
8861
+ * scope with the root so that the change is cleared at the end of the update cycle.
8862
+ */
8863
+ #addChange(key) {
8864
+ this.#changes.add(key);
8865
+ if (!this.#parent) {
8866
+ return;
8867
+ }
8868
+ let root = this;
8869
+ while (root.#parent) {
8870
+ root = root.#parent;
8871
+ }
8872
+ (root.#dirtyScopes ??= new Set()).add(this);
8717
8873
  }
8718
8874
  /**
8719
8875
  * Sets a binding value.
@@ -8889,7 +9045,7 @@
8889
9045
  * @param key The identifier to mark as changed.
8890
9046
  */
8891
9047
  markChanged(key) {
8892
- this.#changes.add(key);
9048
+ this.#addChange(key);
8893
9049
  }
8894
9050
  /**
8895
9051
  * Resolves a local path alias for the given identifier by walking up the bindings chain.
@@ -8915,18 +9071,15 @@
8915
9071
  * global aliases (computed properties etc.).
8916
9072
  */
8917
9073
  doesChangeMatchIdentifier(changePath, identifier) {
8918
- // Direct match
8919
- if (changePath === identifier) {
9074
+ // Direct match (an index "[*]" in the identifier matches any index)
9075
+ if (ReactiveProxy.pathMatches(changePath, identifier)) {
8920
9076
  return true;
8921
9077
  }
8922
9078
  // Resolve via local (scoped) alias chain
8923
9079
  const resolved = this.resolveAlias(identifier);
8924
9080
  if (resolved) {
8925
- if (changePath === resolved) {
8926
- return true;
8927
- }
8928
- // changePath is a descendant of resolved (e.g. "files[0].isModified" for resolved "files[0]")
8929
- if (changePath.startsWith(resolved + '.') || changePath.startsWith(resolved + '[')) {
9081
+ // changePath is resolved itself or a descendant of it (e.g. "files[0].isModified" for resolved "files[0]")
9082
+ if (ReactiveProxy.pathMatches(changePath, resolved, true)) {
8930
9083
  return true;
8931
9084
  }
8932
9085
  // changePath is an ancestor of resolved (e.g. "files" for resolved "files[0].isModified")
@@ -8939,6 +9092,54 @@
8939
9092
  // Fall back to global alias resolution (computed properties, etc.)
8940
9093
  return ReactiveProxy.doesChangeMatchIdentifier(changePath, identifier);
8941
9094
  }
9095
+ /**
9096
+ * Checks whether any of the changes affects an expression with the given identifiers.
9097
+ *
9098
+ * An identifier with a run-time index ("frames[*].url" for frames[i].url) normally
9099
+ * matches a change at any index. When trackedPaths holds the paths the expression read
9100
+ * during its last evaluation, the identifier is narrowed to the concrete paths among
9101
+ * them ("frames[0].url"), so a write to another element is ignored. When no recorded
9102
+ * path fits the identifier (not evaluated yet, a raw object, a short-circuited read),
9103
+ * the identifier keeps matching every index.
9104
+ *
9105
+ * @param changes The change paths of the current update cycle.
9106
+ * @param identifiers The identifiers the expression depends on.
9107
+ * @param trackedPaths The paths read during the expression's last evaluation, if recorded.
9108
+ * @returns True if some change matches some identifier.
9109
+ */
9110
+ isAffected(changes, identifiers, trackedPaths) {
9111
+ if (changes.length === 0) {
9112
+ return false;
9113
+ }
9114
+ for (const identifier of identifiers) {
9115
+ const candidates = this.#concretePaths(identifier, trackedPaths) ?? [identifier];
9116
+ for (const candidate of candidates) {
9117
+ if (changes.some(change => this.doesChangeMatchIdentifier(change, candidate))) {
9118
+ return true;
9119
+ }
9120
+ }
9121
+ }
9122
+ return false;
9123
+ }
9124
+ /**
9125
+ * Returns the recorded paths that an identifier with a run-time index stood for during
9126
+ * the last evaluation, or undefined when the identifier has no such index or nothing
9127
+ * recorded fits it. The identifier is tried both as spelled and resolved through path
9128
+ * aliases, since recorded paths are source paths.
9129
+ */
9130
+ #concretePaths(identifier, trackedPaths) {
9131
+ if (!trackedPaths || trackedPaths.size === 0 || !identifier.includes('[*]')) {
9132
+ return undefined;
9133
+ }
9134
+ const resolved = this.resolveAlias(identifier);
9135
+ const paths = [];
9136
+ for (const path of trackedPaths) {
9137
+ if (ReactiveProxy.pathMatches(path, identifier) || (resolved !== undefined && ReactiveProxy.pathMatches(path, resolved))) {
9138
+ paths.push(path);
9139
+ }
9140
+ }
9141
+ return paths.length > 0 ? paths : undefined;
9142
+ }
8942
9143
  }
8943
9144
 
8944
9145
  // Copyright (c) 2025 MintJams Inc. Licensed under MIT License.
@@ -9133,10 +9334,14 @@
9133
9334
  */
9134
9335
  get domUpdater() {
9135
9336
  const identifiers = this.dependentIdentifiers;
9337
+ const directive = this;
9136
9338
  const updater = {
9137
9339
  get dependentIdentifiers() {
9138
9340
  return identifiers;
9139
9341
  },
9342
+ get trackedPaths() {
9343
+ return directive.#collectTrackedPaths();
9344
+ },
9140
9345
  applyToDOM: () => {
9141
9346
  this.#refresh();
9142
9347
  }
@@ -9159,6 +9364,21 @@
9159
9364
  }
9160
9365
  return Array.from(ids);
9161
9366
  }
9367
+ /**
9368
+ * The paths the prop expressions read during their last evaluation, united across
9369
+ * props, or undefined when no prop expression has a run-time index.
9370
+ */
9371
+ #collectTrackedPaths() {
9372
+ let paths;
9373
+ for (const prop of this.#props) {
9374
+ const tracked = prop.evaluator?.trackedPaths;
9375
+ if (tracked) {
9376
+ paths ??= new Set();
9377
+ tracked.forEach(path => paths.add(path));
9378
+ }
9379
+ }
9380
+ return paths;
9381
+ }
9162
9382
  /**
9163
9383
  * @inheritdoc
9164
9384
  *
@@ -9964,6 +10184,11 @@
9964
10184
  * The function used to evaluate the text with current bindings.
9965
10185
  */
9966
10186
  #evaluate;
10187
+ /**
10188
+ * The source paths read through reactive proxies during the last evaluation,
10189
+ * recorded only when some expression has a run-time index ("items[i].name").
10190
+ */
10191
+ #trackedPaths;
9967
10192
  /**
9968
10193
  * Constructs a VTextEvaluator instance.
9969
10194
  * @param text The text containing embedded expressions in the form of {{...}}.
@@ -9991,7 +10216,7 @@
9991
10216
  // Build base parameter names (no dots), preserving order of first appearance
9992
10217
  const baseArgs = [];
9993
10218
  for (const id of ids) {
9994
- const base = id.split('.')[0];
10219
+ const base = ExpressionUtils.baseIdentifier(id);
9995
10220
  if (!baseArgs.includes(base)) {
9996
10221
  baseArgs.push(base);
9997
10222
  }
@@ -10004,8 +10229,10 @@
10004
10229
  func: new Function(args, funcBody)
10005
10230
  };
10006
10231
  });
10232
+ // Only an expression with an index known at run time needs its reads recorded
10233
+ const hasDynamicIndex = this.#identifiers.some(id => id.includes('[*]'));
10007
10234
  // Create a function to reconstruct the text with evaluated expressions
10008
- this.#evaluate = (bindings) => {
10235
+ const evaluateText = (bindings) => {
10009
10236
  let result = text;
10010
10237
  evaluators.forEach((evaluator, i) => {
10011
10238
  // Build values for baseArgs (no dots) in the same order as function parameters
@@ -10023,6 +10250,15 @@
10023
10250
  });
10024
10251
  return result;
10025
10252
  };
10253
+ this.#evaluate = hasDynamicIndex
10254
+ ? (bindings) => {
10255
+ const paths = new Set();
10256
+ this.#trackedPaths = undefined;
10257
+ const result = ReactiveProxy.track(paths, () => evaluateText(bindings));
10258
+ this.#trackedPaths = paths;
10259
+ return result;
10260
+ }
10261
+ : evaluateText;
10026
10262
  }
10027
10263
  /**
10028
10264
  * Checks if the given text contains any expressions in the form of {{...}}.
@@ -10039,6 +10275,14 @@
10039
10275
  get identifiers() {
10040
10276
  return this.#identifiers;
10041
10277
  }
10278
+ /**
10279
+ * The source paths read through reactive proxies during the last evaluation.
10280
+ * Recorded only when an expression has a run-time index ("items[i].name");
10281
+ * undefined before the first evaluation and for texts without such an index.
10282
+ */
10283
+ get trackedPaths() {
10284
+ return this.#trackedPaths;
10285
+ }
10042
10286
  /**
10043
10287
  * Evaluates the text with the provided bindings and returns the resulting string.
10044
10288
  * @param bindings The bindings to use for evaluating the expressions.
@@ -10528,7 +10772,7 @@
10528
10772
  if (this.#nodeType === Node.TEXT_NODE && this.#textEvaluator) {
10529
10773
  // If this is a text node with a text evaluator, update its content if needed
10530
10774
  // Check if any of the identifiers are in the changed identifiers (considering path aliases)
10531
- const changed = this.#textEvaluator.identifiers.some(id => changes.some(change => this.bindings.doesChangeMatchIdentifier(change, id)));
10775
+ const changed = this.bindings.isAffected(changes, this.#textEvaluator.identifiers, this.#textEvaluator.trackedPaths);
10532
10776
  // If the text node has changed, update its content
10533
10777
  if (changed) {
10534
10778
  const text = this.#node;
@@ -10551,7 +10795,7 @@
10551
10795
  }
10552
10796
  // Prepare bindings for each preparer if relevant identifiers have changed
10553
10797
  for (const preparer of this.#directiveManager.bindingsPreparers) {
10554
- const changed = preparer.dependentIdentifiers.some(id => changes.some(change => this.bindings.doesChangeMatchIdentifier(change, id)));
10798
+ const changed = this.bindings.isAffected(changes, preparer.dependentIdentifiers, preparer.trackedPaths);
10555
10799
  if (changed) {
10556
10800
  preparer.prepareBindings();
10557
10801
  }
@@ -10560,7 +10804,7 @@
10560
10804
  // Apply DOM updaters from directives, if any
10561
10805
  if (this.#directiveManager?.domUpdaters) {
10562
10806
  for (const updater of this.#directiveManager.domUpdaters) {
10563
- const changed = updater.dependentIdentifiers.some(id => changes.some(change => this.bindings.doesChangeMatchIdentifier(change, id)));
10807
+ const changed = this.bindings.isAffected(changes, updater.dependentIdentifiers, updater.trackedPaths);
10564
10808
  if (changed) {
10565
10809
  updater.applyToDOM();
10566
10810
  }
@@ -10813,6 +11057,21 @@
10813
11057
  get allDependentIdentifiers() {
10814
11058
  return this.#allDependentIdentifiers;
10815
11059
  }
11060
+ /**
11061
+ * The paths the conditions of the chain read during their last evaluation, united
11062
+ * across directives, or undefined when no condition has a run-time index.
11063
+ */
11064
+ get trackedPaths() {
11065
+ let paths;
11066
+ for (const directive of this.#directives) {
11067
+ const tracked = directive.trackedPaths;
11068
+ if (tracked) {
11069
+ paths ??= new Set();
11070
+ tracked.forEach(path => paths.add(path));
11071
+ }
11072
+ }
11073
+ return paths;
11074
+ }
10816
11075
  /**
10817
11076
  * Adds a directive (v-else-if or v-else) to the conditional context.
10818
11077
  * @param directive The directive to add.
@@ -11009,11 +11268,15 @@
11009
11268
  */
11010
11269
  get domUpdater() {
11011
11270
  const identifiers = this.#conditionalContext.allDependentIdentifiers;
11271
+ const conditionalContext = this.#conditionalContext;
11012
11272
  // Create an updater that handles the conditional rendering
11013
11273
  const updater = {
11014
11274
  get dependentIdentifiers() {
11015
11275
  return identifiers;
11016
11276
  },
11277
+ get trackedPaths() {
11278
+ return conditionalContext.trackedPaths;
11279
+ },
11017
11280
  applyToDOM: () => {
11018
11281
  this.#render();
11019
11282
  }
@@ -11032,6 +11295,13 @@
11032
11295
  get dependentIdentifiers() {
11033
11296
  return this.#evaluator?.dependentIdentifiers ?? [];
11034
11297
  }
11298
+ /**
11299
+ * The paths the condition read during its last evaluation, when it has an index
11300
+ * known only at run time (see ExpressionEvaluator.trackedPaths).
11301
+ */
11302
+ get trackedPaths() {
11303
+ return this.#evaluator?.trackedPaths;
11304
+ }
11035
11305
  /**
11036
11306
  * The context for managing related conditional directives (v-if, v-else-if, v-else).
11037
11307
  */
@@ -11398,11 +11668,15 @@
11398
11668
  */
11399
11669
  get domUpdater() {
11400
11670
  const identifiers = this.#sourceEvaluator?.dependentIdentifiers ?? [];
11671
+ const sourceEvaluator = this.#sourceEvaluator;
11401
11672
  // Create and return the DOM updater
11402
11673
  const updater = {
11403
11674
  get dependentIdentifiers() {
11404
11675
  return identifiers;
11405
11676
  },
11677
+ get trackedPaths() {
11678
+ return sourceEvaluator?.trackedPaths;
11679
+ },
11406
11680
  applyToDOM: () => {
11407
11681
  this.#render();
11408
11682
  }
@@ -12122,6 +12396,9 @@
12122
12396
  get dependentIdentifiers() {
12123
12397
  return identifiers;
12124
12398
  },
12399
+ get trackedPaths() {
12400
+ return evaluator.trackedPaths;
12401
+ },
12125
12402
  applyToDOM: () => {
12126
12403
  const element = vNode.node;
12127
12404
  const htmlContent = evaluator.evaluate() ?? '';
@@ -12336,7 +12613,7 @@
12336
12613
  const identifiers = optionsDirective.dependentIdentifiers;
12337
12614
  const baseArgs = [];
12338
12615
  for (const id of identifiers) {
12339
- const base = id.split('.')[0];
12616
+ const base = ExpressionUtils.baseIdentifier(id);
12340
12617
  if (!baseArgs.includes(base))
12341
12618
  baseArgs.push(base);
12342
12619
  }
@@ -12416,7 +12693,7 @@
12416
12693
  // Note: inline expressions receive entries and $ctx as parameters
12417
12694
  const baseArgs = [];
12418
12695
  for (const id of identifiers) {
12419
- const base = id.split('.')[0];
12696
+ const base = ExpressionUtils.baseIdentifier(id);
12420
12697
  if (!baseArgs.includes(base))
12421
12698
  baseArgs.push(base);
12422
12699
  }
@@ -12548,6 +12825,9 @@
12548
12825
  get dependentIdentifiers() {
12549
12826
  return self.#collectDependentIdentifiers();
12550
12827
  },
12828
+ get trackedPaths() {
12829
+ return self.#evaluator?.trackedPaths;
12830
+ },
12551
12831
  applyToDOM: () => {
12552
12832
  self.#render();
12553
12833
  }
@@ -13554,7 +13834,7 @@
13554
13834
  const identifiers = optionsDirective.dependentIdentifiers;
13555
13835
  const baseArgs = [];
13556
13836
  for (const id of identifiers) {
13557
- const base = id.split('.')[0];
13837
+ const base = ExpressionUtils.baseIdentifier(id);
13558
13838
  if (!baseArgs.includes(base))
13559
13839
  baseArgs.push(base);
13560
13840
  }
@@ -13642,7 +13922,7 @@
13642
13922
  // Note: inline expressions receive entries, observer, options, and $ctx as parameters
13643
13923
  const baseArgs = [];
13644
13924
  for (const id of identifiers) {
13645
- const base = id.split('.')[0];
13925
+ const base = ExpressionUtils.baseIdentifier(id);
13646
13926
  if (!baseArgs.includes(base))
13647
13927
  baseArgs.push(base);
13648
13928
  }
@@ -13775,7 +14055,7 @@
13775
14055
  const identifiers = optionsDirective.dependentIdentifiers;
13776
14056
  const baseArgs = [];
13777
14057
  for (const id of identifiers) {
13778
- const base = id.split('.')[0];
14058
+ const base = ExpressionUtils.baseIdentifier(id);
13779
14059
  if (!baseArgs.includes(base))
13780
14060
  baseArgs.push(base);
13781
14061
  }
@@ -13855,7 +14135,7 @@
13855
14135
  // Note: inline expressions receive entries and $ctx as parameters
13856
14136
  const baseArgs = [];
13857
14137
  for (const id of identifiers) {
13858
- const base = id.split('.')[0];
14138
+ const base = ExpressionUtils.baseIdentifier(id);
13859
14139
  if (!baseArgs.includes(base))
13860
14140
  baseArgs.push(base);
13861
14141
  }
@@ -13946,6 +14226,9 @@
13946
14226
  get dependentIdentifiers() {
13947
14227
  return identifiers;
13948
14228
  },
14229
+ get trackedPaths() {
14230
+ return evaluator.trackedPaths;
14231
+ },
13949
14232
  applyToDOM() {
13950
14233
  const shouldRender = evaluator.evaluateAsBoolean();
13951
14234
  if (shouldRender) {
@@ -14113,6 +14396,9 @@
14113
14396
  get dependentIdentifiers() {
14114
14397
  return identifiers;
14115
14398
  },
14399
+ get trackedPaths() {
14400
+ return evaluator.trackedPaths;
14401
+ },
14116
14402
  applyToDOM() {
14117
14403
  const element = vNode.node;
14118
14404
  element.textContent = toDisplayString(evaluator.evaluate());
@@ -14275,6 +14561,9 @@
14275
14561
  get dependentIdentifiers() {
14276
14562
  return identifiers;
14277
14563
  },
14564
+ get trackedPaths() {
14565
+ return evaluator.trackedPaths;
14566
+ },
14278
14567
  applyToDOM: () => {
14279
14568
  const value = evaluator.evaluateAsBoolean();
14280
14569
  const previous = this.#previousValue;