@mintjamsinc/ichigojs 0.1.86 → 0.1.88

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.
@@ -6728,1117 +6728,1235 @@ base.MethodDefinition = base.PropertyDefinition = base.Property = function (node
6728
6728
 
6729
6729
  // Copyright (c) 2025 MintJams Inc. Licensed under MIT License.
6730
6730
  /**
6731
- * Utility class for analyzing JavaScript expressions to extract variable and function dependencies.
6731
+ * Utility class for creating reactive proxies that automatically track changes.
6732
6732
  */
6733
- class ExpressionUtils {
6733
+ class ReactiveProxy {
6734
6734
  /**
6735
- * Extracts variable and function names used in the expression.
6736
- * @param expression The expression string to analyze.
6737
- * @param functionDependencies A dictionary mapping function names to their dependencies.
6738
- * @param options Optional parsing options.
6739
- * - asScript: If true, parse the input as a Script (allows multi-statement source with semicolons,
6740
- * declarations, control-flow, etc.). If false/omitted, parse as a single expression (the default,
6741
- * for backward compatibility with interpolation and binding directives).
6742
- * @returns An array of identifier names.
6735
+ * A WeakMap to store the proxy for each target object and path combination.
6736
+ * This prevents creating multiple proxies for the same object accessed from different paths.
6743
6737
  */
6744
- static extractIdentifiers(expression, functionDependencies, options) {
6745
- const identifiers = new Set();
6746
- // In expression mode we wrap in parens so acorn parses the source as a single expression.
6747
- // In script mode we parse as a Program so that multi-statement bodies (e.g. "a=1; b=2") work.
6748
- const source = options?.asScript ? expression : `(${expression})`;
6749
- const ast = parse(source, { ecmaVersion: "latest" });
6750
- // Use walk.full instead of walk.simple to visit ALL nodes including assignment LHS
6751
- full(ast, (node) => {
6752
- if (node.type === 'Identifier') {
6753
- identifiers.add(node.name);
6754
- // Check if the identifier is a function name
6755
- if (functionDependencies[node.name]) {
6756
- // If it is, add its dependencies to the list of identifiers
6757
- for (const dependency of functionDependencies[node.name]) {
6758
- identifiers.add(dependency);
6759
- }
6760
- }
6761
- }
6762
- else if (node.type === 'MemberExpression') {
6763
- // Reconstruct chain parts from a MemberExpression (e.g. a.b.c). Each part
6764
- // carries its own separator so that the chain is spelled the way the reactive
6765
- // proxy reports changes: array indices in brackets, properties after a dot
6766
- // (e.g. "items[0].name").
6767
- const parts = [];
6768
- let cur = node;
6769
- while (cur) {
6770
- // property part (right side)
6771
- if (cur.property) {
6772
- if (!cur.computed && cur.property.type === 'Identifier') {
6773
- parts.unshift(`.${cur.property.name}`);
6774
- }
6775
- else if (cur.computed && cur.property.type === 'Literal' && typeof cur.property.value === 'number') {
6776
- parts.unshift(`[${cur.property.value}]`);
6777
- }
6778
- else if (cur.computed && cur.property.type === 'Literal') {
6779
- parts.unshift(`.${String(cur.property.value)}`);
6780
- }
6781
- else if (cur.computed) {
6782
- // e.g. items[i] or items[i + 1]: the index is only known when the
6783
- // expression runs, so "[*]" stands for any index or key. Identifiers
6784
- // inside the index expression are visited separately by walk.full.
6785
- parts.unshift('[*]');
6786
- }
6787
- }
6788
- // object part (left side)
6789
- if (cur.object) {
6790
- if (cur.object.type === 'Identifier') {
6791
- parts.unshift(cur.object.name);
6792
- break;
6793
- }
6794
- else if (cur.object.type === 'ThisExpression') {
6795
- // For `this.x` or `this.a.b` we don't want to include 'this' itself.
6796
- // Stop unwrapping here; we'll drop a leading 'this' later.
6797
- break;
6798
- }
6799
- else if (cur.object.type === 'MemberExpression') {
6800
- // continue unwrapping
6801
- cur = cur.object;
6802
- }
6803
- else {
6804
- // other object types (CallExpression, etc.) — stop here
6805
- break;
6806
- }
6807
- }
6808
- else {
6809
- break;
6810
- }
6811
- }
6812
- // A chain that does not start at a name (e.g. `this.a.b` or `f().b`) begins
6813
- // with its first property; one that begins with an index names no binding.
6814
- if (parts.length > 0 && parts[0].startsWith('.')) {
6815
- parts[0] = parts[0].substring(1);
6816
- }
6817
- if (parts.length > 0 && !parts[0].startsWith('[')) {
6818
- // Add progressive chains: 'a', 'a.b', 'a.b[0]', 'a.b[0].c'
6819
- for (let i = 0; i < parts.length; i++) {
6820
- const chain = parts.slice(0, i + 1).join('');
6821
- identifiers.add(chain);
6822
- // Also apply functionDependencies lookup for the chain key
6823
- if (functionDependencies[chain]) {
6824
- for (const dependency of functionDependencies[chain]) {
6825
- identifiers.add(dependency);
6826
- }
6827
- }
6828
- }
6829
- }
6830
- }
6831
- });
6832
- return Array.from(identifiers);
6738
+ static proxyCache = new WeakMap();
6739
+ /**
6740
+ * A WeakMap to track which objects are proxies, mapping proxy -> original target.
6741
+ * This prevents double-wrapping of already proxied objects.
6742
+ */
6743
+ static proxyToTarget = new WeakMap();
6744
+ /**
6745
+ * A WeakSet to track objects marked as "raw" (non-reactive).
6746
+ * These objects will not be wrapped with Proxy.
6747
+ */
6748
+ static rawObjects = new WeakSet();
6749
+ /**
6750
+ * A WeakMap to store the path for each proxy object.
6751
+ * This allows retrieving the source path of an object for computed property mapping.
6752
+ */
6753
+ static proxyPaths = new WeakMap();
6754
+ /**
6755
+ * Dispatchers per (target, path). Every target reached while walking a proxy
6756
+ * subtree registers an entry pointing at the same dispatcher as the outermost
6757
+ * proxy of that subtree, so callers can look up the dispatcher from any
6758
+ * intermediate proxy when subscribing.
6759
+ */
6760
+ static dispatchers = new WeakMap();
6761
+ /**
6762
+ * A Map to store path aliases.
6763
+ * Key: alias path (e.g., "editingNestedStep.steps")
6764
+ * Value: source path (e.g., "routes[0].steps[0].steps")
6765
+ * This allows mapping variable names to their actual source paths for dependency tracking.
6766
+ */
6767
+ static pathAliases = new Map();
6768
+ /**
6769
+ * Compiled patterns for identifiers that contain a "[*]" index, keyed by the
6770
+ * identifier and whether descendants of the path also match.
6771
+ */
6772
+ static pathPatterns = new Map();
6773
+ /**
6774
+ * The set collecting the paths read through any proxy while track() runs, or
6775
+ * undefined when no expression is being tracked.
6776
+ */
6777
+ static tracker;
6778
+ /**
6779
+ * Whether a property key is spelled as an array index ("0", "12") rather than a
6780
+ * property name. Both the proxy and the expression analyzer decide between
6781
+ * "items[0]" and "items.name" with this test alone, so a numeric key on a plain
6782
+ * object and a numeric key on an array get the same spelling.
6783
+ *
6784
+ * @param key The property key as a string.
6785
+ * @returns True if the key is a canonical non-negative integer.
6786
+ */
6787
+ static isIndexKey(key) {
6788
+ return /^(?:0|[1-9]\d*)$/.test(key);
6833
6789
  }
6834
6790
  /**
6835
- * Returns the binding name an identifier chain starts with
6836
- * (e.g. "items" for "items[0].name" or "user.name").
6837
- * @param identifier An identifier or member chain returned by extractIdentifiers.
6838
- * @returns The left-most name of the chain.
6791
+ * Appends a property key to a source path the way change paths are reported:
6792
+ * an index in brackets ("items[0]"), a name after a dot ("user.name").
6793
+ *
6794
+ * @param path The path of the containing object, or "" for a root binding.
6795
+ * @param key The property key being appended.
6796
+ * @returns The joined path.
6839
6797
  */
6840
- static baseIdentifier(identifier) {
6841
- const end = identifier.search(/[.[]/);
6842
- return end === -1 ? identifier : identifier.substring(0, end);
6798
+ static joinPath(path, key) {
6799
+ const keyStr = String(key);
6800
+ if (!path) {
6801
+ return keyStr;
6802
+ }
6803
+ return this.isIndexKey(keyStr) ? `${path}[${keyStr}]` : `${path}.${keyStr}`;
6843
6804
  }
6844
6805
  /**
6845
- * Analyzes the dependencies of functions in the provided dictionary.
6846
- * @param functions A dictionary mapping function names to their implementations.
6847
- * @returns A dictionary mapping function names to arrays of their dependencies.
6806
+ * Runs fn while recording, into paths, the source path of every property read
6807
+ * through a reactive proxy. Nested calls record into the innermost set only;
6808
+ * the previous set is restored when fn returns or throws.
6809
+ *
6810
+ * @param paths The set that receives the paths read.
6811
+ * @param fn The function to run.
6812
+ * @returns The value fn returns.
6848
6813
  */
6849
- static analyzeFunctionDependencies(functions) {
6850
- const functionDependencies = {};
6851
- // First pass: collect direct dependencies for each function
6852
- for (const [funcName, func] of Object.entries(functions)) {
6853
- try {
6854
- let source = func.toString();
6855
- // Handle different function formats
6856
- // Arrow function: () => expr or (args) => { ... }
6857
- // Method shorthand: methodName() { ... }
6858
- // Function expression: function() { ... }
6859
- // Detect function type more accurately
6860
- const isArrowFunction = /^\s*(\([^)]*\)|[a-zA-Z_$][a-zA-Z0-9_$]*)\s*=>/.test(source);
6861
- const isFunctionExpression = source.startsWith('function');
6862
- const isAsyncFunction = source.startsWith('async');
6863
- // If it's a method shorthand (e.g., "methodName() { ... }" or "async methodName() { ... }"), convert to function expression
6864
- if (!isFunctionExpression && !isArrowFunction) {
6865
- // It's likely a method shorthand, convert to function expression
6866
- if (isAsyncFunction) {
6867
- // Remove 'async' prefix and add 'async function' prefix
6868
- source = `async function ${source.substring(5).trim()}`;
6869
- }
6870
- else {
6871
- source = `function ${source}`;
6872
- }
6873
- }
6874
- // Wrap in parentheses for parsing
6875
- source = `(${source})`;
6876
- const ast = parse(source, { ecmaVersion: "latest" });
6877
- const dependencies = new Set();
6878
- const declaredVariables = new Set();
6879
- const thisAliases = new Set();
6880
- // First, collect all declared variables (const, let, var, function params, etc.)
6881
- ancestor(ast, {
6882
- VariableDeclarator(node) {
6883
- if (node.id.type === 'Identifier') {
6884
- declaredVariables.add(node.id.name);
6885
- // Detect aliases assigned from `this`, e.g. `let vm = this;`
6886
- if (node.init && node.init.type === 'ThisExpression') {
6887
- thisAliases.add(node.id.name);
6888
- }
6889
- }
6890
- },
6891
- FunctionDeclaration(node) {
6892
- if (node.id && node.id.type === 'Identifier') {
6893
- declaredVariables.add(node.id.name);
6894
- }
6895
- // Add function parameters
6896
- node.params.forEach((param) => {
6897
- if (param.type === 'Identifier') {
6898
- declaredVariables.add(param.name);
6899
- }
6900
- });
6901
- },
6902
- FunctionExpression(node, ancestors) {
6903
- // Only process the outermost function (skip nested functions)
6904
- const functionAncestors = ancestors.filter((n) => n.type === 'FunctionExpression' ||
6905
- n.type === 'FunctionDeclaration' ||
6906
- n.type === 'ArrowFunctionExpression');
6907
- if (functionAncestors.length === 1) {
6908
- // Add function parameters of the main function
6909
- node.params.forEach((param) => {
6910
- if (param.type === 'Identifier') {
6911
- declaredVariables.add(param.name);
6912
- }
6913
- });
6914
- }
6915
- },
6916
- ArrowFunctionExpression(node, ancestors) {
6917
- // Only process the outermost function (skip nested functions)
6918
- const functionAncestors = ancestors.filter((n) => n.type === 'FunctionExpression' ||
6919
- n.type === 'FunctionDeclaration' ||
6920
- n.type === 'ArrowFunctionExpression');
6921
- if (functionAncestors.length === 1) {
6922
- // Add function parameters of the main function
6923
- node.params.forEach((param) => {
6924
- if (param.type === 'Identifier') {
6925
- declaredVariables.add(param.name);
6926
- }
6927
- });
6928
- }
6929
- }
6930
- });
6931
- // Then, collect identifiers that are not declared within the function
6932
- simple(ast, {
6933
- Identifier(node) {
6934
- // Skip the function name itself, declared variables, and common globals
6935
- if (node.name !== funcName && !declaredVariables.has(node.name)) {
6936
- dependencies.add(node.name);
6937
- }
6938
- },
6939
- MemberExpression(node) {
6940
- // Handle 'this.propertyName' patterns and aliases like 'vm.x' when 'vm = this'
6941
- if (node.property.type === 'Identifier') {
6942
- if (node.object.type === 'ThisExpression') {
6943
- dependencies.add(node.property.name);
6944
- }
6945
- else if (node.object.type === 'Identifier' && thisAliases.has(node.object.name)) {
6946
- dependencies.add(node.property.name);
6947
- }
6948
- }
6949
- }
6950
- });
6951
- functionDependencies[funcName] = Array.from(dependencies);
6952
- }
6953
- catch (error) {
6954
- console.warn(`Failed to analyze function '${funcName}':`, error);
6955
- // If parsing fails, assume no dependencies
6956
- functionDependencies[funcName] = [];
6957
- }
6958
- }
6959
- // Second pass: resolve each function's transitive reactive dependencies.
6960
- //
6961
- // A function's direct dependency list mixes two kinds of identifiers: references to other
6962
- // functions (call edges) and references to reactive data properties (the values we actually
6963
- // care about). We want, for every function, the full set of reactive properties reachable by
6964
- // following call edges to any depth.
6965
- //
6966
- // Functions are free to reference each other cyclically — a poller that reschedules itself
6967
- // (`scheduleOperationPoll` -> `pollOperation` -> `scheduleOperationPoll`), a pair of mutually
6968
- // recursive helpers, or any state machine. This is valid JavaScript, so the resolver must not
6969
- // treat it as an error. Every function in a cycle (more precisely, a strongly connected
6970
- // component) is reachable from the others, so they all share the same transitive dependency
6971
- // set: the union of every member's direct reactive dependencies.
6972
- //
6973
- // A depth-first walk that bails out on back-edges cannot compute that correctly — it drops the
6974
- // dependencies contributed around the cycle and caches incomplete, traversal-order-dependent
6975
- // results, which would silently break reactivity for any computed that reads reactive data
6976
- // through a cyclic method. We instead split each function's direct dependencies into reactive
6977
- // properties and call edges, then propagate reactive properties along call edges until the sets
6978
- // stop growing (a fixpoint). This converges regardless of cycles and yields the exact transitive
6979
- // closure for every function, including those participating in a cycle.
6980
- const directReactive = {};
6981
- const callEdges = {};
6982
- for (const funcName of Object.keys(functions)) {
6983
- const reactive = new Set();
6984
- const edges = [];
6985
- for (const dep of functionDependencies[funcName] || []) {
6986
- if (dep === funcName)
6987
- continue; // Skip self-references.
6988
- if (functions[dep]) {
6989
- edges.push(dep); // Call edge to another function.
6990
- }
6991
- else {
6992
- reactive.add(dep); // Reactive data property (terminal dependency).
6993
- }
6994
- }
6995
- directReactive[funcName] = reactive;
6996
- callEdges[funcName] = edges;
6997
- }
6998
- // Seed each function with its own direct reactive dependencies, then propagate along call edges
6999
- // until no set changes. Each pass can only add properties, and the universe of properties is
7000
- // finite, so the loop is guaranteed to terminate.
7001
- const resolved = {};
7002
- for (const funcName of Object.keys(functions)) {
7003
- resolved[funcName] = new Set(directReactive[funcName]);
7004
- }
7005
- let changed = true;
7006
- while (changed) {
7007
- changed = false;
7008
- for (const funcName of Object.keys(functions)) {
7009
- const target = resolved[funcName];
7010
- for (const edge of callEdges[funcName]) {
7011
- for (const dep of resolved[edge]) {
7012
- if (!target.has(dep)) {
7013
- target.add(dep);
7014
- changed = true;
7015
- }
7016
- }
7017
- }
7018
- }
6814
+ static track(paths, fn) {
6815
+ const previous = this.tracker;
6816
+ this.tracker = paths;
6817
+ try {
6818
+ return fn();
7019
6819
  }
7020
- const resolvedDependencies = {};
7021
- for (const funcName of Object.keys(functions)) {
7022
- resolvedDependencies[funcName] = Array.from(resolved[funcName]);
6820
+ finally {
6821
+ this.tracker = previous;
7023
6822
  }
7024
- return resolvedDependencies;
7025
6823
  }
7026
6824
  /**
7027
- * Rewrites an expression to replace identifiers with 'this.identifier'.
7028
- * This allows direct property access and assignment without using 'with' statement.
7029
- * Uses AST parsing to accurately identify which identifiers to replace.
7030
- * @param expression The original expression string.
7031
- * @param identifiers The list of identifiers that are available in bindings.
7032
- * @returns The rewritten expression.
6825
+ * The `Object.prototype.toString` tags of the *only* value types we deeply
6826
+ * proxy. Everything else is returned untouched (raw).
6827
+ *
6828
+ * Rationale: a Proxy can only safely intercept plain data containers. Built-in
6829
+ * and host objects — `Date`, `RegExp`, `File`, `Blob`, `FileList`, `Promise`,
6830
+ * `WeakMap`/`WeakSet`, typed arrays, DOM nodes, etc. — carry private internal
6831
+ * slots, and invoking their native methods with a Proxy as the `this`/receiver
6832
+ * throws "Illegal invocation". They must therefore never be wrapped. This is an
6833
+ * allow-list rather than a deny-list so that *every* such exotic type is
6834
+ * excluded by default, not just the handful we happened to enumerate.
6835
+ *
6836
+ * Plain user-defined class instances report `[object Object]` (unless they set
6837
+ * `Symbol.toStringTag`) and so remain reactive, preserving prior behaviour.
6838
+ * `Map`/`Set` stay reactive because the proxy specially wraps their mutation
6839
+ * methods (see the `get` trap); `WeakMap`/`WeakSet` are intentionally excluded
6840
+ * since they cannot be wrapped that way and would break the same as `File`.
6841
+ *
6842
+ * Callers that want a plain object kept non-reactive can still opt out
6843
+ * explicitly via {@link markRaw}.
7033
6844
  */
7034
- static rewriteExpression(expression, identifiers, options) {
7035
- // Reserved words and built-in objects that should not be prefixed with 'this.'
7036
- const reserved = new Set([
7037
- 'event', '$event', '$ctx', '$newValue',
7038
- 'true', 'false', 'null', 'undefined', 'NaN', 'Infinity',
7039
- 'Math', 'Date', 'String', 'Number', 'Boolean', 'Array', 'Object',
7040
- 'JSON', 'console', 'window', 'document', 'navigator',
7041
- 'parseInt', 'parseFloat', 'isNaN', 'isFinite',
7042
- 'encodeURI', 'decodeURI', 'encodeURIComponent', 'decodeURIComponent'
7043
- ]);
7044
- // Extract ALL identifiers from the expression (including assignment left-hand side)
7045
- // This is necessary because the passed 'identifiers' parameter only includes
7046
- // identifiers that are used (right-hand side), not assigned to (left-hand side)
7047
- let allIdentifiersInExpression;
7048
- try {
7049
- allIdentifiersInExpression = ExpressionUtils.extractIdentifiers(expression, {}, options);
6845
+ static REACTIVE_TYPE_TAGS = new Set([
6846
+ '[object Object]',
6847
+ '[object Array]',
6848
+ '[object Arguments]',
6849
+ '[object Map]',
6850
+ '[object Set]',
6851
+ ]);
6852
+ /**
6853
+ * Whether the given non-null object value is one we deeply proxy.
6854
+ * See {@link REACTIVE_TYPE_TAGS} for the reasoning.
6855
+ */
6856
+ static isReactivable(value) {
6857
+ return this.REACTIVE_TYPE_TAGS.has(Object.prototype.toString.call(value));
6858
+ }
6859
+ /**
6860
+ * Creates a reactive proxy for the given object.
6861
+ * The proxy will call the onChange callback whenever a property is modified.
6862
+ *
6863
+ * @param target The object to make reactive.
6864
+ * @param onChange Callback function to call when the object changes. Receives the full path of the changed property.
6865
+ * @param path The current path in the object tree (used internally for nested objects).
6866
+ * @param inheritedDispatcher Internal: the dispatcher inherited from an enclosing create() call when wrapping a nested target. External callers must omit this.
6867
+ * @returns A reactive proxy of the target object.
6868
+ */
6869
+ static create(target, onChange, path = '', inheritedDispatcher) {
6870
+ // If the target is not an object or is null, return it as-is
6871
+ if (typeof target !== 'object' || target === null) {
6872
+ return target;
7050
6873
  }
7051
- catch (error) {
7052
- console.warn('[ichigo.js] Failed to extract identifiers from expression:', expression, error);
7053
- return expression;
6874
+ // Don't wrap objects marked as raw (non-reactive)
6875
+ if (this.rawObjects.has(target)) {
6876
+ return target;
7054
6877
  }
7055
- // Create a Set of identifiers available in bindings (from data, computed, methods)
7056
- // We need to know which identifiers are valid binding properties
7057
- const bindingIdentifiers = new Set(identifiers.filter(id => !reserved.has(id)));
7058
- // For assignment expressions, we also need to include the left-hand side identifier
7059
- // even if it's not in the tracking identifiers (because it's being assigned, not read)
7060
- for (const id of allIdentifiersInExpression) {
7061
- if (!reserved.has(id)) {
7062
- bindingIdentifiers.add(id);
6878
+ // Only deeply proxy plain data containers; return every built-in/host
6879
+ // object (Date, File, Blob, Promise, WeakMap, DOM nodes, …) as-is, since
6880
+ // their native methods break when invoked through a Proxy receiver.
6881
+ if (!this.isReactivable(target)) {
6882
+ return target;
6883
+ }
6884
+ // Check if the target is already a proxy - if so, return it as-is to prevent double-wrapping
6885
+ if (this.proxyToTarget.has(target)) {
6886
+ return target;
6887
+ }
6888
+ // Resolve (and register) the dispatcher for this (target, path).
6889
+ // Nested create() calls inherit the dispatcher from the enclosing call so
6890
+ // that a single dispatcher fans out changes at any depth of the subtree.
6891
+ const dispatcher = this.resolveDispatcher(target, path, inheritedDispatcher);
6892
+ if (onChange) {
6893
+ dispatcher.listeners.add(onChange);
6894
+ }
6895
+ // Check if we already have a proxy for this target with this path
6896
+ let pathMap = this.proxyCache.get(target);
6897
+ if (pathMap) {
6898
+ const existingProxy = pathMap.get(path);
6899
+ if (existingProxy) {
6900
+ return existingProxy;
7063
6901
  }
7064
6902
  }
7065
- if (bindingIdentifiers.size === 0) {
7066
- return expression;
6903
+ else {
6904
+ pathMap = new Map();
6905
+ this.proxyCache.set(target, pathMap);
7067
6906
  }
7068
- try {
7069
- // Build a map of positions to replace: { start: number, end: number, name: string, asThisAlias?: boolean }[]
7070
- const replacements = [];
7071
- // In script mode we must not wrap in parens (that would make multi-statement input invalid).
7072
- // Offsets from the parser therefore refer directly to the original expression, so no shift.
7073
- const asScript = options?.asScript === true;
7074
- const source = asScript ? expression : `(${expression})`;
7075
- const offsetShift = asScript ? 0 : 1;
7076
- const parsedAst = parse(source, { ecmaVersion: 'latest' });
7077
- // Track identifiers that are locally declared within the handler body (let/const/var, function
7078
- // params) so we don't rewrite them to `this.xxx`. Additionally detect `this` aliases such as
7079
- // `let vm = this;` so that `vm.x` can be rewritten to `this.x` even though `vm` is locally declared.
7080
- const locallyDeclared = new Set();
7081
- const thisAliases = new Set();
7082
- if (asScript) {
7083
- full(parsedAst, (node) => {
7084
- if (node.type === 'VariableDeclarator' && node.id?.type === 'Identifier') {
7085
- locallyDeclared.add(node.id.name);
7086
- if (node.init && node.init.type === 'ThisExpression') {
7087
- thisAliases.add(node.id.name);
7088
- }
6907
+ // Create the proxy with path captured in closure
6908
+ const proxy = new Proxy(target, {
6909
+ get(obj, key, receiver) {
6910
+ const value = Reflect.get(obj, key);
6911
+ // Record the read for an expression being tracked (see track()).
6912
+ // Function-valued members (push, Map#get, …) are accesses to behaviour,
6913
+ // not data, so they are left out.
6914
+ if (ReactiveProxy.tracker && typeof key !== 'symbol' && typeof value !== 'function') {
6915
+ ReactiveProxy.tracker.add(ReactiveProxy.joinPath(path, key));
6916
+ }
6917
+ // If the value is an object or array, make it reactive too
6918
+ if (typeof value === 'object' && value !== null) {
6919
+ // Don't wrap objects marked as raw (non-reactive)
6920
+ if (ReactiveProxy.rawObjects.has(value)) {
6921
+ return value;
7089
6922
  }
7090
- else if (node.type === 'FunctionDeclaration' && node.id?.type === 'Identifier') {
7091
- locallyDeclared.add(node.id.name);
6923
+ // Only deeply proxy plain data containers; hand back built-in
6924
+ // and host objects (Date, File, Blob, Promise, WeakMap, DOM
6925
+ // nodes, …) untouched so their native methods keep working.
6926
+ if (!ReactiveProxy.isReactivable(value)) {
6927
+ return value;
7092
6928
  }
7093
- });
7094
- }
7095
- // Collect all identifier nodes that should be replaced
7096
- // Use walk.fullAncestor to visit ALL nodes (including assignment LHS) while tracking ancestors
7097
- fullAncestor(parsedAst, (node, _state, ancestors) => {
7098
- if (node.type !== 'Identifier') {
7099
- return;
7100
- }
7101
- // Skip if not in our identifier set
7102
- if (!bindingIdentifiers.has(node.name)) {
7103
- return;
6929
+ // Build the nested path
6930
+ const nestedPath = ReactiveProxy.joinPath(path, key);
6931
+ return ReactiveProxy.create(value, undefined, nestedPath, dispatcher);
7104
6932
  }
7105
- // Locally declared identifiers must not be rewritten to `this.xxx`. The one
7106
- // exception is a `this`-alias (e.g. `let vm = this;`): when such an alias is
7107
- // used as the OBJECT of a MemberExpression (`vm.x`), we replace just the
7108
- // object with `this` so that `vm.x` becomes `this.x`. In every other position
7109
- // (the declaration LHS, a bare reference such as `console.log(vm)`, an
7110
- // argument, etc.) the alias must be left untouched — rewriting it to
7111
- // `this.vm` would either produce a syntax error (`let this.vm = this;`) or
7112
- // change the meaning at runtime.
7113
- // walk.fullAncestor includes the visited node itself as the last entry of
7114
- // `ancestors`, so the actual parent node is at length - 2.
7115
- const parentNode = ancestors.length >= 2 ? ancestors[ancestors.length - 2] : undefined;
7116
- const isAliasAsMemberObject = parentNode?.type === 'MemberExpression' && parentNode.object === node;
7117
- if (locallyDeclared.has(node.name)) {
7118
- if (!thisAliases.has(node.name) || !isAliasAsMemberObject) {
7119
- return;
7120
- }
7121
- replacements.push({
7122
- start: node.start - offsetShift,
7123
- end: node.end - offsetShift,
7124
- name: node.name,
7125
- asThisAlias: true
7126
- });
7127
- return;
6933
+ // If the value is a function, we need to wrap it to ensure that any mutations it performs also trigger onChange
6934
+ if (typeof value === 'function') {
6935
+ // For arrays, we only want to wrap mutation methods, not read methods like 'slice', 'concat', etc.
6936
+ if (Array.isArray(obj)) {
6937
+ const arrayMutationMethods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'];
6938
+ if (!arrayMutationMethods.includes(key)) {
6939
+ return value;
6940
+ }
6941
+ return function (...args) {
6942
+ const result = value.apply(this === receiver ? obj : this, args);
6943
+ ReactiveProxy.dispatch(dispatcher, path || undefined);
6944
+ return result;
6945
+ };
6946
+ }
6947
+ // For Map, we only want to wrap mutation methods, not read methods like 'get' or 'has'
6948
+ if (obj.constructor.name === 'Map') {
6949
+ const mapMutationMethods = ['set', 'delete', 'clear'];
6950
+ return function (...args) {
6951
+ const result = value.apply(this === receiver ? obj : this, args);
6952
+ if (mapMutationMethods.includes(key)) {
6953
+ ReactiveProxy.dispatch(dispatcher, path || undefined);
6954
+ }
6955
+ return result;
6956
+ };
6957
+ }
6958
+ // For Set, we only want to wrap mutation methods, not read methods like 'has'
6959
+ if (obj.constructor.name === 'Set') {
6960
+ const setMutationMethods = ['add', 'delete', 'clear'];
6961
+ return function (...args) {
6962
+ const result = value.apply(this === receiver ? obj : this, args);
6963
+ if (setMutationMethods.includes(key)) {
6964
+ ReactiveProxy.dispatch(dispatcher, path || undefined);
6965
+ }
6966
+ return result;
6967
+ };
6968
+ }
7128
6969
  }
7129
- // Skip identifiers that are the property (not the object) of a non-computed
7130
- // member access (e.g., the `prop` in `obj.prop`).
7131
- if (parentNode?.type === 'MemberExpression' && !parentNode.computed && parentNode.property === node) {
7132
- return;
6970
+ return value;
6971
+ },
6972
+ set(obj, key, value) {
6973
+ const oldValue = Reflect.get(obj, key);
6974
+ const result = Reflect.set(obj, key, value);
6975
+ // Only trigger onChange if the value actually changed
6976
+ if (oldValue !== value) {
6977
+ ReactiveProxy.dispatch(dispatcher, ReactiveProxy.joinPath(path, key));
7133
6978
  }
7134
- // Add to replacements list (adjust for the wrapping parentheses in expression mode)
7135
- replacements.push({
7136
- start: node.start - offsetShift,
7137
- end: node.end - offsetShift,
7138
- name: node.name
7139
- });
7140
- });
7141
- // Sort replacements by start position (descending) to replace from end to start
7142
- replacements.sort((a, b) => b.start - a.start);
7143
- // Apply replacements
7144
- let result = expression;
7145
- for (const replacement of replacements) {
7146
- const insertion = replacement.asThisAlias ? 'this' : `this.${replacement.name}`;
7147
- result = result.substring(0, replacement.start) + insertion + result.substring(replacement.end);
6979
+ return result;
6980
+ },
6981
+ deleteProperty(obj, key) {
6982
+ const result = Reflect.deleteProperty(obj, key);
6983
+ ReactiveProxy.dispatch(dispatcher, ReactiveProxy.joinPath(path, key));
6984
+ return result;
7148
6985
  }
7149
- return result;
7150
- }
7151
- catch (error) {
7152
- // If AST parsing fails, fall back to the original expression
7153
- console.warn('Failed to rewrite expression:', expression, error);
7154
- return expression;
6986
+ });
6987
+ // Cache the proxy for this path
6988
+ pathMap.set(path, proxy);
6989
+ // Track that this proxy wraps the target to prevent double-wrapping
6990
+ this.proxyToTarget.set(proxy, target);
6991
+ // Store the path for this proxy (for computed property mapping)
6992
+ if (path) {
6993
+ this.proxyPaths.set(proxy, path);
7155
6994
  }
6995
+ return proxy;
7156
6996
  }
7157
- }
7158
-
7159
- // Copyright (c) 2025 MintJams Inc. Licensed under MIT License.
7160
- /**
7161
- * A centralized expression evaluator that normalizes expression evaluation across all directives.
7162
- *
7163
- * This class provides:
7164
- * - Consistent identifier extraction and binding resolution
7165
- * - Unified global object whitelisting (Math, Date, JSON, etc.)
7166
- * - Expression caching for performance
7167
- * - Centralized error handling
7168
- * - Support for custom expression rewriting (for VOnDirective)
7169
- *
7170
- * Example usage:
7171
- * ```typescript
7172
- * const evaluator = ExpressionEvaluator.create(
7173
- * "count + 1",
7174
- * bindings,
7175
- * functionDependencies
7176
- * );
7177
- * const result = evaluator.evaluate();
7178
- * ```
7179
- */
7180
- class ExpressionEvaluator {
7181
- /**
7182
- * Cache for compiled expression functions.
7183
- * Key: expression string
7184
- * Value: compiled function
7185
- */
7186
- static cache = new Map();
7187
6997
  /**
7188
- * Global objects that are whitelisted for use in expressions.
7189
- * These objects are available to all expressions for convenience and compatibility.
6998
+ * Looks up the dispatcher associated with (target, path), or installs a new one.
6999
+ * When called for a nested target during proxy walking, the enclosing dispatcher
7000
+ * is reused so a single subtree fans out changes through one notification path.
7190
7001
  */
7191
- static GLOBAL_WHITELIST = {
7192
- Math,
7193
- Date,
7194
- JSON,
7195
- Array,
7196
- Object,
7197
- String,
7198
- Number,
7199
- Boolean,
7200
- parseInt,
7201
- parseFloat,
7202
- isNaN,
7203
- isFinite,
7204
- encodeURI,
7205
- decodeURI,
7206
- encodeURIComponent,
7207
- decodeURIComponent
7208
- };
7002
+ static resolveDispatcher(target, path, inheritedDispatcher) {
7003
+ let pathMap = this.dispatchers.get(target);
7004
+ if (!pathMap) {
7005
+ pathMap = new Map();
7006
+ this.dispatchers.set(target, pathMap);
7007
+ }
7008
+ const existing = pathMap.get(path);
7009
+ if (existing) {
7010
+ return existing;
7011
+ }
7012
+ const dispatcher = inheritedDispatcher ?? { listeners: new Set() };
7013
+ pathMap.set(path, dispatcher);
7014
+ return dispatcher;
7015
+ }
7209
7016
  /**
7210
- * The original expression string.
7017
+ * Invokes every listener attached to the dispatcher.
7018
+ * Iterates a snapshot of the listener set so unsubscribing during dispatch is safe.
7211
7019
  */
7212
- expression;
7020
+ static dispatch(dispatcher, changedPath) {
7021
+ if (dispatcher.listeners.size === 0) {
7022
+ return;
7023
+ }
7024
+ const snapshot = Array.from(dispatcher.listeners);
7025
+ for (const listener of snapshot) {
7026
+ listener(changedPath);
7027
+ }
7028
+ }
7213
7029
  /**
7214
- * The identifiers extracted from the expression.
7030
+ * Subscribes a listener to changes inside the subtree of an existing reactive proxy.
7031
+ *
7032
+ * The listener is scoped by the proxy's source path: only changes at or below that
7033
+ * path are delivered, which lets a child component receive notifications when the
7034
+ * nested contents of a prop change even though the prop reference itself is unchanged.
7035
+ *
7036
+ * @param proxyOrTarget A proxy returned from create(), or the underlying target object.
7037
+ * @param listener Called with the full source path of every relevant change.
7038
+ * @returns A function that removes the subscription.
7215
7039
  */
7216
- identifiers;
7217
- // The original extracted identifiers (may include member chains like 'a.b')
7218
- originalIdentifiers;
7040
+ static subscribe(proxyOrTarget, listener) {
7041
+ if (typeof proxyOrTarget !== 'object' || proxyOrTarget === null) {
7042
+ return () => { };
7043
+ }
7044
+ const target = (this.proxyToTarget.get(proxyOrTarget) ?? proxyOrTarget);
7045
+ const scopePath = this.proxyPaths.get(proxyOrTarget) ?? '';
7046
+ const pathMap = this.dispatchers.get(target);
7047
+ const dispatcher = pathMap?.get(scopePath);
7048
+ if (!dispatcher) {
7049
+ return () => { };
7050
+ }
7051
+ const wrapper = scopePath
7052
+ ? (changedPath) => {
7053
+ if (changedPath === scopePath ||
7054
+ (typeof changedPath === 'string' && (changedPath.startsWith(scopePath + '.') ||
7055
+ changedPath.startsWith(scopePath + '[')))) {
7056
+ listener(changedPath);
7057
+ }
7058
+ }
7059
+ : listener;
7060
+ dispatcher.listeners.add(wrapper);
7061
+ return () => {
7062
+ dispatcher.listeners.delete(wrapper);
7063
+ };
7064
+ }
7219
7065
  /**
7220
- * The bindings to use for evaluating the expression.
7066
+ * Checks if the given object is a reactive proxy.
7067
+ *
7068
+ * @param obj The object to check.
7069
+ * @returns True if the object is a reactive proxy, false otherwise.
7221
7070
  */
7222
- bindings;
7071
+ static isReactive(obj) {
7072
+ return this.proxyCache.has(obj);
7073
+ }
7223
7074
  /**
7224
- * The compiled function for evaluating the expression.
7075
+ * Unwraps a reactive proxy to get the original object.
7076
+ * If the object is not a proxy, returns it as-is.
7077
+ *
7078
+ * @param obj The object to unwrap.
7079
+ * @returns The original object.
7225
7080
  */
7226
- evaluatorFunc;
7081
+ static unwrap(obj) {
7082
+ // This is a simplified implementation
7083
+ // In a full implementation, we'd need to store a reverse mapping
7084
+ return obj;
7085
+ }
7227
7086
  /**
7228
- * Additional context variables (e.g., 'event', '$ctx').
7087
+ * Marks an object as "raw" (non-reactive).
7088
+ * Objects marked as raw will not be wrapped with Proxy when accessed from reactive objects.
7089
+ * This is useful for objects that should not be reactive, such as:
7090
+ * - Objects with private fields (class instances with # fields)
7091
+ * - Third-party library instances
7092
+ * - Objects used only for method calls
7093
+ *
7094
+ * @param obj The object to mark as raw.
7095
+ * @returns The same object (for chaining).
7229
7096
  */
7230
- additionalContext;
7097
+ static markRaw(obj) {
7098
+ if (typeof obj === 'object' && obj !== null) {
7099
+ this.rawObjects.add(obj);
7100
+ }
7101
+ return obj;
7102
+ }
7231
7103
  /**
7232
- * Private constructor. Use ExpressionEvaluator.create() to create instances.
7104
+ * Checks if an object is marked as raw (non-reactive).
7105
+ *
7106
+ * @param obj The object to check.
7107
+ * @returns True if the object is marked as raw, false otherwise.
7233
7108
  */
7234
- constructor(expression, bindings, identifiers, originalIdentifiers, evaluatorFunc, additionalContext) {
7235
- this.expression = expression;
7236
- this.bindings = bindings;
7237
- this.identifiers = identifiers;
7238
- this.originalIdentifiers = originalIdentifiers;
7239
- this.evaluatorFunc = evaluatorFunc;
7240
- this.additionalContext = additionalContext;
7109
+ static isRaw(obj) {
7110
+ return typeof obj === 'object' && obj !== null && this.rawObjects.has(obj);
7241
7111
  }
7242
7112
  /**
7243
- * Creates an ExpressionEvaluator instance.
7244
- *
7245
- * @param expression The expression string to evaluate.
7246
- * @param bindings The bindings to use for evaluating the expression.
7247
- * @param functionDependencies A dictionary mapping function names to their dependencies.
7248
- * @param options Optional configuration for the evaluator.
7249
- * @returns An ExpressionEvaluator instance.
7113
+ * Gets the source path for a proxy object.
7114
+ * This is used to map computed property values back to their source paths.
7115
+ * For example, if a computed property returns `model.elements[0]`,
7116
+ * this method returns "model.elements[0]" for that object.
7117
+ *
7118
+ * @param obj The proxy object to get the path for.
7119
+ * @returns The source path, or undefined if not found.
7250
7120
  */
7251
- static create(expression, bindings, functionDependencies, options) {
7252
- // Extract identifiers from the expression (may include member chains like 'a.b')
7253
- const extractedIdentifiers = ExpressionUtils.extractIdentifiers(expression, functionDependencies);
7254
- // For compilation we must only pass simple identifier names (no dots).
7255
- // Use the base (left-most) segment of member chains and preserve order.
7256
- const baseIdentifiers = [];
7257
- for (const id of extractedIdentifiers) {
7258
- const base = ExpressionUtils.baseIdentifier(id);
7259
- if (!baseIdentifiers.includes(base)) {
7260
- baseIdentifiers.push(base);
7261
- }
7262
- }
7263
- // Apply custom rewrite if provided (used by VOnDirective)
7264
- let processedExpression = expression;
7265
- if (options?.rewriteExpression) {
7266
- // Pass the original extracted identifiers (may include member chains)
7267
- processedExpression = options.rewriteExpression(expression, extractedIdentifiers);
7268
- }
7269
- // Build cache key including options that affect compilation
7270
- const cacheKey = JSON.stringify({
7271
- expr: processedExpression,
7272
- additionalCtx: options?.additionalContext ? Object.keys(options.additionalContext).sort() : []
7273
- });
7274
- // Check cache
7275
- let evaluatorFunc = this.cache.get(cacheKey);
7276
- if (!evaluatorFunc) {
7277
- // Compile the expression using base identifiers (no dots)
7278
- evaluatorFunc = this.compileExpression(processedExpression, baseIdentifiers, options);
7279
- // Cache the compiled function
7280
- this.cache.set(cacheKey, evaluatorFunc);
7121
+ static getPath(obj) {
7122
+ if (typeof obj !== 'object' || obj === null) {
7123
+ return undefined;
7281
7124
  }
7282
- return new ExpressionEvaluator(expression, bindings, baseIdentifiers, extractedIdentifiers, evaluatorFunc, options?.additionalContext);
7125
+ return this.proxyPaths.get(obj);
7283
7126
  }
7284
7127
  /**
7285
- * Compiles an expression into a function.
7128
+ * Registers a path alias.
7129
+ * This is used when a variable is assigned to reference an existing reactive object.
7130
+ * For example, when `editingNestedStep = routes[0].steps[0]`, this creates an alias
7131
+ * so that changes to "routes[0].steps[0].steps" also match "editingNestedStep.steps".
7286
7132
  *
7287
- * @param expression The expression string to compile.
7288
- * @param identifiers The identifiers extracted from the expression.
7289
- * @param options Optional configuration.
7290
- * @returns A compiled function.
7133
+ * @param aliasPath The alias path (e.g., "editingNestedStep")
7134
+ * @param sourcePath The source path (e.g., "routes[0].steps[0]")
7291
7135
  */
7292
- static compileExpression(expression, identifiers, options) {
7293
- // Build parameter list: globals first, then identifiers (base names), then additional context
7294
- // Filter out identifiers that are already in the global whitelist to avoid duplicate parameters
7295
- const globalKeys = new Set(Object.keys(ExpressionEvaluator.GLOBAL_WHITELIST));
7296
- const filteredIdentifiers = identifiers.filter(id => !globalKeys.has(id));
7297
- const params = [
7298
- ...Object.keys(ExpressionEvaluator.GLOBAL_WHITELIST),
7299
- ...filteredIdentifiers
7300
- ];
7301
- // Add additional context parameters if provided
7302
- if (options?.additionalContext) {
7303
- params.push(...Object.keys(options.additionalContext));
7304
- }
7305
- const args = params.join(", ");
7306
- const funcBody = `return (${expression});`;
7307
- try {
7308
- // Create the function with error handling
7309
- return new Function(args, funcBody);
7310
- }
7311
- catch (error) {
7312
- throw new Error(`Failed to compile expression: ${expression}\nError: ${error}`);
7136
+ static registerPathAlias(aliasPath, sourcePath) {
7137
+ if (aliasPath && sourcePath && aliasPath !== sourcePath) {
7138
+ this.pathAliases.set(aliasPath, sourcePath);
7313
7139
  }
7314
7140
  }
7315
7141
  /**
7316
- * Evaluates the expression with the current bindings.
7142
+ * Unregisters a path alias.
7317
7143
  *
7318
- * @returns The result of the expression evaluation.
7144
+ * @param aliasPath The alias path to remove
7319
7145
  */
7320
- evaluate() {
7321
- try {
7322
- // Build arguments: globals first, then identifiers, then additional context
7323
- // Filter out identifiers that are already in the global whitelist to match compileExpression
7324
- const globalKeys = new Set(Object.keys(ExpressionEvaluator.GLOBAL_WHITELIST));
7325
- const filteredIdentifiers = this.identifiers.filter(id => !globalKeys.has(id));
7326
- const values = [
7327
- // Global whitelist values
7328
- ...Object.values(ExpressionEvaluator.GLOBAL_WHITELIST),
7329
- // Identifier values from bindings (excluding globals)
7330
- ...filteredIdentifiers.map(id => this.bindings.get(id))
7331
- ];
7332
- // Add additional context values if provided
7333
- if (this.additionalContext) {
7334
- values.push(...Object.values(this.additionalContext));
7146
+ static unregisterPathAlias(aliasPath) {
7147
+ // Remove all aliases that start with the given path
7148
+ for (const key of this.pathAliases.keys()) {
7149
+ if (key === aliasPath || key.startsWith(aliasPath + '.') || key.startsWith(aliasPath + '[')) {
7150
+ this.pathAliases.delete(key);
7335
7151
  }
7336
- // Call the compiled function with the gathered values
7337
- return this.evaluatorFunc(...values);
7338
- }
7339
- catch (error) {
7340
- console.error(`[ichigo.js] Error evaluating expression: ${this.expression}`, error);
7341
- throw error;
7342
7152
  }
7343
7153
  }
7344
7154
  /**
7345
- * Evaluates the expression and returns the result as a boolean.
7155
+ * Resolves an alias path to its source path.
7156
+ * Also handles nested paths (e.g., "editingNestedStep.steps" -> "routes[0].steps[0].steps").
7346
7157
  *
7347
- * @returns The boolean result of the expression evaluation.
7158
+ * @param aliasPath The alias path to resolve
7159
+ * @returns The source path, or undefined if no alias exists
7348
7160
  */
7349
- evaluateAsBoolean() {
7350
- return Boolean(this.evaluate());
7161
+ static resolvePathAlias(aliasPath) {
7162
+ // Direct match
7163
+ if (this.pathAliases.has(aliasPath)) {
7164
+ return this.pathAliases.get(aliasPath);
7165
+ }
7166
+ // Check if this is a nested path of an aliased variable
7167
+ // e.g., "editingNestedStep.steps" when "editingNestedStep" is aliased
7168
+ for (const [alias, source] of this.pathAliases.entries()) {
7169
+ if (aliasPath.startsWith(alias + '.') || aliasPath.startsWith(alias + '[')) {
7170
+ const suffix = aliasPath.substring(alias.length);
7171
+ return source + suffix;
7172
+ }
7173
+ }
7174
+ return undefined;
7351
7175
  }
7352
7176
  /**
7353
- * Gets the list of identifiers extracted from the expression.
7177
+ * Checks if a change path matches an identifier, considering path aliases.
7178
+ * For example, if "editingNestedStep" is aliased to "routes[0].steps[0]",
7179
+ * then a change to "routes[0].steps[0].steps" should match "editingNestedStep.steps".
7180
+ *
7181
+ * @param changePath The path that was changed (e.g., "routes[0].steps[0].steps")
7182
+ * @param identifier The identifier to check (e.g., "editingNestedStep.steps")
7183
+ * @returns True if the change path matches the identifier
7354
7184
  */
7355
- get dependentIdentifiers() {
7356
- // Return the original extracted identifiers (may include member chains like 'a.b')
7357
- return this.originalIdentifiers;
7185
+ static doesChangeMatchIdentifier(changePath, identifier) {
7186
+ // Direct match
7187
+ if (this.pathMatches(changePath, identifier)) {
7188
+ return true;
7189
+ }
7190
+ // Check if the change path is the resolved identifier or lies below it
7191
+ // (handles cases like array item changes)
7192
+ const resolvedIdentifier = this.resolvePathAlias(identifier);
7193
+ if (resolvedIdentifier && this.pathMatches(changePath, resolvedIdentifier, true)) {
7194
+ return true;
7195
+ }
7196
+ return false;
7358
7197
  }
7359
7198
  /**
7360
- * Clears the expression cache.
7361
- * This should be called sparingly, as it will force recompilation of all expressions.
7199
+ * Checks whether a change path names the location an identifier refers to.
7200
+ * A "[*]" index in the identifier (e.g. "items[*].name" for `items[i].name`)
7201
+ * stands for an index known only when the expression runs and matches any
7202
+ * one index or key.
7203
+ *
7204
+ * @param changePath The path that was changed (e.g., "items[2].name")
7205
+ * @param identifier The identifier to check (e.g., "items[*].name")
7206
+ * @param descendants Whether a change below the identifier also matches
7207
+ * @returns True if the change path matches the identifier
7362
7208
  */
7363
- static clearCache() {
7364
- this.cache.clear();
7209
+ static pathMatches(changePath, identifier, descendants = false) {
7210
+ if (changePath === identifier) {
7211
+ return true;
7212
+ }
7213
+ if (!identifier.includes('[*]')) {
7214
+ return descendants && (changePath.startsWith(identifier + '.') || changePath.startsWith(identifier + '['));
7215
+ }
7216
+ const cacheKey = descendants ? identifier + ' ' : identifier;
7217
+ let pattern = this.pathPatterns.get(cacheKey);
7218
+ if (!pattern) {
7219
+ const source = identifier
7220
+ .split('[*]')
7221
+ .map(part => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
7222
+ .join('(?:\\[[^\\]]*\\]|\\.[^.[]+)');
7223
+ pattern = new RegExp(`^${source}${descendants ? '(?:[.[].*)?' : ''}$`);
7224
+ this.pathPatterns.set(cacheKey, pattern);
7225
+ }
7226
+ return pattern.test(changePath);
7365
7227
  }
7366
7228
  }
7367
7229
 
7368
7230
  // Copyright (c) 2025 MintJams Inc. Licensed under MIT License.
7369
7231
  /**
7370
- * Directive for binding dynamic attributes to DOM elements.
7371
- * The `v-bind` directive allows you to bind HTML attributes to expressions in your data model.
7372
- * 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.
7373
- * Example usage:
7374
- * <img v-bind:src="imageSrc" />
7375
- * The value of `imageSrc` should be defined in the component's data object.
7376
- * When the value of `imageSrc` changes, the `src` attribute of the `<img>` element will automatically update to reflect the new value.
7377
- * This directive is particularly useful for dynamically updating attributes based on user interactions or other data changes in your application.
7378
- * It helps keep the DOM in sync with the underlying data model, making it easier to build reactive user interfaces.
7379
- * The `v-bind` directive can also be used with shorthand syntax using a colon (:).
7380
- * For example, `:src="imageSrc"` is equivalent to `v-bind:src="imageSrc"`.
7381
- * This shorthand syntax is often used for brevity and improved readability in templates.
7382
- * 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.
7232
+ * Utility class for analyzing JavaScript expressions to extract variable and function dependencies.
7383
7233
  */
7384
- class VBindDirective {
7385
- /**
7386
- * The virtual node to which this directive is applied.
7387
- */
7388
- #vNode;
7389
- /**
7390
- * The expression evaluator for this directive.
7391
- */
7392
- #evaluator;
7393
- /**
7394
- * The name of the attribute to bind (e.g., "src", "class", "style").
7395
- */
7396
- #attributeName;
7397
- /**
7398
- * The original expression string from the directive.
7399
- */
7400
- #expression;
7401
- /**
7402
- * The set of class names managed by this directive (used when binding to the "class" attribute).
7403
- * This helps in tracking which classes were added by this directive to avoid conflicts with other class manipulations.
7404
- */
7405
- #managedClasses = new Set();
7406
- /**
7407
- * The set of style properties managed by this directive (used when binding to the "style" attribute).
7408
- * This helps in tracking which styles were added by this directive to avoid conflicts with other style manipulations.
7409
- */
7410
- #managedStyles = new Set();
7234
+ class ExpressionUtils {
7411
7235
  /**
7412
- * @param context The context for parsing the directive.
7236
+ * Extracts variable and function names used in the expression.
7237
+ * @param expression The expression string to analyze.
7238
+ * @param functionDependencies A dictionary mapping function names to their dependencies.
7239
+ * @param options Optional parsing options.
7240
+ * - asScript: If true, parse the input as a Script (allows multi-statement source with semicolons,
7241
+ * declarations, control-flow, etc.). If false/omitted, parse as a single expression (the default,
7242
+ * for backward compatibility with interpolation and binding directives).
7243
+ * @returns An array of identifier names.
7413
7244
  */
7414
- constructor(context) {
7415
- this.#vNode = context.vNode;
7416
- // Extract the attribute name from the directive
7417
- // e.g., "v-bind:src" -> "src", ":href" -> "href"
7418
- const attrName = context.attribute.name;
7419
- if (attrName.startsWith('v-bind:')) {
7420
- this.#attributeName = attrName.substring(7);
7421
- }
7422
- else if (attrName.startsWith(':')) {
7423
- this.#attributeName = attrName.substring(1);
7424
- }
7425
- // Parse the expression and create the evaluator
7426
- this.#expression = context.attribute.value;
7427
- if (this.#expression && context.vNode.bindings) {
7428
- this.#evaluator = ExpressionEvaluator.create(this.#expression, context.vNode.bindings, context.vNode.vApplication.functionDependencies);
7429
- }
7430
- // Remove the directive attribute from the element
7431
- this.#vNode.node.removeAttribute(context.attribute.name);
7432
- }
7433
- /**
7434
- * @inheritdoc
7435
- */
7436
- get name() {
7437
- return StandardDirectiveName.V_BIND;
7438
- }
7439
- /**
7440
- * @inheritdoc
7441
- */
7442
- get vNode() {
7443
- return this.#vNode;
7444
- }
7445
- /**
7446
- * @inheritdoc
7447
- */
7448
- get needsAnchor() {
7449
- return false;
7450
- }
7451
- /**
7452
- * @inheritdoc
7453
- */
7454
- get bindingsPreparer() {
7455
- return undefined;
7456
- }
7457
- /**
7458
- * @inheritdoc
7459
- */
7460
- get domUpdater() {
7461
- const identifiers = this.#evaluator?.dependentIdentifiers ?? [];
7462
- // Create an updater that handles the attribute binding
7463
- const updater = {
7464
- get dependentIdentifiers() {
7465
- return identifiers;
7466
- },
7467
- applyToDOM: () => {
7468
- this.#render();
7245
+ static extractIdentifiers(expression, functionDependencies, options) {
7246
+ const identifiers = new Set();
7247
+ // In expression mode we wrap in parens so acorn parses the source as a single expression.
7248
+ // In script mode we parse as a Program so that multi-statement bodies (e.g. "a=1; b=2") work.
7249
+ const source = options?.asScript ? expression : `(${expression})`;
7250
+ const ast = parse(source, { ecmaVersion: "latest" });
7251
+ // Use walk.full instead of walk.simple to visit ALL nodes including assignment LHS
7252
+ full(ast, (node) => {
7253
+ if (node.type === 'Identifier') {
7254
+ identifiers.add(node.name);
7255
+ // Check if the identifier is a function name
7256
+ if (functionDependencies[node.name]) {
7257
+ // If it is, add its dependencies to the list of identifiers
7258
+ for (const dependency of functionDependencies[node.name]) {
7259
+ identifiers.add(dependency);
7260
+ }
7261
+ }
7469
7262
  }
7470
- };
7471
- return updater;
7472
- }
7473
- /**
7474
- * @inheritdoc
7475
- */
7476
- get templatize() {
7477
- return false;
7478
- }
7479
- /**
7480
- * @inheritdoc
7481
- */
7482
- get dependentIdentifiers() {
7483
- return this.#evaluator?.dependentIdentifiers ?? [];
7484
- }
7485
- /**
7486
- * Indicates if this directive is binding the "key" attribute.
7487
- * The "key" attribute is special and is used for optimizing rendering of lists.
7488
- * If this directive is binding the "key" attribute, it will be handled by the VForDirective.
7489
- */
7490
- get isKey() {
7491
- return (this.#attributeName === 'key');
7492
- }
7493
- /**
7494
- * Indicates if this directive is binding the "options" attribute or any of its sub-properties (e.g., "options.intersection").
7495
- * The "options" attribute is special and is used for passing options to certain directives like VIntersectionDirective.
7496
- */
7497
- get isOptions() {
7498
- return (this.#attributeName === 'options' || this.#attributeName?.startsWith('options.') === true);
7499
- }
7500
- /**
7501
- * Gets the name of the attribute being bound (e.g., "src", "class", "options", "options.intersection").
7502
- */
7503
- get attributeName() {
7504
- return this.#attributeName;
7505
- }
7506
- /**
7507
- * Gets the original expression string from the directive.
7508
- */
7509
- get expression() {
7510
- return this.#expression;
7511
- }
7512
- /**
7513
- * Evaluates the bound expression and returns the current value.
7514
- * Returns undefined when no evaluator is available (e.g., empty expression).
7515
- */
7516
- evaluate() {
7517
- return this.#evaluator?.evaluate();
7518
- }
7519
- /**
7520
- * @inheritdoc
7521
- */
7522
- get onMount() {
7523
- // A constant expression (e.g. :label="'Apple'") has no reactive
7524
- // identifiers, so the identifier-driven update cycle never applies it.
7525
- // Render it once at mount instead.
7526
- if (this.#evaluator && this.#evaluator.dependentIdentifiers.length === 0) {
7527
- return () => {
7528
- this.#render();
7529
- };
7530
- }
7531
- return undefined;
7532
- }
7533
- /**
7534
- * @inheritdoc
7535
- */
7536
- get onMounted() {
7537
- return undefined;
7538
- }
7539
- /**
7540
- * @inheritdoc
7541
- */
7542
- get onUpdate() {
7543
- return undefined;
7544
- }
7545
- /**
7546
- * @inheritdoc
7547
- */
7548
- get onUpdated() {
7549
- return undefined;
7550
- }
7551
- /**
7552
- * @inheritdoc
7553
- */
7554
- get onUnmount() {
7555
- return undefined;
7556
- }
7557
- /**
7558
- * @inheritdoc
7559
- */
7560
- get onUnmounted() {
7561
- return undefined;
7263
+ else if (node.type === 'MemberExpression') {
7264
+ // Reconstruct chain parts from a MemberExpression (e.g. a.b.c). Each part
7265
+ // carries its own separator so that the chain is spelled the way the reactive
7266
+ // proxy reports changes: array indices in brackets, properties after a dot
7267
+ // (e.g. "items[0].name").
7268
+ const parts = [];
7269
+ let cur = node;
7270
+ while (cur) {
7271
+ // property part (right side)
7272
+ if (cur.property) {
7273
+ if (!cur.computed && cur.property.type === 'Identifier') {
7274
+ parts.unshift(`.${cur.property.name}`);
7275
+ }
7276
+ else if (cur.computed && cur.property.type === 'Literal') {
7277
+ // items[0] and items["0"] both name an index; labels['a-b'] names a key.
7278
+ const key = String(cur.property.value);
7279
+ parts.unshift(ReactiveProxy.isIndexKey(key) ? `[${key}]` : `.${key}`);
7280
+ }
7281
+ else if (cur.computed) {
7282
+ // e.g. items[i] or items[i + 1]: the index is only known when the
7283
+ // expression runs, so "[*]" stands for any index or key. Identifiers
7284
+ // inside the index expression are visited separately by walk.full.
7285
+ parts.unshift('[*]');
7286
+ }
7287
+ }
7288
+ // object part (left side)
7289
+ if (cur.object) {
7290
+ if (cur.object.type === 'Identifier') {
7291
+ parts.unshift(cur.object.name);
7292
+ break;
7293
+ }
7294
+ else if (cur.object.type === 'ThisExpression') {
7295
+ // For `this.x` or `this.a.b` we don't want to include 'this' itself.
7296
+ // Stop unwrapping here; we'll drop a leading 'this' later.
7297
+ break;
7298
+ }
7299
+ else if (cur.object.type === 'MemberExpression') {
7300
+ // continue unwrapping
7301
+ cur = cur.object;
7302
+ }
7303
+ else {
7304
+ // other object types (CallExpression, etc.) — stop here
7305
+ break;
7306
+ }
7307
+ }
7308
+ else {
7309
+ break;
7310
+ }
7311
+ }
7312
+ // A chain that does not start at a name (e.g. `this.a.b` or `f().b`) begins
7313
+ // with its first property; one that begins with an index names no binding.
7314
+ if (parts.length > 0 && parts[0].startsWith('.')) {
7315
+ parts[0] = parts[0].substring(1);
7316
+ }
7317
+ if (parts.length > 0 && !parts[0].startsWith('[')) {
7318
+ // Add progressive chains: 'a', 'a.b', 'a.b[0]', 'a.b[0].c'
7319
+ for (let i = 0; i < parts.length; i++) {
7320
+ const chain = parts.slice(0, i + 1).join('');
7321
+ identifiers.add(chain);
7322
+ // Also apply functionDependencies lookup for the chain key
7323
+ if (functionDependencies[chain]) {
7324
+ for (const dependency of functionDependencies[chain]) {
7325
+ identifiers.add(dependency);
7326
+ }
7327
+ }
7328
+ }
7329
+ }
7330
+ }
7331
+ });
7332
+ return Array.from(identifiers);
7562
7333
  }
7563
7334
  /**
7564
- * @inheritdoc
7335
+ * Returns the binding name an identifier chain starts with
7336
+ * (e.g. "items" for "items[0].name" or "user.name").
7337
+ * @param identifier An identifier or member chain returned by extractIdentifiers.
7338
+ * @returns The left-most name of the chain.
7565
7339
  */
7566
- destroy() {
7567
- // Do nothing. No special cleanup needed.
7340
+ static baseIdentifier(identifier) {
7341
+ const end = identifier.search(/[.[]/);
7342
+ return end === -1 ? identifier : identifier.substring(0, end);
7568
7343
  }
7569
7344
  /**
7570
- * Renders the bound attribute by evaluating the expression and updating the DOM element.
7345
+ * Analyzes the dependencies of functions in the provided dictionary.
7346
+ * @param functions A dictionary mapping function names to their implementations.
7347
+ * @returns A dictionary mapping function names to arrays of their dependencies.
7571
7348
  */
7572
- #render() {
7573
- // Do nothing for special attributes
7574
- if (this.isKey || this.isOptions) {
7575
- return;
7576
- }
7577
- const element = this.#vNode.node;
7578
- const attributeName = this.#attributeName;
7579
- // If there's no evaluator or attribute name, do nothing
7580
- if (!this.#evaluator || !attributeName) {
7581
- return;
7582
- }
7583
- // Evaluate the expression to get the value
7584
- const value = this.#evaluator.evaluate();
7585
- // Handle different attribute types
7586
- if (attributeName === 'class') {
7587
- this.#updateClass(element, value);
7588
- }
7589
- else if (attributeName === 'style') {
7590
- this.#updateStyle(element, value);
7349
+ static analyzeFunctionDependencies(functions) {
7350
+ const functionDependencies = {};
7351
+ // First pass: collect direct dependencies for each function
7352
+ for (const [funcName, func] of Object.entries(functions)) {
7353
+ try {
7354
+ let source = func.toString();
7355
+ // Handle different function formats
7356
+ // Arrow function: () => expr or (args) => { ... }
7357
+ // Method shorthand: methodName() { ... }
7358
+ // Function expression: function() { ... }
7359
+ // Detect function type more accurately
7360
+ const isArrowFunction = /^\s*(\([^)]*\)|[a-zA-Z_$][a-zA-Z0-9_$]*)\s*=>/.test(source);
7361
+ const isFunctionExpression = source.startsWith('function');
7362
+ const isAsyncFunction = source.startsWith('async');
7363
+ // If it's a method shorthand (e.g., "methodName() { ... }" or "async methodName() { ... }"), convert to function expression
7364
+ if (!isFunctionExpression && !isArrowFunction) {
7365
+ // It's likely a method shorthand, convert to function expression
7366
+ if (isAsyncFunction) {
7367
+ // Remove 'async' prefix and add 'async function' prefix
7368
+ source = `async function ${source.substring(5).trim()}`;
7369
+ }
7370
+ else {
7371
+ source = `function ${source}`;
7372
+ }
7373
+ }
7374
+ // Wrap in parentheses for parsing
7375
+ source = `(${source})`;
7376
+ const ast = parse(source, { ecmaVersion: "latest" });
7377
+ const dependencies = new Set();
7378
+ const declaredVariables = new Set();
7379
+ const thisAliases = new Set();
7380
+ // First, collect all declared variables (const, let, var, function params, etc.)
7381
+ ancestor(ast, {
7382
+ VariableDeclarator(node) {
7383
+ if (node.id.type === 'Identifier') {
7384
+ declaredVariables.add(node.id.name);
7385
+ // Detect aliases assigned from `this`, e.g. `let vm = this;`
7386
+ if (node.init && node.init.type === 'ThisExpression') {
7387
+ thisAliases.add(node.id.name);
7388
+ }
7389
+ }
7390
+ },
7391
+ FunctionDeclaration(node) {
7392
+ if (node.id && node.id.type === 'Identifier') {
7393
+ declaredVariables.add(node.id.name);
7394
+ }
7395
+ // Add function parameters
7396
+ node.params.forEach((param) => {
7397
+ if (param.type === 'Identifier') {
7398
+ declaredVariables.add(param.name);
7399
+ }
7400
+ });
7401
+ },
7402
+ FunctionExpression(node, ancestors) {
7403
+ // Only process the outermost function (skip nested functions)
7404
+ const functionAncestors = ancestors.filter((n) => n.type === 'FunctionExpression' ||
7405
+ n.type === 'FunctionDeclaration' ||
7406
+ n.type === 'ArrowFunctionExpression');
7407
+ if (functionAncestors.length === 1) {
7408
+ // Add function parameters of the main function
7409
+ node.params.forEach((param) => {
7410
+ if (param.type === 'Identifier') {
7411
+ declaredVariables.add(param.name);
7412
+ }
7413
+ });
7414
+ }
7415
+ },
7416
+ ArrowFunctionExpression(node, ancestors) {
7417
+ // Only process the outermost function (skip nested functions)
7418
+ const functionAncestors = ancestors.filter((n) => n.type === 'FunctionExpression' ||
7419
+ n.type === 'FunctionDeclaration' ||
7420
+ n.type === 'ArrowFunctionExpression');
7421
+ if (functionAncestors.length === 1) {
7422
+ // Add function parameters of the main function
7423
+ node.params.forEach((param) => {
7424
+ if (param.type === 'Identifier') {
7425
+ declaredVariables.add(param.name);
7426
+ }
7427
+ });
7428
+ }
7429
+ }
7430
+ });
7431
+ // Then, collect identifiers that are not declared within the function
7432
+ simple(ast, {
7433
+ Identifier(node) {
7434
+ // Skip the function name itself, declared variables, and common globals
7435
+ if (node.name !== funcName && !declaredVariables.has(node.name)) {
7436
+ dependencies.add(node.name);
7437
+ }
7438
+ },
7439
+ MemberExpression(node) {
7440
+ // Handle 'this.propertyName' patterns and aliases like 'vm.x' when 'vm = this'
7441
+ if (node.property.type === 'Identifier') {
7442
+ if (node.object.type === 'ThisExpression') {
7443
+ dependencies.add(node.property.name);
7444
+ }
7445
+ else if (node.object.type === 'Identifier' && thisAliases.has(node.object.name)) {
7446
+ dependencies.add(node.property.name);
7447
+ }
7448
+ }
7449
+ }
7450
+ });
7451
+ functionDependencies[funcName] = Array.from(dependencies);
7452
+ }
7453
+ catch (error) {
7454
+ console.warn(`Failed to analyze function '${funcName}':`, error);
7455
+ // If parsing fails, assume no dependencies
7456
+ functionDependencies[funcName] = [];
7457
+ }
7591
7458
  }
7592
- else if (this.#isCustomElementProperty(element, attributeName, value)) {
7593
- // Custom elements: set objects/arrays (and declared props) as properties
7594
- // so complex values are not serialized to strings via setAttribute.
7595
- // HTML attributes are lowercased by the browser, so resolve to the
7596
- // actual camelCase property name declared on the custom element.
7597
- const propName = this.#resolveCustomElementPropertyName(element, attributeName);
7598
- element[propName] = value;
7459
+ // Second pass: resolve each function's transitive reactive dependencies.
7460
+ //
7461
+ // A function's direct dependency list mixes two kinds of identifiers: references to other
7462
+ // functions (call edges) and references to reactive data properties (the values we actually
7463
+ // care about). We want, for every function, the full set of reactive properties reachable by
7464
+ // following call edges to any depth.
7465
+ //
7466
+ // Functions are free to reference each other cyclically — a poller that reschedules itself
7467
+ // (`scheduleOperationPoll` -> `pollOperation` -> `scheduleOperationPoll`), a pair of mutually
7468
+ // recursive helpers, or any state machine. This is valid JavaScript, so the resolver must not
7469
+ // treat it as an error. Every function in a cycle (more precisely, a strongly connected
7470
+ // component) is reachable from the others, so they all share the same transitive dependency
7471
+ // set: the union of every member's direct reactive dependencies.
7472
+ //
7473
+ // A depth-first walk that bails out on back-edges cannot compute that correctly — it drops the
7474
+ // dependencies contributed around the cycle and caches incomplete, traversal-order-dependent
7475
+ // results, which would silently break reactivity for any computed that reads reactive data
7476
+ // through a cyclic method. We instead split each function's direct dependencies into reactive
7477
+ // properties and call edges, then propagate reactive properties along call edges until the sets
7478
+ // stop growing (a fixpoint). This converges regardless of cycles and yields the exact transitive
7479
+ // closure for every function, including those participating in a cycle.
7480
+ const directReactive = {};
7481
+ const callEdges = {};
7482
+ for (const funcName of Object.keys(functions)) {
7483
+ const reactive = new Set();
7484
+ const edges = [];
7485
+ for (const dep of functionDependencies[funcName] || []) {
7486
+ if (dep === funcName)
7487
+ continue; // Skip self-references.
7488
+ if (functions[dep]) {
7489
+ edges.push(dep); // Call edge to another function.
7490
+ }
7491
+ else {
7492
+ reactive.add(dep); // Reactive data property (terminal dependency).
7493
+ }
7494
+ }
7495
+ directReactive[funcName] = reactive;
7496
+ callEdges[funcName] = edges;
7599
7497
  }
7600
- else if (this.#isDOMProperty(attributeName)) {
7601
- this.#updateProperty(element, attributeName, value);
7498
+ // Seed each function with its own direct reactive dependencies, then propagate along call edges
7499
+ // until no set changes. Each pass can only add properties, and the universe of properties is
7500
+ // finite, so the loop is guaranteed to terminate.
7501
+ const resolved = {};
7502
+ for (const funcName of Object.keys(functions)) {
7503
+ resolved[funcName] = new Set(directReactive[funcName]);
7602
7504
  }
7603
- else if (this.#isBooleanAttribute(attributeName)) {
7604
- this.#updateBooleanAttribute(element, attributeName, value);
7505
+ let changed = true;
7506
+ while (changed) {
7507
+ changed = false;
7508
+ for (const funcName of Object.keys(functions)) {
7509
+ const target = resolved[funcName];
7510
+ for (const edge of callEdges[funcName]) {
7511
+ for (const dep of resolved[edge]) {
7512
+ if (!target.has(dep)) {
7513
+ target.add(dep);
7514
+ changed = true;
7515
+ }
7516
+ }
7517
+ }
7518
+ }
7605
7519
  }
7606
- else {
7607
- this.#updateAttribute(element, attributeName, value);
7520
+ const resolvedDependencies = {};
7521
+ for (const funcName of Object.keys(functions)) {
7522
+ resolvedDependencies[funcName] = Array.from(resolved[funcName]);
7608
7523
  }
7524
+ return resolvedDependencies;
7609
7525
  }
7610
7526
  /**
7611
- * Updates the class attribute with support for string, array, and object formats.
7527
+ * Rewrites an expression to replace identifiers with 'this.identifier'.
7528
+ * This allows direct property access and assignment without using 'with' statement.
7529
+ * Uses AST parsing to accurately identify which identifiers to replace.
7530
+ * @param expression The original expression string.
7531
+ * @param identifiers The list of identifiers that are available in bindings.
7532
+ * @returns The rewritten expression.
7612
7533
  */
7613
- #updateClass(element, value) {
7614
- // Determine the new set of classes to apply
7615
- let newClasses = [];
7616
- if (typeof value === 'string') {
7617
- newClasses = value.split(/\s+/).filter(Boolean);
7534
+ static rewriteExpression(expression, identifiers, options) {
7535
+ // Reserved words and built-in objects that should not be prefixed with 'this.'
7536
+ const reserved = new Set([
7537
+ 'event', '$event', '$ctx', '$newValue',
7538
+ 'true', 'false', 'null', 'undefined', 'NaN', 'Infinity',
7539
+ 'Math', 'Date', 'String', 'Number', 'Boolean', 'Array', 'Object',
7540
+ 'JSON', 'console', 'window', 'document', 'navigator',
7541
+ 'parseInt', 'parseFloat', 'isNaN', 'isFinite',
7542
+ 'encodeURI', 'decodeURI', 'encodeURIComponent', 'decodeURIComponent'
7543
+ ]);
7544
+ // Extract ALL identifiers from the expression (including assignment left-hand side)
7545
+ // This is necessary because the passed 'identifiers' parameter only includes
7546
+ // identifiers that are used (right-hand side), not assigned to (left-hand side)
7547
+ let allIdentifiersInExpression;
7548
+ try {
7549
+ allIdentifiersInExpression = ExpressionUtils.extractIdentifiers(expression, {}, options);
7618
7550
  }
7619
- else if (Array.isArray(value)) {
7620
- // Flatten array elements that may contain strings or objects
7621
- newClasses = value
7622
- .filter(Boolean)
7623
- .flatMap(cls => {
7624
- if (typeof cls === 'string') {
7625
- return cls.split(/\s+/).filter(Boolean);
7626
- }
7627
- else if (typeof cls === 'object' && cls !== null) {
7628
- // Handle object format within array: { className: condition }
7629
- // Keys may contain space-separated class names
7630
- return Object.keys(cls).filter(key => cls[key]).flatMap(key => key.split(/\s+/).filter(Boolean));
7631
- }
7632
- return [];
7633
- });
7551
+ catch (error) {
7552
+ console.warn('[ichigo.js] Failed to extract identifiers from expression:', expression, error);
7553
+ return expression;
7634
7554
  }
7635
- else if (typeof value === 'object' && value !== null) {
7636
- // Keys may contain space-separated class names
7637
- newClasses = Object.keys(value).filter(key => value[key]).flatMap(key => key.split(/\s+/).filter(Boolean));
7555
+ // Create a Set of identifiers available in bindings (from data, computed, methods)
7556
+ // We need to know which identifiers are valid binding properties
7557
+ const bindingIdentifiers = new Set(identifiers.filter(id => !reserved.has(id)));
7558
+ // For assignment expressions, we also need to include the left-hand side identifier
7559
+ // even if it's not in the tracking identifiers (because it's being assigned, not read)
7560
+ for (const id of allIdentifiersInExpression) {
7561
+ if (!reserved.has(id)) {
7562
+ bindingIdentifiers.add(id);
7563
+ }
7638
7564
  }
7639
- // Remove previously managed classes
7640
- this.#managedClasses.forEach(cls => element.classList.remove(cls));
7641
- // Add newly managed classes
7642
- newClasses.forEach(cls => element.classList.add(cls));
7643
- // Update managed classes list
7644
- this.#managedClasses = new Set(newClasses);
7645
- }
7646
- /**
7647
- * Updates the style attribute with support for object format.
7648
- */
7649
- #updateStyle(element, value) {
7650
- let newStyles = [];
7651
- if (typeof value === 'string') {
7652
- // Directly set the style string
7653
- element.style.cssText = value;
7654
- // Extract managed properties
7655
- newStyles = value.split(';').map(s => s.split(':')[0].trim()).filter(Boolean);
7565
+ if (bindingIdentifiers.size === 0) {
7566
+ return expression;
7656
7567
  }
7657
- else if (typeof value === 'object' && value !== null) {
7658
- // Remove all previously managed properties
7659
- this.#managedStyles.forEach(prop => {
7660
- element.style.removeProperty(this.#camelToKebab(prop));
7661
- });
7662
- // Add newly managed properties
7663
- for (const key in value) {
7664
- if (Object.prototype.hasOwnProperty.call(value, key)) {
7665
- const cssKey = this.#camelToKebab(key);
7666
- const cssValue = value[key];
7667
- if (cssValue != null) {
7668
- element.style.setProperty(cssKey, String(cssValue));
7669
- newStyles.push(key);
7568
+ try {
7569
+ // Build a map of positions to replace: { start: number, end: number, name: string, asThisAlias?: boolean }[]
7570
+ const replacements = [];
7571
+ // In script mode we must not wrap in parens (that would make multi-statement input invalid).
7572
+ // Offsets from the parser therefore refer directly to the original expression, so no shift.
7573
+ const asScript = options?.asScript === true;
7574
+ const source = asScript ? expression : `(${expression})`;
7575
+ const offsetShift = asScript ? 0 : 1;
7576
+ const parsedAst = parse(source, { ecmaVersion: 'latest' });
7577
+ // Track identifiers that are locally declared within the handler body (let/const/var, function
7578
+ // params) so we don't rewrite them to `this.xxx`. Additionally detect `this` aliases such as
7579
+ // `let vm = this;` so that `vm.x` can be rewritten to `this.x` even though `vm` is locally declared.
7580
+ const locallyDeclared = new Set();
7581
+ const thisAliases = new Set();
7582
+ if (asScript) {
7583
+ full(parsedAst, (node) => {
7584
+ if (node.type === 'VariableDeclarator' && node.id?.type === 'Identifier') {
7585
+ locallyDeclared.add(node.id.name);
7586
+ if (node.init && node.init.type === 'ThisExpression') {
7587
+ thisAliases.add(node.id.name);
7588
+ }
7589
+ }
7590
+ else if (node.type === 'FunctionDeclaration' && node.id?.type === 'Identifier') {
7591
+ locallyDeclared.add(node.id.name);
7592
+ }
7593
+ });
7594
+ }
7595
+ // Collect all identifier nodes that should be replaced
7596
+ // Use walk.fullAncestor to visit ALL nodes (including assignment LHS) while tracking ancestors
7597
+ fullAncestor(parsedAst, (node, _state, ancestors) => {
7598
+ if (node.type !== 'Identifier') {
7599
+ return;
7600
+ }
7601
+ // Skip if not in our identifier set
7602
+ if (!bindingIdentifiers.has(node.name)) {
7603
+ return;
7604
+ }
7605
+ // Locally declared identifiers must not be rewritten to `this.xxx`. The one
7606
+ // exception is a `this`-alias (e.g. `let vm = this;`): when such an alias is
7607
+ // used as the OBJECT of a MemberExpression (`vm.x`), we replace just the
7608
+ // object with `this` so that `vm.x` becomes `this.x`. In every other position
7609
+ // (the declaration LHS, a bare reference such as `console.log(vm)`, an
7610
+ // argument, etc.) the alias must be left untouched — rewriting it to
7611
+ // `this.vm` would either produce a syntax error (`let this.vm = this;`) or
7612
+ // change the meaning at runtime.
7613
+ // walk.fullAncestor includes the visited node itself as the last entry of
7614
+ // `ancestors`, so the actual parent node is at length - 2.
7615
+ const parentNode = ancestors.length >= 2 ? ancestors[ancestors.length - 2] : undefined;
7616
+ const isAliasAsMemberObject = parentNode?.type === 'MemberExpression' && parentNode.object === node;
7617
+ if (locallyDeclared.has(node.name)) {
7618
+ if (!thisAliases.has(node.name) || !isAliasAsMemberObject) {
7619
+ return;
7670
7620
  }
7621
+ replacements.push({
7622
+ start: node.start - offsetShift,
7623
+ end: node.end - offsetShift,
7624
+ name: node.name,
7625
+ asThisAlias: true
7626
+ });
7627
+ return;
7628
+ }
7629
+ // Skip identifiers that are the property (not the object) of a non-computed
7630
+ // member access (e.g., the `prop` in `obj.prop`).
7631
+ if (parentNode?.type === 'MemberExpression' && !parentNode.computed && parentNode.property === node) {
7632
+ return;
7671
7633
  }
7634
+ // Add to replacements list (adjust for the wrapping parentheses in expression mode)
7635
+ replacements.push({
7636
+ start: node.start - offsetShift,
7637
+ end: node.end - offsetShift,
7638
+ name: node.name
7639
+ });
7640
+ });
7641
+ // Sort replacements by start position (descending) to replace from end to start
7642
+ replacements.sort((a, b) => b.start - a.start);
7643
+ // Apply replacements
7644
+ let result = expression;
7645
+ for (const replacement of replacements) {
7646
+ const insertion = replacement.asThisAlias ? 'this' : `this.${replacement.name}`;
7647
+ result = result.substring(0, replacement.start) + insertion + result.substring(replacement.end);
7672
7648
  }
7649
+ return result;
7650
+ }
7651
+ catch (error) {
7652
+ // If AST parsing fails, fall back to the original expression
7653
+ console.warn('Failed to rewrite expression:', expression, error);
7654
+ return expression;
7673
7655
  }
7674
- // Update managed properties list
7675
- this.#managedStyles = new Set(newStyles);
7676
7656
  }
7657
+ }
7658
+
7659
+ // Copyright (c) 2025 MintJams Inc. Licensed under MIT License.
7660
+ /**
7661
+ * A centralized expression evaluator that normalizes expression evaluation across all directives.
7662
+ *
7663
+ * This class provides:
7664
+ * - Consistent identifier extraction and binding resolution
7665
+ * - Unified global object whitelisting (Math, Date, JSON, etc.)
7666
+ * - Expression caching for performance
7667
+ * - Centralized error handling
7668
+ * - Support for custom expression rewriting (for VOnDirective)
7669
+ *
7670
+ * Example usage:
7671
+ * ```typescript
7672
+ * const evaluator = ExpressionEvaluator.create(
7673
+ * "count + 1",
7674
+ * bindings,
7675
+ * functionDependencies
7676
+ * );
7677
+ * const result = evaluator.evaluate();
7678
+ * ```
7679
+ */
7680
+ class ExpressionEvaluator {
7681
+ /**
7682
+ * Cache for compiled expression functions.
7683
+ * Key: expression string
7684
+ * Value: compiled function
7685
+ */
7686
+ static cache = new Map();
7687
+ /**
7688
+ * Global objects that are whitelisted for use in expressions.
7689
+ * These objects are available to all expressions for convenience and compatibility.
7690
+ */
7691
+ static GLOBAL_WHITELIST = {
7692
+ Math,
7693
+ Date,
7694
+ JSON,
7695
+ Array,
7696
+ Object,
7697
+ String,
7698
+ Number,
7699
+ Boolean,
7700
+ parseInt,
7701
+ parseFloat,
7702
+ isNaN,
7703
+ isFinite,
7704
+ encodeURI,
7705
+ decodeURI,
7706
+ encodeURIComponent,
7707
+ decodeURIComponent
7708
+ };
7677
7709
  /**
7678
- * Converts camelCase to kebab-case for CSS properties.
7710
+ * The original expression string.
7679
7711
  */
7680
- #camelToKebab(str) {
7681
- return str.replace(/([A-Z])/g, '-$1').toLowerCase();
7682
- }
7712
+ expression;
7683
7713
  /**
7684
- * Returns true when the target is a custom element and the binding should be
7685
- * delivered as a property rather than an HTML attribute.
7686
- *
7687
- * Three conditions trigger property delivery:
7688
- * 1. The value is an object or array — serialising these to a string attribute
7689
- * would lose type information.
7690
- * 2. The element exposes a matching property accessor (e.g. a prop declared via
7691
- * defineComponent), even when the value is a primitive.
7692
- * 3. The element declares a matching prop in its static _props (case-insensitive
7693
- * and kebab-case → camelCase tolerant), so primitive values reach the
7694
- * reactive binding via the generated setter instead of being lost as a
7695
- * plain HTML attribute that no attributeChangedCallback observes.
7714
+ * The identifiers extracted from the expression.
7696
7715
  */
7697
- #isCustomElementProperty(element, name, value) {
7698
- if (!element.tagName.includes('-')) {
7699
- return false;
7700
- }
7701
- const isObjectOrArray = Array.isArray(value) || (typeof value === 'object' && value !== null);
7702
- if (isObjectOrArray || name in element) {
7703
- return true;
7704
- }
7705
- return this.#findDeclaredProp(element, name) !== undefined;
7706
- }
7716
+ identifiers;
7717
+ // The original extracted identifiers (may include member chains like 'a.b')
7718
+ originalIdentifiers;
7707
7719
  /**
7708
- * Resolves the actual property name on a custom element for a given
7709
- * (lowercased) HTML attribute name. HTML attributes are always lowercase,
7710
- * but custom element props are typically camelCase. This method checks the
7711
- * element's declared _props (set by defineComponent) for a case-insensitive
7712
- * match against both the raw attribute name and its kebab-case → camelCase
7713
- * conversion (so `user-name` resolves to `userName`), falling back to the
7714
- * original name when no match is found.
7720
+ * The bindings to use for evaluating the expression.
7715
7721
  */
7716
- #resolveCustomElementPropertyName(element, name) {
7717
- // Fast path: exact match already exists
7718
- if (name in element) {
7719
- return name;
7720
- }
7721
- return this.#findDeclaredProp(element, name) ?? name;
7722
- }
7722
+ bindings;
7723
7723
  /**
7724
- * Looks up a declared prop on a custom element constructor whose name matches
7725
- * the given attribute name, treating the comparison as case-insensitive and
7726
- * tolerant of kebab-case → camelCase conversion. Returns the canonical prop
7727
- * name (as declared) when a match is found, or undefined otherwise.
7724
+ * The compiled function for evaluating the expression.
7728
7725
  */
7729
- #findDeclaredProp(element, name) {
7730
- const props = element.constructor._props;
7731
- if (!Array.isArray(props)) {
7732
- return undefined;
7733
- }
7734
- const lowerName = name.toLowerCase();
7735
- const camelLower = this.#kebabToCamel(lowerName).toLowerCase();
7736
- return props.find(p => {
7737
- const lowerProp = p.toLowerCase();
7738
- return lowerProp === lowerName || lowerProp === camelLower;
7739
- });
7740
- }
7726
+ evaluatorFunc;
7741
7727
  /**
7742
- * Converts kebab-case to camelCase (e.g. `user-name` → `userName`).
7728
+ * Additional context variables (e.g., 'event', '$ctx').
7743
7729
  */
7744
- #kebabToCamel(str) {
7745
- return str.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase());
7746
- }
7730
+ additionalContext;
7747
7731
  /**
7748
- * Checks if the attribute should be set as a DOM property.
7732
+ * Whether any identifier has an index known only at run time ("items[*].name").
7733
+ * Only then are the paths read during evaluation worth recording.
7749
7734
  */
7750
- #isDOMProperty(name) {
7751
- const domProperties = ['value', 'checked', 'selected', 'innerHTML', 'textContent'];
7752
- return domProperties.includes(name);
7753
- }
7735
+ hasDynamicIndex;
7754
7736
  /**
7755
- * Checks if the attribute is a boolean attribute.
7737
+ * The source paths read through reactive proxies during the last evaluation,
7738
+ * or undefined before the first evaluation and when nothing is recorded.
7756
7739
  */
7757
- #isBooleanAttribute(name) {
7758
- const booleanAttributes = [
7759
- 'disabled', 'readonly', 'required', 'checked', 'selected',
7760
- 'multiple', 'autofocus', 'autoplay', 'controls', 'loop',
7761
- 'muted', 'open', 'hidden'
7762
- ];
7763
- return booleanAttributes.includes(name);
7740
+ trackedPathSet;
7741
+ /**
7742
+ * Private constructor. Use ExpressionEvaluator.create() to create instances.
7743
+ */
7744
+ constructor(expression, bindings, identifiers, originalIdentifiers, evaluatorFunc, additionalContext) {
7745
+ this.expression = expression;
7746
+ this.bindings = bindings;
7747
+ this.identifiers = identifiers;
7748
+ this.originalIdentifiers = originalIdentifiers;
7749
+ this.evaluatorFunc = evaluatorFunc;
7750
+ this.additionalContext = additionalContext;
7751
+ this.hasDynamicIndex = originalIdentifiers.some(id => id.includes('[*]'));
7764
7752
  }
7765
7753
  /**
7766
- * Updates a DOM property.
7767
- * For value-like properties (value, textContent, innerHTML), null/undefined is converted to empty string
7768
- * to match Vue.js behavior and prevent "undefined" from being displayed.
7754
+ * Creates an ExpressionEvaluator instance.
7755
+ *
7756
+ * @param expression The expression string to evaluate.
7757
+ * @param bindings The bindings to use for evaluating the expression.
7758
+ * @param functionDependencies A dictionary mapping function names to their dependencies.
7759
+ * @param options Optional configuration for the evaluator.
7760
+ * @returns An ExpressionEvaluator instance.
7769
7761
  */
7770
- #updateProperty(element, name, value) {
7771
- if (value == null && (name === 'value' || name === 'textContent' || name === 'innerHTML')) {
7772
- element[name] = '';
7773
- if (name === 'value') {
7774
- element._value = null;
7762
+ static create(expression, bindings, functionDependencies, options) {
7763
+ // Extract identifiers from the expression (may include member chains like 'a.b')
7764
+ const extractedIdentifiers = ExpressionUtils.extractIdentifiers(expression, functionDependencies);
7765
+ // For compilation we must only pass simple identifier names (no dots).
7766
+ // Use the base (left-most) segment of member chains and preserve order.
7767
+ const baseIdentifiers = [];
7768
+ for (const id of extractedIdentifiers) {
7769
+ const base = ExpressionUtils.baseIdentifier(id);
7770
+ if (!baseIdentifiers.includes(base)) {
7771
+ baseIdentifiers.push(base);
7775
7772
  }
7776
7773
  }
7777
- else {
7778
- element[name] = value;
7779
- if (name === 'value') {
7780
- // Store the original typed value so that v-model on radio buttons
7781
- // can preserve the type (e.g., boolean false instead of string "false").
7782
- element._value = value;
7783
- }
7774
+ // Apply custom rewrite if provided (used by VOnDirective)
7775
+ let processedExpression = expression;
7776
+ if (options?.rewriteExpression) {
7777
+ // Pass the original extracted identifiers (may include member chains)
7778
+ processedExpression = options.rewriteExpression(expression, extractedIdentifiers);
7784
7779
  }
7780
+ // Build cache key including options that affect compilation
7781
+ const cacheKey = JSON.stringify({
7782
+ expr: processedExpression,
7783
+ additionalCtx: options?.additionalContext ? Object.keys(options.additionalContext).sort() : []
7784
+ });
7785
+ // Check cache
7786
+ let evaluatorFunc = this.cache.get(cacheKey);
7787
+ if (!evaluatorFunc) {
7788
+ // Compile the expression using base identifiers (no dots)
7789
+ evaluatorFunc = this.compileExpression(processedExpression, baseIdentifiers, options);
7790
+ // Cache the compiled function
7791
+ this.cache.set(cacheKey, evaluatorFunc);
7792
+ }
7793
+ return new ExpressionEvaluator(expression, bindings, baseIdentifiers, extractedIdentifiers, evaluatorFunc, options?.additionalContext);
7785
7794
  }
7786
7795
  /**
7787
- * Updates a boolean attribute.
7796
+ * Compiles an expression into a function.
7797
+ *
7798
+ * @param expression The expression string to compile.
7799
+ * @param identifiers The identifiers extracted from the expression.
7800
+ * @param options Optional configuration.
7801
+ * @returns A compiled function.
7788
7802
  */
7789
- #updateBooleanAttribute(element, name, value) {
7790
- if (value) {
7791
- element.setAttribute(name, '');
7803
+ static compileExpression(expression, identifiers, options) {
7804
+ // Build parameter list: globals first, then identifiers (base names), then additional context
7805
+ // Filter out identifiers that are already in the global whitelist to avoid duplicate parameters
7806
+ const globalKeys = new Set(Object.keys(ExpressionEvaluator.GLOBAL_WHITELIST));
7807
+ const filteredIdentifiers = identifiers.filter(id => !globalKeys.has(id));
7808
+ const params = [
7809
+ ...Object.keys(ExpressionEvaluator.GLOBAL_WHITELIST),
7810
+ ...filteredIdentifiers
7811
+ ];
7812
+ // Add additional context parameters if provided
7813
+ if (options?.additionalContext) {
7814
+ params.push(...Object.keys(options.additionalContext));
7792
7815
  }
7793
- else {
7794
- element.removeAttribute(name);
7816
+ const args = params.join(", ");
7817
+ const funcBody = `return (${expression});`;
7818
+ try {
7819
+ // Create the function with error handling
7820
+ return new Function(args, funcBody);
7821
+ }
7822
+ catch (error) {
7823
+ throw new Error(`Failed to compile expression: ${expression}\nError: ${error}`);
7795
7824
  }
7796
7825
  }
7797
7826
  /**
7798
- * Updates a regular attribute.
7799
- * An attribute that already holds the value is left alone: setting `src` on
7800
- * an <iframe>, even to the same URL, loads its page again.
7827
+ * Evaluates the expression with the current bindings.
7828
+ *
7829
+ * @returns The result of the expression evaluation.
7801
7830
  */
7802
- #updateAttribute(element, name, value) {
7803
- if (value == null || value === false) {
7804
- if (element.hasAttribute(name)) {
7805
- element.removeAttribute(name);
7831
+ evaluate() {
7832
+ try {
7833
+ // Build arguments: globals first, then identifiers, then additional context
7834
+ // Filter out identifiers that are already in the global whitelist to match compileExpression
7835
+ const globalKeys = new Set(Object.keys(ExpressionEvaluator.GLOBAL_WHITELIST));
7836
+ const filteredIdentifiers = this.identifiers.filter(id => !globalKeys.has(id));
7837
+ const values = [
7838
+ // Global whitelist values
7839
+ ...Object.values(ExpressionEvaluator.GLOBAL_WHITELIST),
7840
+ // Identifier values from bindings (excluding globals)
7841
+ ...filteredIdentifiers.map(id => this.bindings.get(id))
7842
+ ];
7843
+ // Add additional context values if provided
7844
+ if (this.additionalContext) {
7845
+ values.push(...Object.values(this.additionalContext));
7806
7846
  }
7807
- }
7808
- else {
7809
- const text = String(value);
7810
- if (element.getAttribute(name) !== text) {
7811
- element.setAttribute(name, text);
7847
+ // Call the compiled function with the gathered values. When an index is
7848
+ // known only at run time, record which concrete paths this evaluation
7849
+ // read so that change matching can be narrowed to them (see trackedPaths).
7850
+ if (!this.hasDynamicIndex) {
7851
+ return this.evaluatorFunc(...values);
7812
7852
  }
7853
+ const paths = new Set();
7854
+ this.trackedPathSet = undefined;
7855
+ const result = ReactiveProxy.track(paths, () => this.evaluatorFunc(...values));
7856
+ this.trackedPathSet = paths;
7857
+ return result;
7858
+ }
7859
+ catch (error) {
7860
+ console.error(`[ichigo.js] Error evaluating expression: ${this.expression}`, error);
7861
+ throw error;
7813
7862
  }
7814
7863
  }
7864
+ /**
7865
+ * The source paths read through reactive proxies during the last evaluation,
7866
+ * recorded only for an expression with a run-time index ("items[i].name").
7867
+ * Each evaluation replaces the previous set, so a path read for a former
7868
+ * index value is dropped as soon as the expression runs with the new one.
7869
+ * Undefined before the first evaluation and for expressions without such an index.
7870
+ */
7871
+ get trackedPaths() {
7872
+ return this.trackedPathSet;
7873
+ }
7874
+ /**
7875
+ * Evaluates the expression and returns the result as a boolean.
7876
+ *
7877
+ * @returns The boolean result of the expression evaluation.
7878
+ */
7879
+ evaluateAsBoolean() {
7880
+ return Boolean(this.evaluate());
7881
+ }
7882
+ /**
7883
+ * Gets the list of identifiers extracted from the expression.
7884
+ */
7885
+ get dependentIdentifiers() {
7886
+ // Return the original extracted identifiers (may include member chains like 'a.b')
7887
+ return this.originalIdentifiers;
7888
+ }
7889
+ /**
7890
+ * Clears the expression cache.
7891
+ * This should be called sparingly, as it will force recompilation of all expressions.
7892
+ */
7893
+ static clearCache() {
7894
+ this.cache.clear();
7895
+ }
7815
7896
  }
7816
7897
 
7817
7898
  // Copyright (c) 2025 MintJams Inc. Licensed under MIT License.
7818
7899
  /**
7819
- * Directive for rendering components.
7820
- * Usage: <div v-component="componentId" :options="props"></div>
7821
- *
7822
- * The :options binding is used to pass properties to the component.
7823
- * Example:
7824
- * <div v-component="my-component" :options="{message: 'Hello'}"></div>
7900
+ * Directive for binding dynamic attributes to DOM elements.
7901
+ * The `v-bind` directive allows you to bind HTML attributes to expressions in your data model.
7902
+ * 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.
7903
+ * Example usage:
7904
+ * <img v-bind:src="imageSrc" />
7905
+ * The value of `imageSrc` should be defined in the component's data object.
7906
+ * When the value of `imageSrc` changes, the `src` attribute of the `<img>` element will automatically update to reflect the new value.
7907
+ * This directive is particularly useful for dynamically updating attributes based on user interactions or other data changes in your application.
7908
+ * It helps keep the DOM in sync with the underlying data model, making it easier to build reactive user interfaces.
7909
+ * The `v-bind` directive can also be used with shorthand syntax using a colon (:).
7910
+ * For example, `:src="imageSrc"` is equivalent to `v-bind:src="imageSrc"`.
7911
+ * This shorthand syntax is often used for brevity and improved readability in templates.
7912
+ * 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.
7825
7913
  */
7826
- class VComponentDirective {
7914
+ class VBindDirective {
7827
7915
  /**
7828
7916
  * The virtual node to which this directive is applied.
7829
7917
  */
7830
7918
  #vNode;
7831
7919
  /**
7832
- * The component ID to render.
7920
+ * The expression evaluator for this directive.
7833
7921
  */
7834
- #componentId;
7922
+ #evaluator;
7835
7923
  /**
7836
- * The application instance for the component.
7924
+ * The name of the attribute to bind (e.g., "src", "class", "style").
7925
+ */
7926
+ #attributeName;
7927
+ /**
7928
+ * The original expression string from the directive.
7929
+ */
7930
+ #expression;
7931
+ /**
7932
+ * The set of class names managed by this directive (used when binding to the "class" attribute).
7933
+ * This helps in tracking which classes were added by this directive to avoid conflicts with other class manipulations.
7934
+ */
7935
+ #managedClasses = new Set();
7936
+ /**
7937
+ * The set of style properties managed by this directive (used when binding to the "style" attribute).
7938
+ * This helps in tracking which styles were added by this directive to avoid conflicts with other style manipulations.
7939
+ */
7940
+ #managedStyles = new Set();
7941
+ /**
7942
+ * @param context The context for parsing the directive.
7837
7943
  */
7838
- #componentApp;
7839
7944
  constructor(context) {
7840
7945
  this.#vNode = context.vNode;
7841
- this.#componentId = context.attribute.value.trim();
7946
+ // Extract the attribute name from the directive
7947
+ // e.g., "v-bind:src" -> "src", ":href" -> "href"
7948
+ const attrName = context.attribute.name;
7949
+ if (attrName.startsWith('v-bind:')) {
7950
+ this.#attributeName = attrName.substring(7);
7951
+ }
7952
+ else if (attrName.startsWith(':')) {
7953
+ this.#attributeName = attrName.substring(1);
7954
+ }
7955
+ // Parse the expression and create the evaluator
7956
+ this.#expression = context.attribute.value;
7957
+ if (this.#expression && context.vNode.bindings) {
7958
+ this.#evaluator = ExpressionEvaluator.create(this.#expression, context.vNode.bindings, context.vNode.vApplication.functionDependencies);
7959
+ }
7842
7960
  // Remove the directive attribute from the element
7843
7961
  this.#vNode.node.removeAttribute(context.attribute.name);
7844
7962
  }
@@ -7846,7 +7964,7 @@ class VComponentDirective {
7846
7964
  * @inheritdoc
7847
7965
  */
7848
7966
  get name() {
7849
- return StandardDirectiveName.V_COMPONENT;
7967
+ return StandardDirectiveName.V_BIND;
7850
7968
  }
7851
7969
  /**
7852
7970
  * @inheritdoc
@@ -7858,7 +7976,7 @@ class VComponentDirective {
7858
7976
  * @inheritdoc
7859
7977
  */
7860
7978
  get needsAnchor() {
7861
- return true;
7979
+ return false;
7862
7980
  }
7863
7981
  /**
7864
7982
  * @inheritdoc
@@ -7870,10 +7988,15 @@ class VComponentDirective {
7870
7988
  * @inheritdoc
7871
7989
  */
7872
7990
  get domUpdater() {
7873
- // Create and return the DOM updater
7991
+ const identifiers = this.#evaluator?.dependentIdentifiers ?? [];
7992
+ const evaluator = this.#evaluator;
7993
+ // Create an updater that handles the attribute binding
7874
7994
  const updater = {
7875
7995
  get dependentIdentifiers() {
7876
- return [];
7996
+ return identifiers;
7997
+ },
7998
+ get trackedPaths() {
7999
+ return evaluator?.trackedPaths;
7877
8000
  },
7878
8001
  applyToDOM: () => {
7879
8002
  this.#render();
@@ -7885,577 +8008,539 @@ class VComponentDirective {
7885
8008
  * @inheritdoc
7886
8009
  */
7887
8010
  get templatize() {
7888
- return true;
8011
+ return false;
7889
8012
  }
7890
8013
  /**
7891
8014
  * @inheritdoc
7892
8015
  */
7893
8016
  get dependentIdentifiers() {
7894
- return [];
8017
+ return this.#evaluator?.dependentIdentifiers ?? [];
7895
8018
  }
7896
8019
  /**
7897
- * @inheritdoc
8020
+ * Indicates if this directive is binding the "key" attribute.
8021
+ * The "key" attribute is special and is used for optimizing rendering of lists.
8022
+ * If this directive is binding the "key" attribute, it will be handled by the VForDirective.
7898
8023
  */
7899
- get onMount() {
7900
- return undefined;
8024
+ get isKey() {
8025
+ return (this.#attributeName === 'key');
7901
8026
  }
7902
8027
  /**
7903
- * @inheritdoc
8028
+ * Indicates if this directive is binding the "options" attribute or any of its sub-properties (e.g., "options.intersection").
8029
+ * The "options" attribute is special and is used for passing options to certain directives like VIntersectionDirective.
7904
8030
  */
7905
- get onMounted() {
7906
- return undefined;
8031
+ get isOptions() {
8032
+ return (this.#attributeName === 'options' || this.#attributeName?.startsWith('options.') === true);
7907
8033
  }
7908
8034
  /**
7909
- * @inheritdoc
8035
+ * Gets the name of the attribute being bound (e.g., "src", "class", "options", "options.intersection").
7910
8036
  */
7911
- get onUpdate() {
7912
- return undefined;
8037
+ get attributeName() {
8038
+ return this.#attributeName;
7913
8039
  }
7914
8040
  /**
7915
- * @inheritdoc
8041
+ * Gets the original expression string from the directive.
7916
8042
  */
7917
- get onUpdated() {
7918
- return undefined;
8043
+ get expression() {
8044
+ return this.#expression;
7919
8045
  }
7920
8046
  /**
7921
- * @inheritdoc
8047
+ * Evaluates the bound expression and returns the current value.
8048
+ * Returns undefined when no evaluator is available (e.g., empty expression).
7922
8049
  */
7923
- get onUnmount() {
7924
- return undefined;
8050
+ evaluate() {
8051
+ return this.#evaluator?.evaluate();
7925
8052
  }
7926
8053
  /**
7927
8054
  * @inheritdoc
7928
8055
  */
7929
- get onUnmounted() {
8056
+ get onMount() {
8057
+ // A constant expression (e.g. :label="'Apple'") has no reactive
8058
+ // identifiers, so the identifier-driven update cycle never applies it.
8059
+ // Render it once at mount instead.
8060
+ if (this.#evaluator && this.#evaluator.dependentIdentifiers.length === 0) {
8061
+ return () => {
8062
+ this.#render();
8063
+ };
8064
+ }
7930
8065
  return undefined;
7931
8066
  }
7932
8067
  /**
7933
8068
  * @inheritdoc
7934
8069
  */
7935
- destroy() {
7936
- if (!this.#componentApp) {
7937
- // Not rendered, no action needed
7938
- return;
7939
- }
7940
- // Get the component's root VNode
7941
- const componentVNode = this.#componentApp.rootVNode;
7942
- // Destroy component application first (calls @unmount hooks while DOM is still accessible)
7943
- this.#componentApp.unmount();
7944
- // Remove the component's root node from the DOM
7945
- if (componentVNode?.node.parentNode) {
7946
- componentVNode.node.parentNode.removeChild(componentVNode.node);
7947
- }
7948
- this.#componentApp = undefined;
8070
+ get onMounted() {
8071
+ return undefined;
7949
8072
  }
7950
8073
  /**
7951
- * Renders the component.
8074
+ * @inheritdoc
7952
8075
  */
7953
- #render() {
7954
- if (this.#componentApp) {
7955
- // Already rendered, no action needed
7956
- return;
7957
- }
7958
- // Clone the component's template and replace the original node
7959
- const clone = this.#cloneNode();
7960
- this.#vNode.anchorNode?.parentNode?.insertBefore(clone, this.#vNode.anchorNode.nextSibling);
7961
- // Get properties from :options or :options.component directive
7962
- let properties = {};
7963
- const optionsDirective = this.#vNode.directiveManager?.optionsDirective('component');
7964
- if (optionsDirective && optionsDirective.expression && this.#vNode.bindings) {
7965
- // Evaluate the options expression using ExpressionEvaluator
7966
- const evaluator = ExpressionEvaluator.create(optionsDirective.expression, this.#vNode.bindings, this.#vNode.vApplication.functionDependencies);
7967
- const result = evaluator.evaluate();
7968
- if (typeof result === 'object' && result !== null) {
7969
- properties = result;
7970
- }
7971
- }
7972
- // Get component definition from the application's component registry
7973
- const component = this.#vNode.vApplication.componentRegistry.get(this.#componentId);
7974
- if (!component) {
7975
- throw new Error(`Component '${this.#componentId}' not found in registry`);
7976
- }
7977
- // Create component instance
7978
- const instance = component.createInstance(properties);
7979
- // Create and mount child application using the parent application's registries
7980
- this.#componentApp = this.#vNode.vApplication.createChildApp(instance);
7981
- this.#componentApp.mount(clone);
8076
+ get onUpdate() {
8077
+ return undefined;
7982
8078
  }
7983
8079
  /**
7984
- * Clones the component's template and returns the root element.
7985
- * @returns The cloned root HTMLElement of the component.
7986
- * @throws Error if the component or its template is not found.
8080
+ * @inheritdoc
7987
8081
  */
7988
- #cloneNode() {
7989
- // Get component definition from the application's component registry
7990
- const component = this.#vNode.vApplication.componentRegistry.get(this.#componentId);
7991
- if (!component) {
7992
- throw new Error(`Component '${this.#componentId}' not found in registry`);
7993
- }
7994
- // Get template element
7995
- const finalTemplateID = component.templateID || component.id;
7996
- const templateElement = document.querySelector(`#${finalTemplateID}`);
7997
- if (!templateElement || !(templateElement instanceof HTMLTemplateElement)) {
7998
- throw new Error(`Template element '#${finalTemplateID}' not found`);
7999
- }
8000
- // Clone template content
8001
- const fragment = templateElement.content.cloneNode(true);
8002
- const childNodes = Array.from(fragment.childNodes);
8003
- // Find the first element node
8004
- for (const node of childNodes) {
8005
- if (node.nodeType === Node.ELEMENT_NODE) {
8006
- return node;
8007
- }
8008
- }
8009
- throw new Error(`No element found in template '#${finalTemplateID}'`);
8082
+ get onUpdated() {
8083
+ return undefined;
8010
8084
  }
8011
- }
8012
-
8013
- // Copyright (c) 2025 MintJams Inc. Licensed under MIT License.
8014
- /**
8015
- * Utility class for creating reactive proxies that automatically track changes.
8016
- */
8017
- class ReactiveProxy {
8018
- /**
8019
- * A WeakMap to store the proxy for each target object and path combination.
8020
- * This prevents creating multiple proxies for the same object accessed from different paths.
8021
- */
8022
- static proxyCache = new WeakMap();
8023
- /**
8024
- * A WeakMap to track which objects are proxies, mapping proxy -> original target.
8025
- * This prevents double-wrapping of already proxied objects.
8026
- */
8027
- static proxyToTarget = new WeakMap();
8028
- /**
8029
- * A WeakSet to track objects marked as "raw" (non-reactive).
8030
- * These objects will not be wrapped with Proxy.
8031
- */
8032
- static rawObjects = new WeakSet();
8033
- /**
8034
- * A WeakMap to store the path for each proxy object.
8035
- * This allows retrieving the source path of an object for computed property mapping.
8036
- */
8037
- static proxyPaths = new WeakMap();
8038
- /**
8039
- * Dispatchers per (target, path). Every target reached while walking a proxy
8040
- * subtree registers an entry pointing at the same dispatcher as the outermost
8041
- * proxy of that subtree, so callers can look up the dispatcher from any
8042
- * intermediate proxy when subscribing.
8043
- */
8044
- static dispatchers = new WeakMap();
8045
- /**
8046
- * A Map to store path aliases.
8047
- * Key: alias path (e.g., "editingNestedStep.steps")
8048
- * Value: source path (e.g., "routes[0].steps[0].steps")
8049
- * This allows mapping variable names to their actual source paths for dependency tracking.
8050
- */
8051
- static pathAliases = new Map();
8052
8085
  /**
8053
- * Compiled patterns for identifiers that contain a "[*]" index, keyed by the
8054
- * identifier and whether descendants of the path also match.
8086
+ * @inheritdoc
8055
8087
  */
8056
- static pathPatterns = new Map();
8057
- /**
8058
- * The `Object.prototype.toString` tags of the *only* value types we deeply
8059
- * proxy. Everything else is returned untouched (raw).
8060
- *
8061
- * Rationale: a Proxy can only safely intercept plain data containers. Built-in
8062
- * and host objects — `Date`, `RegExp`, `File`, `Blob`, `FileList`, `Promise`,
8063
- * `WeakMap`/`WeakSet`, typed arrays, DOM nodes, etc. — carry private internal
8064
- * slots, and invoking their native methods with a Proxy as the `this`/receiver
8065
- * throws "Illegal invocation". They must therefore never be wrapped. This is an
8066
- * allow-list rather than a deny-list so that *every* such exotic type is
8067
- * excluded by default, not just the handful we happened to enumerate.
8068
- *
8069
- * Plain user-defined class instances report `[object Object]` (unless they set
8070
- * `Symbol.toStringTag`) and so remain reactive, preserving prior behaviour.
8071
- * `Map`/`Set` stay reactive because the proxy specially wraps their mutation
8072
- * methods (see the `get` trap); `WeakMap`/`WeakSet` are intentionally excluded
8073
- * since they cannot be wrapped that way and would break the same as `File`.
8074
- *
8075
- * Callers that want a plain object kept non-reactive can still opt out
8076
- * explicitly via {@link markRaw}.
8088
+ get onUnmount() {
8089
+ return undefined;
8090
+ }
8091
+ /**
8092
+ * @inheritdoc
8077
8093
  */
8078
- static REACTIVE_TYPE_TAGS = new Set([
8079
- '[object Object]',
8080
- '[object Array]',
8081
- '[object Arguments]',
8082
- '[object Map]',
8083
- '[object Set]',
8084
- ]);
8094
+ get onUnmounted() {
8095
+ return undefined;
8096
+ }
8085
8097
  /**
8086
- * Whether the given non-null object value is one we deeply proxy.
8087
- * See {@link REACTIVE_TYPE_TAGS} for the reasoning.
8098
+ * @inheritdoc
8088
8099
  */
8089
- static isReactivable(value) {
8090
- return this.REACTIVE_TYPE_TAGS.has(Object.prototype.toString.call(value));
8100
+ destroy() {
8101
+ // Do nothing. No special cleanup needed.
8091
8102
  }
8092
8103
  /**
8093
- * Creates a reactive proxy for the given object.
8094
- * The proxy will call the onChange callback whenever a property is modified.
8095
- *
8096
- * @param target The object to make reactive.
8097
- * @param onChange Callback function to call when the object changes. Receives the full path of the changed property.
8098
- * @param path The current path in the object tree (used internally for nested objects).
8099
- * @param inheritedDispatcher Internal: the dispatcher inherited from an enclosing create() call when wrapping a nested target. External callers must omit this.
8100
- * @returns A reactive proxy of the target object.
8104
+ * Renders the bound attribute by evaluating the expression and updating the DOM element.
8101
8105
  */
8102
- static create(target, onChange, path = '', inheritedDispatcher) {
8103
- // If the target is not an object or is null, return it as-is
8104
- if (typeof target !== 'object' || target === null) {
8105
- return target;
8106
+ #render() {
8107
+ // Do nothing for special attributes
8108
+ if (this.isKey || this.isOptions) {
8109
+ return;
8106
8110
  }
8107
- // Don't wrap objects marked as raw (non-reactive)
8108
- if (this.rawObjects.has(target)) {
8109
- return target;
8111
+ const element = this.#vNode.node;
8112
+ const attributeName = this.#attributeName;
8113
+ // If there's no evaluator or attribute name, do nothing
8114
+ if (!this.#evaluator || !attributeName) {
8115
+ return;
8110
8116
  }
8111
- // Only deeply proxy plain data containers; return every built-in/host
8112
- // object (Date, File, Blob, Promise, WeakMap, DOM nodes, …) as-is, since
8113
- // their native methods break when invoked through a Proxy receiver.
8114
- if (!this.isReactivable(target)) {
8115
- return target;
8117
+ // Evaluate the expression to get the value
8118
+ const value = this.#evaluator.evaluate();
8119
+ // Handle different attribute types
8120
+ if (attributeName === 'class') {
8121
+ this.#updateClass(element, value);
8116
8122
  }
8117
- // Check if the target is already a proxy - if so, return it as-is to prevent double-wrapping
8118
- if (this.proxyToTarget.has(target)) {
8119
- return target;
8123
+ else if (attributeName === 'style') {
8124
+ this.#updateStyle(element, value);
8120
8125
  }
8121
- // Resolve (and register) the dispatcher for this (target, path).
8122
- // Nested create() calls inherit the dispatcher from the enclosing call so
8123
- // that a single dispatcher fans out changes at any depth of the subtree.
8124
- const dispatcher = this.resolveDispatcher(target, path, inheritedDispatcher);
8125
- if (onChange) {
8126
- dispatcher.listeners.add(onChange);
8126
+ else if (this.#isCustomElementProperty(element, attributeName, value)) {
8127
+ // Custom elements: set objects/arrays (and declared props) as properties
8128
+ // so complex values are not serialized to strings via setAttribute.
8129
+ // HTML attributes are lowercased by the browser, so resolve to the
8130
+ // actual camelCase property name declared on the custom element.
8131
+ const propName = this.#resolveCustomElementPropertyName(element, attributeName);
8132
+ element[propName] = value;
8127
8133
  }
8128
- // Check if we already have a proxy for this target with this path
8129
- let pathMap = this.proxyCache.get(target);
8130
- if (pathMap) {
8131
- const existingProxy = pathMap.get(path);
8132
- if (existingProxy) {
8133
- return existingProxy;
8134
- }
8134
+ else if (this.#isDOMProperty(attributeName)) {
8135
+ this.#updateProperty(element, attributeName, value);
8136
+ }
8137
+ else if (this.#isBooleanAttribute(attributeName)) {
8138
+ this.#updateBooleanAttribute(element, attributeName, value);
8135
8139
  }
8136
8140
  else {
8137
- pathMap = new Map();
8138
- this.proxyCache.set(target, pathMap);
8141
+ this.#updateAttribute(element, attributeName, value);
8139
8142
  }
8140
- // Create the proxy with path captured in closure
8141
- const proxy = new Proxy(target, {
8142
- get(obj, key, receiver) {
8143
- const value = Reflect.get(obj, key);
8144
- // If the value is an object or array, make it reactive too
8145
- if (typeof value === 'object' && value !== null) {
8146
- // Don't wrap objects marked as raw (non-reactive)
8147
- if (ReactiveProxy.rawObjects.has(value)) {
8148
- return value;
8149
- }
8150
- // Only deeply proxy plain data containers; hand back built-in
8151
- // and host objects (Date, File, Blob, Promise, WeakMap, DOM
8152
- // nodes, …) untouched so their native methods keep working.
8153
- if (!ReactiveProxy.isReactivable(value)) {
8154
- return value;
8155
- }
8156
- // Build the nested path
8157
- const keyStr = String(key);
8158
- const nestedPath = path ? (Array.isArray(obj) ? `${path}[${keyStr}]` : `${path}.${keyStr}`) : keyStr;
8159
- return ReactiveProxy.create(value, undefined, nestedPath, dispatcher);
8143
+ }
8144
+ /**
8145
+ * Updates the class attribute with support for string, array, and object formats.
8146
+ */
8147
+ #updateClass(element, value) {
8148
+ // Determine the new set of classes to apply
8149
+ let newClasses = [];
8150
+ if (typeof value === 'string') {
8151
+ newClasses = value.split(/\s+/).filter(Boolean);
8152
+ }
8153
+ else if (Array.isArray(value)) {
8154
+ // Flatten array elements that may contain strings or objects
8155
+ newClasses = value
8156
+ .filter(Boolean)
8157
+ .flatMap(cls => {
8158
+ if (typeof cls === 'string') {
8159
+ return cls.split(/\s+/).filter(Boolean);
8160
8160
  }
8161
- // If the value is a function, we need to wrap it to ensure that any mutations it performs also trigger onChange
8162
- if (typeof value === 'function') {
8163
- // For arrays, we only want to wrap mutation methods, not read methods like 'slice', 'concat', etc.
8164
- if (Array.isArray(obj)) {
8165
- const arrayMutationMethods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'];
8166
- if (!arrayMutationMethods.includes(key)) {
8167
- return value;
8168
- }
8169
- return function (...args) {
8170
- const result = value.apply(this === receiver ? obj : this, args);
8171
- ReactiveProxy.dispatch(dispatcher, path || undefined);
8172
- return result;
8173
- };
8174
- }
8175
- // For Map, we only want to wrap mutation methods, not read methods like 'get' or 'has'
8176
- if (obj.constructor.name === 'Map') {
8177
- const mapMutationMethods = ['set', 'delete', 'clear'];
8178
- return function (...args) {
8179
- const result = value.apply(this === receiver ? obj : this, args);
8180
- if (mapMutationMethods.includes(key)) {
8181
- ReactiveProxy.dispatch(dispatcher, path || undefined);
8182
- }
8183
- return result;
8184
- };
8185
- }
8186
- // For Set, we only want to wrap mutation methods, not read methods like 'has'
8187
- if (obj.constructor.name === 'Set') {
8188
- const setMutationMethods = ['add', 'delete', 'clear'];
8189
- return function (...args) {
8190
- const result = value.apply(this === receiver ? obj : this, args);
8191
- if (setMutationMethods.includes(key)) {
8192
- ReactiveProxy.dispatch(dispatcher, path || undefined);
8193
- }
8194
- return result;
8195
- };
8196
- }
8161
+ else if (typeof cls === 'object' && cls !== null) {
8162
+ // Handle object format within array: { className: condition }
8163
+ // Keys may contain space-separated class names
8164
+ return Object.keys(cls).filter(key => cls[key]).flatMap(key => key.split(/\s+/).filter(Boolean));
8197
8165
  }
8198
- return value;
8199
- },
8200
- set(obj, key, value) {
8201
- const oldValue = Reflect.get(obj, key);
8202
- const result = Reflect.set(obj, key, value);
8203
- // Only trigger onChange if the value actually changed
8204
- if (oldValue !== value) {
8205
- const keyStr = String(key);
8206
- const fullPath = path ? (Array.isArray(obj) ? `${path}[${keyStr}]` : `${path}.${keyStr}`) : keyStr;
8207
- ReactiveProxy.dispatch(dispatcher, fullPath);
8166
+ return [];
8167
+ });
8168
+ }
8169
+ else if (typeof value === 'object' && value !== null) {
8170
+ // Keys may contain space-separated class names
8171
+ newClasses = Object.keys(value).filter(key => value[key]).flatMap(key => key.split(/\s+/).filter(Boolean));
8172
+ }
8173
+ // Remove previously managed classes
8174
+ this.#managedClasses.forEach(cls => element.classList.remove(cls));
8175
+ // Add newly managed classes
8176
+ newClasses.forEach(cls => element.classList.add(cls));
8177
+ // Update managed classes list
8178
+ this.#managedClasses = new Set(newClasses);
8179
+ }
8180
+ /**
8181
+ * Updates the style attribute with support for object format.
8182
+ */
8183
+ #updateStyle(element, value) {
8184
+ let newStyles = [];
8185
+ if (typeof value === 'string') {
8186
+ // Directly set the style string
8187
+ element.style.cssText = value;
8188
+ // Extract managed properties
8189
+ newStyles = value.split(';').map(s => s.split(':')[0].trim()).filter(Boolean);
8190
+ }
8191
+ else if (typeof value === 'object' && value !== null) {
8192
+ // Remove all previously managed properties
8193
+ this.#managedStyles.forEach(prop => {
8194
+ element.style.removeProperty(this.#camelToKebab(prop));
8195
+ });
8196
+ // Add newly managed properties
8197
+ for (const key in value) {
8198
+ if (Object.prototype.hasOwnProperty.call(value, key)) {
8199
+ const cssKey = this.#camelToKebab(key);
8200
+ const cssValue = value[key];
8201
+ if (cssValue != null) {
8202
+ element.style.setProperty(cssKey, String(cssValue));
8203
+ newStyles.push(key);
8204
+ }
8208
8205
  }
8209
- return result;
8210
- },
8211
- deleteProperty(obj, key) {
8212
- const result = Reflect.deleteProperty(obj, key);
8213
- const keyStr = String(key);
8214
- const fullPath = path ? (Array.isArray(obj) ? `${path}[${keyStr}]` : `${path}.${keyStr}`) : keyStr;
8215
- ReactiveProxy.dispatch(dispatcher, fullPath);
8216
- return result;
8217
8206
  }
8218
- });
8219
- // Cache the proxy for this path
8220
- pathMap.set(path, proxy);
8221
- // Track that this proxy wraps the target to prevent double-wrapping
8222
- this.proxyToTarget.set(proxy, target);
8223
- // Store the path for this proxy (for computed property mapping)
8224
- if (path) {
8225
- this.proxyPaths.set(proxy, path);
8226
8207
  }
8227
- return proxy;
8208
+ // Update managed properties list
8209
+ this.#managedStyles = new Set(newStyles);
8210
+ }
8211
+ /**
8212
+ * Converts camelCase to kebab-case for CSS properties.
8213
+ */
8214
+ #camelToKebab(str) {
8215
+ return str.replace(/([A-Z])/g, '-$1').toLowerCase();
8216
+ }
8217
+ /**
8218
+ * Returns true when the target is a custom element and the binding should be
8219
+ * delivered as a property rather than an HTML attribute.
8220
+ *
8221
+ * Three conditions trigger property delivery:
8222
+ * 1. The value is an object or array — serialising these to a string attribute
8223
+ * would lose type information.
8224
+ * 2. The element exposes a matching property accessor (e.g. a prop declared via
8225
+ * defineComponent), even when the value is a primitive.
8226
+ * 3. The element declares a matching prop in its static _props (case-insensitive
8227
+ * and kebab-case → camelCase tolerant), so primitive values reach the
8228
+ * reactive binding via the generated setter instead of being lost as a
8229
+ * plain HTML attribute that no attributeChangedCallback observes.
8230
+ */
8231
+ #isCustomElementProperty(element, name, value) {
8232
+ if (!element.tagName.includes('-')) {
8233
+ return false;
8234
+ }
8235
+ const isObjectOrArray = Array.isArray(value) || (typeof value === 'object' && value !== null);
8236
+ if (isObjectOrArray || name in element) {
8237
+ return true;
8238
+ }
8239
+ return this.#findDeclaredProp(element, name) !== undefined;
8240
+ }
8241
+ /**
8242
+ * Resolves the actual property name on a custom element for a given
8243
+ * (lowercased) HTML attribute name. HTML attributes are always lowercase,
8244
+ * but custom element props are typically camelCase. This method checks the
8245
+ * element's declared _props (set by defineComponent) for a case-insensitive
8246
+ * match against both the raw attribute name and its kebab-case → camelCase
8247
+ * conversion (so `user-name` resolves to `userName`), falling back to the
8248
+ * original name when no match is found.
8249
+ */
8250
+ #resolveCustomElementPropertyName(element, name) {
8251
+ // Fast path: exact match already exists
8252
+ if (name in element) {
8253
+ return name;
8254
+ }
8255
+ return this.#findDeclaredProp(element, name) ?? name;
8228
8256
  }
8229
8257
  /**
8230
- * Looks up the dispatcher associated with (target, path), or installs a new one.
8231
- * When called for a nested target during proxy walking, the enclosing dispatcher
8232
- * is reused so a single subtree fans out changes through one notification path.
8258
+ * Looks up a declared prop on a custom element constructor whose name matches
8259
+ * the given attribute name, treating the comparison as case-insensitive and
8260
+ * tolerant of kebab-case → camelCase conversion. Returns the canonical prop
8261
+ * name (as declared) when a match is found, or undefined otherwise.
8233
8262
  */
8234
- static resolveDispatcher(target, path, inheritedDispatcher) {
8235
- let pathMap = this.dispatchers.get(target);
8236
- if (!pathMap) {
8237
- pathMap = new Map();
8238
- this.dispatchers.set(target, pathMap);
8263
+ #findDeclaredProp(element, name) {
8264
+ const props = element.constructor._props;
8265
+ if (!Array.isArray(props)) {
8266
+ return undefined;
8239
8267
  }
8240
- const existing = pathMap.get(path);
8241
- if (existing) {
8242
- return existing;
8268
+ const lowerName = name.toLowerCase();
8269
+ const camelLower = this.#kebabToCamel(lowerName).toLowerCase();
8270
+ return props.find(p => {
8271
+ const lowerProp = p.toLowerCase();
8272
+ return lowerProp === lowerName || lowerProp === camelLower;
8273
+ });
8274
+ }
8275
+ /**
8276
+ * Converts kebab-case to camelCase (e.g. `user-name` → `userName`).
8277
+ */
8278
+ #kebabToCamel(str) {
8279
+ return str.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase());
8280
+ }
8281
+ /**
8282
+ * Checks if the attribute should be set as a DOM property.
8283
+ */
8284
+ #isDOMProperty(name) {
8285
+ const domProperties = ['value', 'checked', 'selected', 'innerHTML', 'textContent'];
8286
+ return domProperties.includes(name);
8287
+ }
8288
+ /**
8289
+ * Checks if the attribute is a boolean attribute.
8290
+ */
8291
+ #isBooleanAttribute(name) {
8292
+ const booleanAttributes = [
8293
+ 'disabled', 'readonly', 'required', 'checked', 'selected',
8294
+ 'multiple', 'autofocus', 'autoplay', 'controls', 'loop',
8295
+ 'muted', 'open', 'hidden'
8296
+ ];
8297
+ return booleanAttributes.includes(name);
8298
+ }
8299
+ /**
8300
+ * Updates a DOM property.
8301
+ * For value-like properties (value, textContent, innerHTML), null/undefined is converted to empty string
8302
+ * to match Vue.js behavior and prevent "undefined" from being displayed.
8303
+ */
8304
+ #updateProperty(element, name, value) {
8305
+ if (value == null && (name === 'value' || name === 'textContent' || name === 'innerHTML')) {
8306
+ element[name] = '';
8307
+ if (name === 'value') {
8308
+ element._value = null;
8309
+ }
8310
+ }
8311
+ else {
8312
+ element[name] = value;
8313
+ if (name === 'value') {
8314
+ // Store the original typed value so that v-model on radio buttons
8315
+ // can preserve the type (e.g., boolean false instead of string "false").
8316
+ element._value = value;
8317
+ }
8243
8318
  }
8244
- const dispatcher = inheritedDispatcher ?? { listeners: new Set() };
8245
- pathMap.set(path, dispatcher);
8246
- return dispatcher;
8247
8319
  }
8248
8320
  /**
8249
- * Invokes every listener attached to the dispatcher.
8250
- * Iterates a snapshot of the listener set so unsubscribing during dispatch is safe.
8321
+ * Updates a boolean attribute.
8251
8322
  */
8252
- static dispatch(dispatcher, changedPath) {
8253
- if (dispatcher.listeners.size === 0) {
8254
- return;
8323
+ #updateBooleanAttribute(element, name, value) {
8324
+ if (value) {
8325
+ element.setAttribute(name, '');
8255
8326
  }
8256
- const snapshot = Array.from(dispatcher.listeners);
8257
- for (const listener of snapshot) {
8258
- listener(changedPath);
8327
+ else {
8328
+ element.removeAttribute(name);
8259
8329
  }
8260
8330
  }
8261
8331
  /**
8262
- * Subscribes a listener to changes inside the subtree of an existing reactive proxy.
8263
- *
8264
- * The listener is scoped by the proxy's source path: only changes at or below that
8265
- * path are delivered, which lets a child component receive notifications when the
8266
- * nested contents of a prop change even though the prop reference itself is unchanged.
8267
- *
8268
- * @param proxyOrTarget A proxy returned from create(), or the underlying target object.
8269
- * @param listener Called with the full source path of every relevant change.
8270
- * @returns A function that removes the subscription.
8332
+ * Updates a regular attribute.
8333
+ * An attribute that already holds the value is left alone: setting `src` on
8334
+ * an <iframe>, even to the same URL, loads its page again.
8271
8335
  */
8272
- static subscribe(proxyOrTarget, listener) {
8273
- if (typeof proxyOrTarget !== 'object' || proxyOrTarget === null) {
8274
- return () => { };
8336
+ #updateAttribute(element, name, value) {
8337
+ if (value == null || value === false) {
8338
+ if (element.hasAttribute(name)) {
8339
+ element.removeAttribute(name);
8340
+ }
8275
8341
  }
8276
- const target = (this.proxyToTarget.get(proxyOrTarget) ?? proxyOrTarget);
8277
- const scopePath = this.proxyPaths.get(proxyOrTarget) ?? '';
8278
- const pathMap = this.dispatchers.get(target);
8279
- const dispatcher = pathMap?.get(scopePath);
8280
- if (!dispatcher) {
8281
- return () => { };
8342
+ else {
8343
+ const text = String(value);
8344
+ if (element.getAttribute(name) !== text) {
8345
+ element.setAttribute(name, text);
8346
+ }
8282
8347
  }
8283
- const wrapper = scopePath
8284
- ? (changedPath) => {
8285
- if (changedPath === scopePath ||
8286
- (typeof changedPath === 'string' && (changedPath.startsWith(scopePath + '.') ||
8287
- changedPath.startsWith(scopePath + '[')))) {
8288
- listener(changedPath);
8289
- }
8348
+ }
8349
+ }
8350
+
8351
+ // Copyright (c) 2025 MintJams Inc. Licensed under MIT License.
8352
+ /**
8353
+ * Directive for rendering components.
8354
+ * Usage: <div v-component="componentId" :options="props"></div>
8355
+ *
8356
+ * The :options binding is used to pass properties to the component.
8357
+ * Example:
8358
+ * <div v-component="my-component" :options="{message: 'Hello'}"></div>
8359
+ */
8360
+ class VComponentDirective {
8361
+ /**
8362
+ * The virtual node to which this directive is applied.
8363
+ */
8364
+ #vNode;
8365
+ /**
8366
+ * The component ID to render.
8367
+ */
8368
+ #componentId;
8369
+ /**
8370
+ * The application instance for the component.
8371
+ */
8372
+ #componentApp;
8373
+ constructor(context) {
8374
+ this.#vNode = context.vNode;
8375
+ this.#componentId = context.attribute.value.trim();
8376
+ // Remove the directive attribute from the element
8377
+ this.#vNode.node.removeAttribute(context.attribute.name);
8378
+ }
8379
+ /**
8380
+ * @inheritdoc
8381
+ */
8382
+ get name() {
8383
+ return StandardDirectiveName.V_COMPONENT;
8384
+ }
8385
+ /**
8386
+ * @inheritdoc
8387
+ */
8388
+ get vNode() {
8389
+ return this.#vNode;
8390
+ }
8391
+ /**
8392
+ * @inheritdoc
8393
+ */
8394
+ get needsAnchor() {
8395
+ return true;
8396
+ }
8397
+ /**
8398
+ * @inheritdoc
8399
+ */
8400
+ get bindingsPreparer() {
8401
+ return undefined;
8402
+ }
8403
+ /**
8404
+ * @inheritdoc
8405
+ */
8406
+ get domUpdater() {
8407
+ // Create and return the DOM updater
8408
+ const updater = {
8409
+ get dependentIdentifiers() {
8410
+ return [];
8411
+ },
8412
+ applyToDOM: () => {
8413
+ this.#render();
8290
8414
  }
8291
- : listener;
8292
- dispatcher.listeners.add(wrapper);
8293
- return () => {
8294
- dispatcher.listeners.delete(wrapper);
8295
8415
  };
8416
+ return updater;
8296
8417
  }
8297
8418
  /**
8298
- * Checks if the given object is a reactive proxy.
8299
- *
8300
- * @param obj The object to check.
8301
- * @returns True if the object is a reactive proxy, false otherwise.
8419
+ * @inheritdoc
8302
8420
  */
8303
- static isReactive(obj) {
8304
- return this.proxyCache.has(obj);
8421
+ get templatize() {
8422
+ return true;
8305
8423
  }
8306
8424
  /**
8307
- * Unwraps a reactive proxy to get the original object.
8308
- * If the object is not a proxy, returns it as-is.
8309
- *
8310
- * @param obj The object to unwrap.
8311
- * @returns The original object.
8425
+ * @inheritdoc
8312
8426
  */
8313
- static unwrap(obj) {
8314
- // This is a simplified implementation
8315
- // In a full implementation, we'd need to store a reverse mapping
8316
- return obj;
8427
+ get dependentIdentifiers() {
8428
+ return [];
8317
8429
  }
8318
8430
  /**
8319
- * Marks an object as "raw" (non-reactive).
8320
- * Objects marked as raw will not be wrapped with Proxy when accessed from reactive objects.
8321
- * This is useful for objects that should not be reactive, such as:
8322
- * - Objects with private fields (class instances with # fields)
8323
- * - Third-party library instances
8324
- * - Objects used only for method calls
8325
- *
8326
- * @param obj The object to mark as raw.
8327
- * @returns The same object (for chaining).
8431
+ * @inheritdoc
8328
8432
  */
8329
- static markRaw(obj) {
8330
- if (typeof obj === 'object' && obj !== null) {
8331
- this.rawObjects.add(obj);
8332
- }
8333
- return obj;
8433
+ get onMount() {
8434
+ return undefined;
8435
+ }
8436
+ /**
8437
+ * @inheritdoc
8438
+ */
8439
+ get onMounted() {
8440
+ return undefined;
8334
8441
  }
8335
8442
  /**
8336
- * Checks if an object is marked as raw (non-reactive).
8337
- *
8338
- * @param obj The object to check.
8339
- * @returns True if the object is marked as raw, false otherwise.
8443
+ * @inheritdoc
8340
8444
  */
8341
- static isRaw(obj) {
8342
- return typeof obj === 'object' && obj !== null && this.rawObjects.has(obj);
8445
+ get onUpdate() {
8446
+ return undefined;
8343
8447
  }
8344
8448
  /**
8345
- * Gets the source path for a proxy object.
8346
- * This is used to map computed property values back to their source paths.
8347
- * For example, if a computed property returns `model.elements[0]`,
8348
- * this method returns "model.elements[0]" for that object.
8349
- *
8350
- * @param obj The proxy object to get the path for.
8351
- * @returns The source path, or undefined if not found.
8449
+ * @inheritdoc
8352
8450
  */
8353
- static getPath(obj) {
8354
- if (typeof obj !== 'object' || obj === null) {
8355
- return undefined;
8356
- }
8357
- return this.proxyPaths.get(obj);
8451
+ get onUpdated() {
8452
+ return undefined;
8358
8453
  }
8359
8454
  /**
8360
- * Registers a path alias.
8361
- * This is used when a variable is assigned to reference an existing reactive object.
8362
- * For example, when `editingNestedStep = routes[0].steps[0]`, this creates an alias
8363
- * so that changes to "routes[0].steps[0].steps" also match "editingNestedStep.steps".
8364
- *
8365
- * @param aliasPath The alias path (e.g., "editingNestedStep")
8366
- * @param sourcePath The source path (e.g., "routes[0].steps[0]")
8455
+ * @inheritdoc
8367
8456
  */
8368
- static registerPathAlias(aliasPath, sourcePath) {
8369
- if (aliasPath && sourcePath && aliasPath !== sourcePath) {
8370
- this.pathAliases.set(aliasPath, sourcePath);
8371
- }
8457
+ get onUnmount() {
8458
+ return undefined;
8372
8459
  }
8373
8460
  /**
8374
- * Unregisters a path alias.
8375
- *
8376
- * @param aliasPath The alias path to remove
8461
+ * @inheritdoc
8377
8462
  */
8378
- static unregisterPathAlias(aliasPath) {
8379
- // Remove all aliases that start with the given path
8380
- for (const key of this.pathAliases.keys()) {
8381
- if (key === aliasPath || key.startsWith(aliasPath + '.') || key.startsWith(aliasPath + '[')) {
8382
- this.pathAliases.delete(key);
8383
- }
8384
- }
8463
+ get onUnmounted() {
8464
+ return undefined;
8385
8465
  }
8386
8466
  /**
8387
- * Resolves an alias path to its source path.
8388
- * Also handles nested paths (e.g., "editingNestedStep.steps" -> "routes[0].steps[0].steps").
8389
- *
8390
- * @param aliasPath The alias path to resolve
8391
- * @returns The source path, or undefined if no alias exists
8467
+ * @inheritdoc
8392
8468
  */
8393
- static resolvePathAlias(aliasPath) {
8394
- // Direct match
8395
- if (this.pathAliases.has(aliasPath)) {
8396
- return this.pathAliases.get(aliasPath);
8469
+ destroy() {
8470
+ if (!this.#componentApp) {
8471
+ // Not rendered, no action needed
8472
+ return;
8397
8473
  }
8398
- // Check if this is a nested path of an aliased variable
8399
- // e.g., "editingNestedStep.steps" when "editingNestedStep" is aliased
8400
- for (const [alias, source] of this.pathAliases.entries()) {
8401
- if (aliasPath.startsWith(alias + '.') || aliasPath.startsWith(alias + '[')) {
8402
- const suffix = aliasPath.substring(alias.length);
8403
- return source + suffix;
8404
- }
8474
+ // Get the component's root VNode
8475
+ const componentVNode = this.#componentApp.rootVNode;
8476
+ // Destroy component application first (calls @unmount hooks while DOM is still accessible)
8477
+ this.#componentApp.unmount();
8478
+ // Remove the component's root node from the DOM
8479
+ if (componentVNode?.node.parentNode) {
8480
+ componentVNode.node.parentNode.removeChild(componentVNode.node);
8405
8481
  }
8406
- return undefined;
8482
+ this.#componentApp = undefined;
8407
8483
  }
8408
8484
  /**
8409
- * Checks if a change path matches an identifier, considering path aliases.
8410
- * For example, if "editingNestedStep" is aliased to "routes[0].steps[0]",
8411
- * then a change to "routes[0].steps[0].steps" should match "editingNestedStep.steps".
8412
- *
8413
- * @param changePath The path that was changed (e.g., "routes[0].steps[0].steps")
8414
- * @param identifier The identifier to check (e.g., "editingNestedStep.steps")
8415
- * @returns True if the change path matches the identifier
8485
+ * Renders the component.
8416
8486
  */
8417
- static doesChangeMatchIdentifier(changePath, identifier) {
8418
- // Direct match
8419
- if (this.pathMatches(changePath, identifier)) {
8420
- return true;
8487
+ #render() {
8488
+ if (this.#componentApp) {
8489
+ // Already rendered, no action needed
8490
+ return;
8421
8491
  }
8422
- // Check if the change path is the resolved identifier or lies below it
8423
- // (handles cases like array item changes)
8424
- const resolvedIdentifier = this.resolvePathAlias(identifier);
8425
- if (resolvedIdentifier && this.pathMatches(changePath, resolvedIdentifier, true)) {
8426
- return true;
8492
+ // Clone the component's template and replace the original node
8493
+ const clone = this.#cloneNode();
8494
+ this.#vNode.anchorNode?.parentNode?.insertBefore(clone, this.#vNode.anchorNode.nextSibling);
8495
+ // Get properties from :options or :options.component directive
8496
+ let properties = {};
8497
+ const optionsDirective = this.#vNode.directiveManager?.optionsDirective('component');
8498
+ if (optionsDirective && optionsDirective.expression && this.#vNode.bindings) {
8499
+ // Evaluate the options expression using ExpressionEvaluator
8500
+ const evaluator = ExpressionEvaluator.create(optionsDirective.expression, this.#vNode.bindings, this.#vNode.vApplication.functionDependencies);
8501
+ const result = evaluator.evaluate();
8502
+ if (typeof result === 'object' && result !== null) {
8503
+ properties = result;
8504
+ }
8427
8505
  }
8428
- return false;
8506
+ // Get component definition from the application's component registry
8507
+ const component = this.#vNode.vApplication.componentRegistry.get(this.#componentId);
8508
+ if (!component) {
8509
+ throw new Error(`Component '${this.#componentId}' not found in registry`);
8510
+ }
8511
+ // Create component instance
8512
+ const instance = component.createInstance(properties);
8513
+ // Create and mount child application using the parent application's registries
8514
+ this.#componentApp = this.#vNode.vApplication.createChildApp(instance);
8515
+ this.#componentApp.mount(clone);
8429
8516
  }
8430
8517
  /**
8431
- * Checks whether a change path names the location an identifier refers to.
8432
- * A "[*]" index in the identifier (e.g. "items[*].name" for `items[i].name`)
8433
- * stands for an index known only when the expression runs and matches any
8434
- * one index or key.
8435
- *
8436
- * @param changePath The path that was changed (e.g., "items[2].name")
8437
- * @param identifier The identifier to check (e.g., "items[*].name")
8438
- * @param descendants Whether a change below the identifier also matches
8439
- * @returns True if the change path matches the identifier
8518
+ * Clones the component's template and returns the root element.
8519
+ * @returns The cloned root HTMLElement of the component.
8520
+ * @throws Error if the component or its template is not found.
8440
8521
  */
8441
- static pathMatches(changePath, identifier, descendants = false) {
8442
- if (changePath === identifier) {
8443
- return true;
8522
+ #cloneNode() {
8523
+ // Get component definition from the application's component registry
8524
+ const component = this.#vNode.vApplication.componentRegistry.get(this.#componentId);
8525
+ if (!component) {
8526
+ throw new Error(`Component '${this.#componentId}' not found in registry`);
8444
8527
  }
8445
- if (!identifier.includes('[*]')) {
8446
- return descendants && (changePath.startsWith(identifier + '.') || changePath.startsWith(identifier + '['));
8528
+ // Get template element
8529
+ const finalTemplateID = component.templateID || component.id;
8530
+ const templateElement = document.querySelector(`#${finalTemplateID}`);
8531
+ if (!templateElement || !(templateElement instanceof HTMLTemplateElement)) {
8532
+ throw new Error(`Template element '#${finalTemplateID}' not found`);
8447
8533
  }
8448
- const cacheKey = descendants ? identifier + ' ' : identifier;
8449
- let pattern = this.pathPatterns.get(cacheKey);
8450
- if (!pattern) {
8451
- const source = identifier
8452
- .split('[*]')
8453
- .map(part => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
8454
- .join('(?:\\[[^\\]]*\\]|\\.[^.[]+)');
8455
- pattern = new RegExp(`^${source}${descendants ? '(?:[.[].*)?' : ''}$`);
8456
- this.pathPatterns.set(cacheKey, pattern);
8534
+ // Clone template content
8535
+ const fragment = templateElement.content.cloneNode(true);
8536
+ const childNodes = Array.from(fragment.childNodes);
8537
+ // Find the first element node
8538
+ for (const node of childNodes) {
8539
+ if (node.nodeType === Node.ELEMENT_NODE) {
8540
+ return node;
8541
+ }
8457
8542
  }
8458
- return pattern.test(changePath);
8543
+ throw new Error(`No element found in template '#${finalTemplateID}'`);
8459
8544
  }
8460
8545
  }
8461
8546
 
@@ -8486,6 +8571,13 @@ class VBindings {
8486
8571
  * The set of changed identifiers.
8487
8572
  */
8488
8573
  #changes = new Set();
8574
+ /**
8575
+ * On the root bindings: every descendant scope (a v-for row, a component) that
8576
+ * recorded a change since the last clearChanges(). The root clears their change
8577
+ * sets together with its own at the end of the update cycle, so a loop variable
8578
+ * set when a row was created does not count as changed in every later cycle.
8579
+ */
8580
+ #dirtyScopes;
8489
8581
  /**
8490
8582
  * Cache for array lengths to detect length changes when the same object reference is used.
8491
8583
  */
@@ -8628,7 +8720,7 @@ class VBindings {
8628
8720
  for (const part of changedPath?.split('.') || []) {
8629
8721
  path = path ? `${path}.${part}` : part;
8630
8722
  this.#logger?.debug(`Binding changed: ${path}`);
8631
- this.#changes.add(path);
8723
+ this.#addChange(path);
8632
8724
  }
8633
8725
  if (!this.#suppressOnChange) {
8634
8726
  this.#onChange?.(changedPath);
@@ -8668,7 +8760,7 @@ class VBindings {
8668
8760
  for (const part of changedPath.split('.')) {
8669
8761
  path = path ? `${path}.${part}` : part;
8670
8762
  this.#logger?.debug(`Binding changed (external): ${path}`);
8671
- this.#changes.add(path);
8763
+ this.#addChange(path);
8672
8764
  }
8673
8765
  if (!this.#suppressOnChange) {
8674
8766
  this.#onChange?.(changedPath);
@@ -8695,7 +8787,7 @@ class VBindings {
8695
8787
  const newValuePreview = newValueString.length > 100 ? `${newValueString.substring(0, 100)}...` : newValueString;
8696
8788
  this.#logger.debug(`Binding set on ${target === obj ? 'local' : 'parent'}: ${key}: ${oldValuePreview} -> ${newValuePreview}`);
8697
8789
  }
8698
- this.#changes.add(key);
8790
+ this.#addChange(key);
8699
8791
  if (!this.#suppressOnChange) {
8700
8792
  this.#onChange?.(key);
8701
8793
  }
@@ -8710,7 +8802,7 @@ class VBindings {
8710
8802
  this.#externalSubscriptions.delete(key);
8711
8803
  }
8712
8804
  this.#logger?.debug(`Binding deleted: ${key}`);
8713
- this.#changes.add(key);
8805
+ this.#addChange(key);
8714
8806
  this.#onChange?.(key);
8715
8807
  return result;
8716
8808
  }
@@ -8751,6 +8843,53 @@ class VBindings {
8751
8843
  */
8752
8844
  clearChanges() {
8753
8845
  this.#changes.clear();
8846
+ this.#changeLog.length = 0;
8847
+ if (this.#dirtyScopes) {
8848
+ for (const scope of this.#dirtyScopes) {
8849
+ scope.#changes.clear();
8850
+ }
8851
+ this.#dirtyScopes.clear();
8852
+ }
8853
+ }
8854
+ /**
8855
+ * Records a changed identifier on this scope and, for a non-root scope, registers the
8856
+ * scope with the root so that the change is cleared at the end of the update cycle.
8857
+ */
8858
+ #addChange(key) {
8859
+ this.#changes.add(key);
8860
+ let root = this;
8861
+ while (root.#parent) {
8862
+ root = root.#parent;
8863
+ }
8864
+ root.#changeLog.push(key);
8865
+ if (root !== this) {
8866
+ (root.#dirtyScopes ??= new Set()).add(this);
8867
+ }
8868
+ }
8869
+ /**
8870
+ * Every change recorded since the last clearChanges(), in order and with repeats, on the
8871
+ * root; the change set itself cannot tell a write made during an update cycle from the one
8872
+ * the cycle was scheduled for when both touch the same identifier.
8873
+ */
8874
+ #changeLog = [];
8875
+ /**
8876
+ * A marker for {@link changesSince}: the number of changes recorded so far.
8877
+ */
8878
+ get changeLogLength() {
8879
+ return this.#root.#changeLog.length;
8880
+ }
8881
+ /**
8882
+ * The distinct identifiers recorded after the marker was taken.
8883
+ */
8884
+ changesSince(marker) {
8885
+ return Array.from(new Set(this.#root.#changeLog.slice(marker)));
8886
+ }
8887
+ get #root() {
8888
+ let root = this;
8889
+ while (root.#parent) {
8890
+ root = root.#parent;
8891
+ }
8892
+ return root;
8754
8893
  }
8755
8894
  /**
8756
8895
  * Sets a binding value.
@@ -8926,7 +9065,7 @@ class VBindings {
8926
9065
  * @param key The identifier to mark as changed.
8927
9066
  */
8928
9067
  markChanged(key) {
8929
- this.#changes.add(key);
9068
+ this.#addChange(key);
8930
9069
  }
8931
9070
  /**
8932
9071
  * Resolves a local path alias for the given identifier by walking up the bindings chain.
@@ -8973,6 +9112,54 @@ class VBindings {
8973
9112
  // Fall back to global alias resolution (computed properties, etc.)
8974
9113
  return ReactiveProxy.doesChangeMatchIdentifier(changePath, identifier);
8975
9114
  }
9115
+ /**
9116
+ * Checks whether any of the changes affects an expression with the given identifiers.
9117
+ *
9118
+ * An identifier with a run-time index ("frames[*].url" for frames[i].url) normally
9119
+ * matches a change at any index. When trackedPaths holds the paths the expression read
9120
+ * during its last evaluation, the identifier is narrowed to the concrete paths among
9121
+ * them ("frames[0].url"), so a write to another element is ignored. When no recorded
9122
+ * path fits the identifier (not evaluated yet, a raw object, a short-circuited read),
9123
+ * the identifier keeps matching every index.
9124
+ *
9125
+ * @param changes The change paths of the current update cycle.
9126
+ * @param identifiers The identifiers the expression depends on.
9127
+ * @param trackedPaths The paths read during the expression's last evaluation, if recorded.
9128
+ * @returns True if some change matches some identifier.
9129
+ */
9130
+ isAffected(changes, identifiers, trackedPaths) {
9131
+ if (changes.length === 0) {
9132
+ return false;
9133
+ }
9134
+ for (const identifier of identifiers) {
9135
+ const candidates = this.#concretePaths(identifier, trackedPaths) ?? [identifier];
9136
+ for (const candidate of candidates) {
9137
+ if (changes.some(change => this.doesChangeMatchIdentifier(change, candidate))) {
9138
+ return true;
9139
+ }
9140
+ }
9141
+ }
9142
+ return false;
9143
+ }
9144
+ /**
9145
+ * Returns the recorded paths that an identifier with a run-time index stood for during
9146
+ * the last evaluation, or undefined when the identifier has no such index or nothing
9147
+ * recorded fits it. The identifier is tried both as spelled and resolved through path
9148
+ * aliases, since recorded paths are source paths.
9149
+ */
9150
+ #concretePaths(identifier, trackedPaths) {
9151
+ if (!trackedPaths || trackedPaths.size === 0 || !identifier.includes('[*]')) {
9152
+ return undefined;
9153
+ }
9154
+ const resolved = this.resolveAlias(identifier);
9155
+ const paths = [];
9156
+ for (const path of trackedPaths) {
9157
+ if (ReactiveProxy.pathMatches(path, identifier) || (resolved !== undefined && ReactiveProxy.pathMatches(path, resolved))) {
9158
+ paths.push(path);
9159
+ }
9160
+ }
9161
+ return paths.length > 0 ? paths : undefined;
9162
+ }
8976
9163
  }
8977
9164
 
8978
9165
  // Copyright (c) 2025 MintJams Inc. Licensed under MIT License.
@@ -9167,10 +9354,14 @@ class VSlotOutletDirective {
9167
9354
  */
9168
9355
  get domUpdater() {
9169
9356
  const identifiers = this.dependentIdentifiers;
9357
+ const directive = this;
9170
9358
  const updater = {
9171
9359
  get dependentIdentifiers() {
9172
9360
  return identifiers;
9173
9361
  },
9362
+ get trackedPaths() {
9363
+ return directive.#collectTrackedPaths();
9364
+ },
9174
9365
  applyToDOM: () => {
9175
9366
  this.#refresh();
9176
9367
  }
@@ -9193,6 +9384,21 @@ class VSlotOutletDirective {
9193
9384
  }
9194
9385
  return Array.from(ids);
9195
9386
  }
9387
+ /**
9388
+ * The paths the prop expressions read during their last evaluation, united across
9389
+ * props, or undefined when no prop expression has a run-time index.
9390
+ */
9391
+ #collectTrackedPaths() {
9392
+ let paths;
9393
+ for (const prop of this.#props) {
9394
+ const tracked = prop.evaluator?.trackedPaths;
9395
+ if (tracked) {
9396
+ paths ??= new Set();
9397
+ tracked.forEach(path => paths.add(path));
9398
+ }
9399
+ }
9400
+ return paths;
9401
+ }
9196
9402
  /**
9197
9403
  * @inheritdoc
9198
9404
  *
@@ -9998,6 +10204,11 @@ class VTextEvaluator {
9998
10204
  * The function used to evaluate the text with current bindings.
9999
10205
  */
10000
10206
  #evaluate;
10207
+ /**
10208
+ * The source paths read through reactive proxies during the last evaluation,
10209
+ * recorded only when some expression has a run-time index ("items[i].name").
10210
+ */
10211
+ #trackedPaths;
10001
10212
  /**
10002
10213
  * Constructs a VTextEvaluator instance.
10003
10214
  * @param text The text containing embedded expressions in the form of {{...}}.
@@ -10038,8 +10249,10 @@ class VTextEvaluator {
10038
10249
  func: new Function(args, funcBody)
10039
10250
  };
10040
10251
  });
10252
+ // Only an expression with an index known at run time needs its reads recorded
10253
+ const hasDynamicIndex = this.#identifiers.some(id => id.includes('[*]'));
10041
10254
  // Create a function to reconstruct the text with evaluated expressions
10042
- this.#evaluate = (bindings) => {
10255
+ const evaluateText = (bindings) => {
10043
10256
  let result = text;
10044
10257
  evaluators.forEach((evaluator, i) => {
10045
10258
  // Build values for baseArgs (no dots) in the same order as function parameters
@@ -10057,6 +10270,15 @@ class VTextEvaluator {
10057
10270
  });
10058
10271
  return result;
10059
10272
  };
10273
+ this.#evaluate = hasDynamicIndex
10274
+ ? (bindings) => {
10275
+ const paths = new Set();
10276
+ this.#trackedPaths = undefined;
10277
+ const result = ReactiveProxy.track(paths, () => evaluateText(bindings));
10278
+ this.#trackedPaths = paths;
10279
+ return result;
10280
+ }
10281
+ : evaluateText;
10060
10282
  }
10061
10283
  /**
10062
10284
  * Checks if the given text contains any expressions in the form of {{...}}.
@@ -10073,6 +10295,14 @@ class VTextEvaluator {
10073
10295
  get identifiers() {
10074
10296
  return this.#identifiers;
10075
10297
  }
10298
+ /**
10299
+ * The source paths read through reactive proxies during the last evaluation.
10300
+ * Recorded only when an expression has a run-time index ("items[i].name");
10301
+ * undefined before the first evaluation and for texts without such an index.
10302
+ */
10303
+ get trackedPaths() {
10304
+ return this.#trackedPaths;
10305
+ }
10076
10306
  /**
10077
10307
  * Evaluates the text with the provided bindings and returns the resulting string.
10078
10308
  * @param bindings The bindings to use for evaluating the expressions.
@@ -10562,7 +10792,7 @@ class VNode {
10562
10792
  if (this.#nodeType === Node.TEXT_NODE && this.#textEvaluator) {
10563
10793
  // If this is a text node with a text evaluator, update its content if needed
10564
10794
  // Check if any of the identifiers are in the changed identifiers (considering path aliases)
10565
- const changed = this.#textEvaluator.identifiers.some(id => changes.some(change => this.bindings.doesChangeMatchIdentifier(change, id)));
10795
+ const changed = this.bindings.isAffected(changes, this.#textEvaluator.identifiers, this.#textEvaluator.trackedPaths);
10566
10796
  // If the text node has changed, update its content
10567
10797
  if (changed) {
10568
10798
  const text = this.#node;
@@ -10585,7 +10815,7 @@ class VNode {
10585
10815
  }
10586
10816
  // Prepare bindings for each preparer if relevant identifiers have changed
10587
10817
  for (const preparer of this.#directiveManager.bindingsPreparers) {
10588
- const changed = preparer.dependentIdentifiers.some(id => changes.some(change => this.bindings.doesChangeMatchIdentifier(change, id)));
10818
+ const changed = this.bindings.isAffected(changes, preparer.dependentIdentifiers, preparer.trackedPaths);
10589
10819
  if (changed) {
10590
10820
  preparer.prepareBindings();
10591
10821
  }
@@ -10594,7 +10824,7 @@ class VNode {
10594
10824
  // Apply DOM updaters from directives, if any
10595
10825
  if (this.#directiveManager?.domUpdaters) {
10596
10826
  for (const updater of this.#directiveManager.domUpdaters) {
10597
- const changed = updater.dependentIdentifiers.some(id => changes.some(change => this.bindings.doesChangeMatchIdentifier(change, id)));
10827
+ const changed = this.bindings.isAffected(changes, updater.dependentIdentifiers, updater.trackedPaths);
10598
10828
  if (changed) {
10599
10829
  updater.applyToDOM();
10600
10830
  }
@@ -10847,6 +11077,21 @@ class VConditionalDirectiveContext {
10847
11077
  get allDependentIdentifiers() {
10848
11078
  return this.#allDependentIdentifiers;
10849
11079
  }
11080
+ /**
11081
+ * The paths the conditions of the chain read during their last evaluation, united
11082
+ * across directives, or undefined when no condition has a run-time index.
11083
+ */
11084
+ get trackedPaths() {
11085
+ let paths;
11086
+ for (const directive of this.#directives) {
11087
+ const tracked = directive.trackedPaths;
11088
+ if (tracked) {
11089
+ paths ??= new Set();
11090
+ tracked.forEach(path => paths.add(path));
11091
+ }
11092
+ }
11093
+ return paths;
11094
+ }
10850
11095
  /**
10851
11096
  * Adds a directive (v-else-if or v-else) to the conditional context.
10852
11097
  * @param directive The directive to add.
@@ -11043,11 +11288,15 @@ class VConditionalDirective {
11043
11288
  */
11044
11289
  get domUpdater() {
11045
11290
  const identifiers = this.#conditionalContext.allDependentIdentifiers;
11291
+ const conditionalContext = this.#conditionalContext;
11046
11292
  // Create an updater that handles the conditional rendering
11047
11293
  const updater = {
11048
11294
  get dependentIdentifiers() {
11049
11295
  return identifiers;
11050
11296
  },
11297
+ get trackedPaths() {
11298
+ return conditionalContext.trackedPaths;
11299
+ },
11051
11300
  applyToDOM: () => {
11052
11301
  this.#render();
11053
11302
  }
@@ -11066,6 +11315,13 @@ class VConditionalDirective {
11066
11315
  get dependentIdentifiers() {
11067
11316
  return this.#evaluator?.dependentIdentifiers ?? [];
11068
11317
  }
11318
+ /**
11319
+ * The paths the condition read during its last evaluation, when it has an index
11320
+ * known only at run time (see ExpressionEvaluator.trackedPaths).
11321
+ */
11322
+ get trackedPaths() {
11323
+ return this.#evaluator?.trackedPaths;
11324
+ }
11069
11325
  /**
11070
11326
  * The context for managing related conditional directives (v-if, v-else-if, v-else).
11071
11327
  */
@@ -11432,11 +11688,15 @@ class VForDirective {
11432
11688
  */
11433
11689
  get domUpdater() {
11434
11690
  const identifiers = this.#sourceEvaluator?.dependentIdentifiers ?? [];
11691
+ const sourceEvaluator = this.#sourceEvaluator;
11435
11692
  // Create and return the DOM updater
11436
11693
  const updater = {
11437
11694
  get dependentIdentifiers() {
11438
11695
  return identifiers;
11439
11696
  },
11697
+ get trackedPaths() {
11698
+ return sourceEvaluator?.trackedPaths;
11699
+ },
11440
11700
  applyToDOM: () => {
11441
11701
  this.#render();
11442
11702
  }
@@ -12156,6 +12416,9 @@ class VHtmlDirective {
12156
12416
  get dependentIdentifiers() {
12157
12417
  return identifiers;
12158
12418
  },
12419
+ get trackedPaths() {
12420
+ return evaluator.trackedPaths;
12421
+ },
12159
12422
  applyToDOM: () => {
12160
12423
  const element = vNode.node;
12161
12424
  const htmlContent = evaluator.evaluate() ?? '';
@@ -12582,6 +12845,9 @@ class VModelDirective {
12582
12845
  get dependentIdentifiers() {
12583
12846
  return self.#collectDependentIdentifiers();
12584
12847
  },
12848
+ get trackedPaths() {
12849
+ return self.#evaluator?.trackedPaths;
12850
+ },
12585
12851
  applyToDOM: () => {
12586
12852
  self.#render();
12587
12853
  }
@@ -13980,6 +14246,9 @@ class VShowDirective {
13980
14246
  get dependentIdentifiers() {
13981
14247
  return identifiers;
13982
14248
  },
14249
+ get trackedPaths() {
14250
+ return evaluator.trackedPaths;
14251
+ },
13983
14252
  applyToDOM() {
13984
14253
  const shouldRender = evaluator.evaluateAsBoolean();
13985
14254
  if (shouldRender) {
@@ -14147,6 +14416,9 @@ class VTextDirective {
14147
14416
  get dependentIdentifiers() {
14148
14417
  return identifiers;
14149
14418
  },
14419
+ get trackedPaths() {
14420
+ return evaluator.trackedPaths;
14421
+ },
14150
14422
  applyToDOM() {
14151
14423
  const element = vNode.node;
14152
14424
  element.textContent = toDisplayString(evaluator.evaluate());
@@ -14309,6 +14581,9 @@ class VFocusDirective {
14309
14581
  get dependentIdentifiers() {
14310
14582
  return identifiers;
14311
14583
  },
14584
+ get trackedPaths() {
14585
+ return evaluator.trackedPaths;
14586
+ },
14312
14587
  applyToDOM: () => {
14313
14588
  const value = evaluator.evaluateAsBoolean();
14314
14589
  const previous = this.#previousValue;
@@ -15197,6 +15472,14 @@ class VApplication {
15197
15472
  * Flag to indicate if an update is already scheduled.
15198
15473
  */
15199
15474
  #updateScheduled = false;
15475
+ /**
15476
+ * How many update cycles in a row ended with changes made during the cycle (by a watcher
15477
+ * or a lifecycle hook), each of which runs another cycle. Reset when a cycle ends clean;
15478
+ * past MAX_CASCADED_UPDATES the late changes are dropped, so a watcher that writes on every
15479
+ * cycle cannot keep the application updating forever.
15480
+ */
15481
+ #cascadedUpdates = 0;
15482
+ static #MAX_CASCADED_UPDATES = 100;
15200
15483
  /**
15201
15484
  * The custom element hosting this application when it powers a component
15202
15485
  * defined via defineComponent(). Set by IchigoElement before mounting.
@@ -15640,8 +15923,10 @@ class VApplication {
15640
15923
  }
15641
15924
  this.#updateScheduled = true;
15642
15925
  queueMicrotask(() => {
15643
- this.#update();
15926
+ // Cleared before the update runs, not after: a change made during the update (by a
15927
+ // watcher or a lifecycle hook) then schedules the next cycle instead of being dropped.
15644
15928
  this.#updateScheduled = false;
15929
+ this.#update();
15645
15930
  });
15646
15931
  }
15647
15932
  /**
@@ -15655,14 +15940,46 @@ class VApplication {
15655
15940
  // This converts paths like "model.elements[0].executionListeners"
15656
15941
  // to "selectedElement.executionListeners" when selectedElement points to model.elements[0]
15657
15942
  this.#applyComputedPathMappings();
15943
+ // The changes this cycle was scheduled for. Whatever is recorded after this point was
15944
+ // written during the cycle itself (see below); the marker tells the two apart even when
15945
+ // a watcher writes the very identifier it watches.
15946
+ const scheduledChanges = this.#bindings ? this.#bindings.changes : [];
15947
+ const lateMarker = this.#bindings ? this.#bindings.changeLogLength : 0;
15658
15948
  // Notify watchers before DOM update
15659
15949
  if (this.#bindings) {
15660
- this.#watcher.notify(this.#bindings.changes, (p) => this.#getValueByPath(p), this.#bindings.raw);
15950
+ this.#watcher.notify(scheduledChanges, (p) => this.#getValueByPath(p), this.#bindings.raw);
15951
+ // A watcher may have written data. The computed properties that depend on it were
15952
+ // only marked dirty, which the DOM diff below does not see; resolve them now, so this
15953
+ // render already shows what the watcher did, and so the computed names are in the
15954
+ // change set when the diff runs.
15955
+ this.#flushDirtyComputeds();
15956
+ this.#applyComputedPathMappings();
15661
15957
  }
15662
15958
  // Update the DOM
15663
15959
  this.#vNode?.update();
15664
- // Clear the set of changed identifiers after the update
15665
- this.#bindings?.clearChanges();
15960
+ // Clear the set of changed identifiers after the update. Changes recorded during the
15961
+ // cycle (by watchers or lifecycle hooks) are carried into the next cycle instead: the DOM
15962
+ // diff above has seen them, but watchers that depend on them have not, since watcher
15963
+ // notification ran before they were written.
15964
+ if (this.#bindings) {
15965
+ const late = this.#bindings.changesSince(lateMarker);
15966
+ this.#bindings.clearChanges();
15967
+ if (late.length > 0) {
15968
+ this.#cascadedUpdates++;
15969
+ if (this.#cascadedUpdates > VApplication.#MAX_CASCADED_UPDATES) {
15970
+ this.#logger.warn(`Update cycle did not settle after ${VApplication.#MAX_CASCADED_UPDATES} cascaded updates; dropping changes: ${late.join(', ')}`);
15971
+ this.#cascadedUpdates = 0;
15972
+ return;
15973
+ }
15974
+ for (const change of late) {
15975
+ this.#bindings.markChanged(change);
15976
+ }
15977
+ this.#scheduleUpdate();
15978
+ }
15979
+ else {
15980
+ this.#cascadedUpdates = 0;
15981
+ }
15982
+ }
15666
15983
  }
15667
15984
  /**
15668
15985
  * Applies computed source path mappings to the current changes.