Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/alpinejs/src/alpine.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -31,6 +31,7 @@ let Alpine = {
version: ALPINE_VERSION,
flushAndStopDeferringMutations,
dontAutoEvaluateFunctions,
skipAutoEvaluate,
disableEffectScheduling,
startObservingMutations,
stopObservingMutations,
Expand Down
39 changes: 32 additions & 7 deletions packages/alpinejs/src/evaluator.js
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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
Expand All @@ -139,20 +162,20 @@ 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 )
}
}
}
}

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)
}
Expand Down Expand Up @@ -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)
}

Expand Down
5 changes: 5 additions & 0 deletions packages/alpinejs/src/magics/$watch.js
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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)
})
10 changes: 7 additions & 3 deletions packages/csp/src/evaluator.js
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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)
}

Expand Down Expand Up @@ -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) {
Expand Down
15 changes: 15 additions & 0 deletions packages/docs/src/en/magics/watch.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,21 @@ When the `<button>` is pressed, `foo.bar` will be set to "bob", and "bob" will b
</div>
```

<a name="stopping-a-watcher"></a>
### Stopping a watcher

`$watch` returns a function you can call to stop watching:

```alpine
<div x-data="{ open: false, unwatch: null }" x-init="unwatch = $watch('open', value => console.log(value))">
<button @click="open = ! open">Toggle Open</button>

<button @click="unwatch">Stop watching</button>
</div>
```

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.

<a name="deep-watching"></a>
### Deep watching

Expand Down
18 changes: 18 additions & 0 deletions tests/cypress/integration/magics/$watch.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`
<div x-data="{ foo: 'bar', bob: 'lob', unwatch: null }" x-init="unwatch = $watch('foo', value => bob = value)">
<h1 x-text="bob"></h1>

<button id="change" x-on:click="foo = foo + '!'"></button>
<button id="unwatch" x-on:click="unwatch"></button>
</div>
`,
({ 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!'))
}
)
27 changes: 27 additions & 0 deletions tests/cypress/integration/plugins/csp-compatibility.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`
<div x-data="watcher" x-init="unwatch = $watch('foo', update)">
<h1 x-text="bob"></h1>

<button id="change" x-on:click="foo = foo + '!'"></button>
<button id="unwatch" x-on:click="unwatch"></button>
</div>
`,
`
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!'))
},
)
33 changes: 33 additions & 0 deletions tests/vitest/csp-evaluator.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -446,3 +446,36 @@ 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)

scope.unwatch()

expect(calls).toBe(1)
});

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)
});
});
111 changes: 110 additions & 1 deletion tests/vitest/evaluator.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -203,4 +203,113 @@ describe('evaluateRaw([String])', () => {
expect(result).toBeInstanceOf(Promise)
expect(result.foo).toBe('bar')
});
})
})

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)

scope.unwatch()

expect(calls).toBe(1)
});

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)

scope.unwatch()

expect(calls).toBe(1)
});

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 })

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', () => {
let element = { parentNode: null, _x_dataStack: [] }
let calls = 0
let scope = { subscribe: () => Alpine.skipAutoEvaluate(() => calls++) }

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', () => {
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)
});
})
Loading