@mintjamsinc/ichigojs 0.1.86 → 0.1.87

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