From b678f402b80863313f220d1b920f7d026055a15f Mon Sep 17 00:00:00 2001 From: MaxencePaulin Date: Fri, 2 Oct 2026 01:21:47 +0200 Subject: [PATCH 1/2] feat(Evaluator): Add Alpine.skipAutoEvaluate() so $watch can return its unwatch function --- packages/alpinejs/src/alpine.js | 3 +- packages/alpinejs/src/evaluator.js | 39 ++++++-- packages/alpinejs/src/magics/$watch.js | 5 + packages/csp/src/evaluator.js | 10 +- packages/docs/src/en/magics/watch.md | 15 +++ .../cypress/integration/magics/$watch.spec.js | 18 ++++ .../plugins/csp-compatibility.spec.js | 27 ++++++ tests/vitest/csp-evaluator.spec.js | 29 ++++++ tests/vitest/evaluator.spec.js | 95 ++++++++++++++++++- 9 files changed, 229 insertions(+), 12 deletions(-) diff --git a/packages/alpinejs/src/alpine.js b/packages/alpinejs/src/alpine.js index 942fd1147..e36ad8fd8 100644 --- a/packages/alpinejs/src/alpine.js +++ b/packages/alpinejs/src/alpine.js @@ -4,7 +4,7 @@ import { start, addRootSelector, addInitSelector, closestRoot, findClosest, init import { onElRemoved, onAttributeRemoved, onAttributesAdded, mutateDom, deferMutations, flushAndStopDeferringMutations, startObservingMutations, stopObservingMutations } from './mutation' import { deferInit } from './deferInit' import { mergeProxies, closestDataStack, addScopeToNode, scope as $data } from './scope' -import { setEvaluator, setRawEvaluator, evaluate, evaluateLater, dontAutoEvaluateFunctions, evaluateRaw } from './evaluator' +import { setEvaluator, setRawEvaluator, evaluate, evaluateLater, dontAutoEvaluateFunctions, evaluateRaw, skipAutoEvaluate } from './evaluator' import { transition } from './directives/x-transition' import { clone, cloneNode, skipDuringClone, onlyDuringClone, interceptClone } from './clone' import { interceptor, initInterceptors } from './interceptor' @@ -31,6 +31,7 @@ let Alpine = { version: ALPINE_VERSION, flushAndStopDeferringMutations, dontAutoEvaluateFunctions, + skipAutoEvaluate, disableEffectScheduling, startObservingMutations, stopObservingMutations, diff --git a/packages/alpinejs/src/evaluator.js b/packages/alpinejs/src/evaluator.js index fa76422cb..fc2c112a5 100644 --- a/packages/alpinejs/src/evaluator.js +++ b/packages/alpinejs/src/evaluator.js @@ -4,6 +4,25 @@ import { tryCatch, handleError } from './utils/error' export let shouldAutoEvaluateFunctions = true +// Functions passed to skipAutoEvaluate() are numbered, so an evaluation won't call +// the ones skipped while it was running (like the "unwatch" returned by $watch), +// but any later evaluation still will. +export let skipCount = 0 + +let skippedAt = new WeakMap() + +export function skipAutoEvaluate(func) { + skippedAt.set(func, ++skipCount) + + return func +} + +export function shouldAutoEvaluate(value, startedAt) { + return shouldAutoEvaluateFunctions + && typeof value === 'function' + && ! (skippedAt.get(value) > startedAt) +} + export function dontAutoEvaluateFunctions(callback) { let cache = shouldAutoEvaluateFunctions @@ -63,9 +82,11 @@ export function generateEvaluatorFromFunction(dataStack, func) { return } + let startedAt = skipCount + let result = func.apply(mergeProxies([scope, ...dataStack]), params) - runIfTypeOfFunction(receiver, result) + runIfTypeOfFunction(receiver, result, undefined, undefined, undefined, startedAt) } } @@ -125,12 +146,14 @@ function generateEvaluatorFromString(dataStack, expression, el) { let completeScope = mergeProxies([ scope, ...dataStack ]) if (typeof func === 'function' ) { + let startedAt = skipCount + let promise = func.call(context, func, completeScope).catch((error) => handleError(error, el, expression)) // Check if the function ran synchronously, if (func.finished) { // Return the immediate result. - runIfTypeOfFunction(receiver, func.result, completeScope, params, el) + runIfTypeOfFunction(receiver, func.result, completeScope, params, el, startedAt) // Once the function has run, we clear func.result so we don't create // memory leaks. func is stored in the evaluatorMemo and every time // it runs, it assigns the evaluated expression to result which could @@ -139,7 +162,7 @@ function generateEvaluatorFromString(dataStack, expression, el) { } else { // If not, return the result when the promise resolves. promise.then(result => { - runIfTypeOfFunction(receiver, result, completeScope, params, el) + runIfTypeOfFunction(receiver, result, completeScope, params, el, startedAt) }).catch( error => handleError( error, el, expression ) ) .finally( () => func.result = undefined ) } @@ -147,12 +170,12 @@ function generateEvaluatorFromString(dataStack, expression, el) { } } -export function runIfTypeOfFunction(receiver, value, scope, params, el) { - if (shouldAutoEvaluateFunctions && typeof value === 'function') { +export function runIfTypeOfFunction(receiver, value, scope, params, el, startedAt = skipCount) { + if (shouldAutoEvaluate(value, startedAt)) { let result = value.apply(scope, params) if (result instanceof Promise) { - result.then(i => runIfTypeOfFunction(receiver, i, scope, params)).catch( error => handleError( error, el, value ) ) + result.then(i => runIfTypeOfFunction(receiver, i, scope, params, el, startedAt)).catch( error => handleError( error, el, value ) ) } else { receiver(result) } @@ -217,10 +240,12 @@ export function normalRawEvaluator(el, expression, extras = {}) { `with (scope) { let __result = ${rightSideSafeExpression}; return __result }` ) + let startedAt = skipCount + let result = func.call(extras.context, scope) // If the result is a function, call it - if (typeof result === 'function' && shouldAutoEvaluateFunctions) { + if (shouldAutoEvaluate(result, startedAt)) { return result.apply(scope, params) } diff --git a/packages/alpinejs/src/magics/$watch.js b/packages/alpinejs/src/magics/$watch.js index 7539c8792..e120479d2 100644 --- a/packages/alpinejs/src/magics/$watch.js +++ b/packages/alpinejs/src/magics/$watch.js @@ -1,5 +1,6 @@ import { magic } from '../magics' import { watch } from '../reactivity' +import { skipAutoEvaluate } from '../evaluator' magic('watch', (el, { evaluateLater, cleanup }) => (key, callback) => { let evaluate = evaluateLater(key) @@ -15,4 +16,8 @@ magic('watch', (el, { evaluateLater, cleanup }) => (key, callback) => { let unwatch = watch(getter, callback) cleanup(unwatch) + + // Only skipped while the expression calling $watch() runs, so x-init="$watch(...)" + // doesn't stop watching right away, but @click="unwatch" still does... + return skipAutoEvaluate(unwatch) }) diff --git a/packages/csp/src/evaluator.js b/packages/csp/src/evaluator.js index 5abcd52e8..8c0db8fd8 100644 --- a/packages/csp/src/evaluator.js +++ b/packages/csp/src/evaluator.js @@ -1,4 +1,4 @@ -import { generateEvaluatorFromFunction, shouldAutoEvaluateFunctions } from 'alpinejs/src/evaluator' +import { generateEvaluatorFromFunction, shouldAutoEvaluate, skipCount } from 'alpinejs/src/evaluator' import { closestDataStack, mergeProxies } from 'alpinejs/src/scope' import { tryCatch } from 'alpinejs/src/utils/error' import { generateRuntimeFunction } from './parser' @@ -13,13 +13,15 @@ export function cspRawEvaluator(el, expression, extras = {}) { let evaluate = generateRuntimeFunction(expression) + let startedAt = skipCount + let result = evaluate({ scope, forceBindingRootScopeToFunctions: true, }) // If the result is a function, call it - if (typeof result === 'function' && shouldAutoEvaluateFunctions) { + if (shouldAutoEvaluate(result, startedAt)) { return result.apply(scope, params) } @@ -61,12 +63,14 @@ function generateEvaluator(el, expression, dataStack) { let evaluate = generateRuntimeFunction(expression) + let startedAt = skipCount + let returnValue = evaluate({ scope: completeScope, forceBindingRootScopeToFunctions: true, }) - if (shouldAutoEvaluateFunctions && typeof returnValue === 'function') { + if (shouldAutoEvaluate(returnValue, startedAt)) { let nextReturnValue = returnValue.apply(returnValue, params) if (nextReturnValue instanceof Promise) { diff --git a/packages/docs/src/en/magics/watch.md b/packages/docs/src/en/magics/watch.md index 3fc402a64..5ab513b1d 100644 --- a/packages/docs/src/en/magics/watch.md +++ b/packages/docs/src/en/magics/watch.md @@ -36,6 +36,21 @@ When the ` + + + +``` + +Even though Alpine calls functions returned by an expression, the one returned by `$watch` isn't called by the expression that started the watcher, so `x-init` keeps watching. + ### Deep watching diff --git a/tests/cypress/integration/magics/$watch.spec.js b/tests/cypress/integration/magics/$watch.spec.js index 6946de07a..14ba6102a 100644 --- a/tests/cypress/integration/magics/$watch.spec.js +++ b/tests/cypress/integration/magics/$watch.spec.js @@ -271,3 +271,21 @@ test('$watch does not fire for a change made after its element is removed', } ) +test('$watch returns a function to stop watching', + html` +
+

+ + + +
+ `, + ({ get }) => { + get('h1').should(haveText('lob')) + get('#change').click() + get('h1').should(haveText('bar!')) + get('#unwatch').click() + get('#change').click() + get('h1').should(haveText('bar!')) + } +) diff --git a/tests/cypress/integration/plugins/csp-compatibility.spec.js b/tests/cypress/integration/plugins/csp-compatibility.spec.js index 2ff1e2173..a077a9872 100644 --- a/tests/cypress/integration/plugins/csp-compatibility.spec.js +++ b/tests/cypress/integration/plugins/csp-compatibility.spec.js @@ -277,3 +277,30 @@ test.csp('throws when inserting a node into the DOM', cy.get('button').click() }, ) + +test.csp('$watch returns a function to stop watching', + [html` +
+

+ + + +
+ `, + ` + Alpine.data('watcher', () => ({ + foo: 'bar', + bob: 'lob', + unwatch: null, + update(value) { this.bob = value }, + })) + `], + ({ get }) => { + get('h1').should(haveText('lob')) + get('#change').click() + get('h1').should(haveText('bar!')) + get('#unwatch').click() + get('#change').click() + get('h1').should(haveText('bar!')) + }, +) diff --git a/tests/vitest/csp-evaluator.spec.js b/tests/vitest/csp-evaluator.spec.js index ef4e53fe1..1b6168f99 100644 --- a/tests/vitest/csp-evaluator.spec.js +++ b/tests/vitest/csp-evaluator.spec.js @@ -446,3 +446,32 @@ describe('DOM access restrictions', () => { expect(cspRawEvaluator(element, "list.append('a')", { scope })).toBe(1) }); }); + +describe('skipAutoEvaluate()', () => { + it('is not called by the expression that produced it', () => { + let element = { parentNode: null, _x_dataStack: [] } + let calls = 0 + let scope = { unwatch: null, subscribe: () => Alpine.skipAutoEvaluate(() => calls++) } + + Alpine.evaluate(element, 'subscribe()', { scope }) + Alpine.evaluate(element, 'unwatch = subscribe()', { scope }) + cspRawEvaluator(element, 'unwatch = subscribe()', { scope }) + + expect(calls).toBe(0) + }); + + it('is called by later expressions', () => { + let element = { parentNode: null, _x_dataStack: [] } + let calls = 0 + let scope = { unwatch: null, subscribe: () => Alpine.skipAutoEvaluate(() => calls++) } + + Alpine.evaluate(element, 'unwatch = subscribe()', { scope }) + Alpine.evaluate(element, 'unwatch', { scope }) + + expect(calls).toBe(1) + + cspRawEvaluator(element, 'unwatch', { scope }) + + expect(calls).toBe(2) + }); +}); diff --git a/tests/vitest/evaluator.spec.js b/tests/vitest/evaluator.spec.js index 8e25b3ce4..23a73a176 100644 --- a/tests/vitest/evaluator.spec.js +++ b/tests/vitest/evaluator.spec.js @@ -203,4 +203,97 @@ describe('evaluateRaw([String])', () => { expect(result).toBeInstanceOf(Promise) expect(result.foo).toBe('bar') }); -}) \ No newline at end of file +}) + +describe('skipAutoEvaluate()', () => { + it('returns the function as is', () => { + let func = () => {} + + expect(Alpine.skipAutoEvaluate(func)).toBe(func) + }); + + it('is not called by the expression that produced it', () => { + let element = { parentNode: null, _x_dataStack: [] } + let calls = 0 + let scope = { unwatch: null, foo: null, subscribe: () => Alpine.skipAutoEvaluate(() => calls++) } + + evaluate(element, 'subscribe()', { scope }) + evaluate(element, 'unwatch = subscribe()', { scope }) + evaluate(element, '(foo = 1, subscribe())', { scope }) + evaluate(element, 'unwatch = subscribe(); foo = 1', { scope }) + + expect(calls).toBe(0) + }); + + it('is called by later expressions', () => { + let element = { parentNode: null, _x_dataStack: [] } + let calls = 0 + let scope = { unwatch: null, subscribe: () => Alpine.skipAutoEvaluate(() => calls++) } + + evaluate(element, 'unwatch = subscribe()', { scope }) + evaluate(element, 'unwatch', { scope }) + + expect(calls).toBe(1) + + scope.unwatch() + + expect(calls).toBe(2) + }); + + it('is not called by an async expression that produced it', async () => { + let element = { parentNode: null, _x_dataStack: [] } + let calls = 0 + let tick = () => new Promise(resolve => setTimeout(resolve)) + let scope = { + unwatch: null, + tick, + subscribe: () => Alpine.skipAutoEvaluate(() => calls++), + subscribeAsync: async () => { await tick(); return Alpine.skipAutoEvaluate(() => calls++) }, + } + + evaluate(element, 'unwatch = subscribe(); await tick()', { scope }) + evaluate(element, 'let foo = await tick(); return subscribe()', { scope }) + evaluate(element, 'await subscribeAsync()', { scope }) + evaluate(element, 'subscribeAsync', { scope }) + + await new Promise(resolve => setTimeout(resolve, 10)) + + expect(calls).toBe(0) + }); + + it('is not called by a nested expression or the one around it', () => { + let element = { parentNode: null, _x_dataStack: [] } + let calls = 0 + let scope = { subscribe: () => Alpine.skipAutoEvaluate(() => calls++) } + + scope.nested = () => evaluate(element, 'subscribe()', { scope }) + + evaluate(element, 'nested()', { scope }) + + expect(calls).toBe(0) + }); + + it('is not called by a function expression that produced it', () => { + let element = { parentNode: null, _x_dataStack: [] } + let calls = 0 + let scope = { subscribe: () => Alpine.skipAutoEvaluate(() => calls++) } + + evaluate(element, function () { return this.subscribe() }, { scope }) + + expect(calls).toBe(0) + }); + + it('is not called by a raw expression that produced it', () => { + let element = { parentNode: null, _x_dataStack: [] } + let calls = 0 + let scope = { unwatch: null, subscribe: () => Alpine.skipAutoEvaluate(() => calls++) } + + evaluateRaw(element, 'unwatch = subscribe()', { scope }) + + expect(calls).toBe(0) + + evaluateRaw(element, 'unwatch', { scope }) + + expect(calls).toBe(1) + }); +}) From 413ff7af69029b95421e9c100f1754ff3f3de336 Mon Sep 17 00:00:00 2001 From: MaxencePaulin Date: Fri, 2 Oct 2026 02:04:07 +0200 Subject: [PATCH 2/2] tests(Evaluator): Check skipped functions can still be called in skipAutoEvaluate tests --- tests/vitest/csp-evaluator.spec.js | 4 ++++ tests/vitest/evaluator.spec.js | 20 ++++++++++++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/tests/vitest/csp-evaluator.spec.js b/tests/vitest/csp-evaluator.spec.js index 1b6168f99..409003fc7 100644 --- a/tests/vitest/csp-evaluator.spec.js +++ b/tests/vitest/csp-evaluator.spec.js @@ -458,6 +458,10 @@ describe('skipAutoEvaluate()', () => { cspRawEvaluator(element, 'unwatch = subscribe()', { scope }) expect(calls).toBe(0) + + scope.unwatch() + + expect(calls).toBe(1) }); it('is called by later expressions', () => { diff --git a/tests/vitest/evaluator.spec.js b/tests/vitest/evaluator.spec.js index 23a73a176..adad2db61 100644 --- a/tests/vitest/evaluator.spec.js +++ b/tests/vitest/evaluator.spec.js @@ -223,6 +223,10 @@ describe('skipAutoEvaluate()', () => { evaluate(element, 'unwatch = subscribe(); foo = 1', { scope }) expect(calls).toBe(0) + + scope.unwatch() + + expect(calls).toBe(1) }); it('is called by later expressions', () => { @@ -259,6 +263,10 @@ describe('skipAutoEvaluate()', () => { await new Promise(resolve => setTimeout(resolve, 10)) expect(calls).toBe(0) + + scope.unwatch() + + expect(calls).toBe(1) }); it('is not called by a nested expression or the one around it', () => { @@ -268,9 +276,13 @@ describe('skipAutoEvaluate()', () => { scope.nested = () => evaluate(element, 'subscribe()', { scope }) - evaluate(element, 'nested()', { scope }) + let unwatch = evaluate(element, 'nested()', { scope }) expect(calls).toBe(0) + + unwatch() + + expect(calls).toBe(1) }); it('is not called by a function expression that produced it', () => { @@ -278,9 +290,13 @@ describe('skipAutoEvaluate()', () => { let calls = 0 let scope = { subscribe: () => Alpine.skipAutoEvaluate(() => calls++) } - evaluate(element, function () { return this.subscribe() }, { scope }) + let unwatch = evaluate(element, function () { return this.subscribe() }, { scope }) expect(calls).toBe(0) + + unwatch() + + expect(calls).toBe(1) }); it('is not called by a raw expression that produced it', () => {