Skip to content

Add Alpine.skipAutoEvaluate() so returned teardowns aren't called by auto-evaluation - #4924

Open
MaxencePaulin wants to merge 2 commits into
alpinejs:mainfrom
MaxencePaulin:feature/skip_auto_evaluate
Open

MaxencePaulin wants to merge 2 commits into
alpinejs:mainfrom
MaxencePaulin:feature/skip_auto_evaluate

Conversation

@MaxencePaulin

@MaxencePaulin MaxencePaulin commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

The problem

Alpine calls a function when it's the value of an expression. That's why @click="toggle" works, but a subscriber can't return its teardown: x-init="$watch(...)" or x-init="unwatch = $watch(...)" would call it right away. So $watch returns nothing, and a watcher can't be stopped from a template.

Livewire has the same problem: $wire.$watch, $wire.$hook and $wire.$intercept* return a teardown that x-init calls right away, while $wire.$on returns nothing.

The solution

Alpine.skipAutoEvaluate(func) returns func as is. Expressions already running when it's called won't call it, later ones will. $watch now returns its unwatch through it:

return skipAutoEvaluate(unwatch)
<div x-data="{ open: false, unwatch: null }" x-init="unwatch = $watch('open', value => console.log(value))">
    <button @click="unwatch">Stop watching</button>
</div>
  • x-init="$watch(...)" keeps watching, @click="unwatch" stops it, and so does unwatch() from JavaScript.
  • It holds across await, multiple expressions and nested evaluations, in every evaluator (normal, raw, CSP, CSP raw), including semicolon-separated CSP expressions (Support semicolon-separated expressions in the CSP evaluator #4915).

How it works

Skipped functions are numbered in a WeakMap. Each evaluator notes the current number before running its expression, and won't call a function skipped after that:

export function shouldAutoEvaluate(value, startedAt) {
    return shouldAutoEvaluateFunctions
        && typeof value === 'function'
        && ! (skippedAt.get(value) > startedAt)
}

No "current evaluation" to track, so await isn't a problem. The function isn't mutated or retained, and runIfTypeOfFunction() callers are unchanged (startedAt is an optional last parameter). +82 bytes gzipped.

Trade-offs

  • The counter is never reset, like nextStructuralEffectOrder in reactivity.js and markerDispenser in lifecycle.js: there's no safe moment while async expressions still compare against it.
  • An async expression is still running during its await, so it won't call a function skipped meanwhile, even one it reads afterwards. Calling it explicitly, unwatch(), isn't affected:
<button @click="(await save(), unwatch)"></button>  <!-- 1. waits on save() -->
<button @click="unwatch = $watch(...)"></button>    <!-- 2. clicked meanwhile, 1 won't call this unwatch either -->

Alternatives considered

  • dontAutoEvaluateFunctions(): only covers evaluations run inside its callback, not the one already calling the subscriber, nor past an await.
  • A flag on the function (property or Symbol): permanent, @click="unwatch" never works; cleared once skipped, foo = 1; unwatch = $watch(...) keeps it. Storing the number there mutates the function, and throws if it's frozen.
  • A wrapper: still needs marking, and breaks reference comparisons.
  • Tracking running evaluations: lost across an await, and leaks when a promise never settles.
  • Skipping what a promise resolves to: unnecessary, promise.then(Alpine.skipAutoEvaluate) does it.

Livewire

If this is accepted, Livewire can route its subscriptions ($wire.$watch, $wire.$on, $wire.$hook, $wire.$intercept*) through one helper returning their teardown wrapped in Alpine.skipAutoEvaluate(). They would all return a teardown, and calling any of them from an expression, like x-init="$wire.$on(...)", would keep it subscribed.

Tests

Vitest specs for each evaluator path (evaluator.spec.js, csp-evaluator.spec.js) and $watch returns a function to stop watching in Cypress (normal and CSP), all failing without the change. Full Vitest and Cypress suites pass.

Documentation

A "Stopping a watcher" section in magics/watch.md.

@MaxencePaulin MaxencePaulin changed the title Add Alpine.skipAutoEvaluate() so $watch can return its unwatch function Add Alpine.skipAutoEvaluate() so subscribers can return their teardown Oct 1, 2026
@ekwoka

ekwoka commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

It's a bit unclear what was going on, and I think I understand it.

This is specifically about $watch returning a teardown function, so x-init causes the function to tear itself down, right?

I think this solution is overengineered.

I think you could just have a symbol or property on the function that has the auto evaluator skip it.

And a function that marks the thing as not-auto evaluated

So more like

const SKIP_EVALUATION = new Symbol();
const skipAutoEvaluate = (fn) => {
  fn[SKIP_EVALUATION] = true
  return fn
}

and the auto evalutor just checks

if (SKIP_EVALUATION in callback) { ... }

Not sure what the need for counters and so many different touch points is in the presented implementation.

Would this satisfy the goal you're concerned about or is there a reason for everything else like weakmaps and stuff?

@MaxencePaulin

MaxencePaulin commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor Author

Hi @ekwoka, thanks for the review, yes, you got it right. The issue is subscribers that return their teardown (Livewire's $wire.$watch, $wire.$hook, $wire.$intercept*). It's useful for clearing whenever you want, but x-init auto-calls function results, so you can't use them as a plain statement:

<div x-init="$wire.$watch('foo', cb)"></div> <!-- teardown called instantly, unsubscribed -->

I also made Alpine's $watch return its unwatch (it returns nothing today), so both behave the same.

I tried the flag first, but it has issues:

1. Permanent flag: potential regression if this feature is later used on Livewire's subscribers

<div x-init="unwatch = $wire.$watch('foo', cb)">
    <button @click="unwatch">Stop</button> <!-- works today, but would never call it if the function were flagged -->
</div>

Livewire's subscribers support this today. With a permanent flag, @click="unwatch" would stop working (like it does for any other function, e.g. @click="toggle"), and you'd have to write @click="unwatch()" every time.

2. Clearing the flag after skipping: it's only cleared if the evaluator checks it

<div x-init="foo = 1; unwatch = $watch('open', cb)">
    <button @click="unwatch">Stop</button>
</div>

Here the function is never the final result of x-init, so the evaluator never checks it and the flag is never cleared. The flag then lingers until the next auto-call: the first @click="unwatch" is skipped (nothing happens), and only the following clicks work. That's inconsistent, and depends on the shape of the expression.

3. Frozen functions

const fn = Object.freeze(() => {})
fn[SKIP_EVALUATION] = true // throws in strict mode

Minor, we could just not support frozen functions.

That's why I went with a counter and a WeakMap: no mutation of the function, and only expressions already running at that moment skip it.

As for the touch points: they're the same places that use shouldAutoEvaluateFunctions today, so a SKIP_EVALUATION in callback check would need to be called in each of them too. I stayed close to the existing implementation because I thought it would be clearer to follow.

My goal was to propose this with as few quirks to document as possible, so it's easier to accept. If you think @click="unwatch" isn't worth supporting, I can simplify to the flag. What do you think?

@MaxencePaulin MaxencePaulin changed the title Add Alpine.skipAutoEvaluate() so subscribers can return their teardown Add Alpine.skipAutoEvaluate() so returned teardowns aren't called by auto-evaluation Oct 5, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants