Repository navigation
Add Alpine.skipAutoEvaluate() so returned teardowns aren't called by auto-evaluation - #4924
MaxencePaulin wants to merge 2 commits into
Conversation
…ts unwatch function
…AutoEvaluate tests
|
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? |
|
Hi @ekwoka, thanks for the review, yes, you got it right. The issue is subscribers that return their teardown (Livewire's <div x-init="$wire.$watch('foo', cb)"></div> <!-- teardown called instantly, unsubscribed -->I also made Alpine's 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, 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 3. Frozen functions const fn = Object.freeze(() => {})
fn[SKIP_EVALUATION] = true // throws in strict modeMinor, 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 My goal was to propose this with as few quirks to document as possible, so it's easier to accept. If you think |
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(...)"orx-init="unwatch = $watch(...)"would call it right away. So$watchreturns nothing, and a watcher can't be stopped from a template.Livewire has the same problem:
$wire.$watch,$wire.$hookand$wire.$intercept*return a teardown thatx-initcalls right away, while$wire.$onreturns nothing.The solution
Alpine.skipAutoEvaluate(func)returnsfuncas is. Expressions already running when it's called won't call it, later ones will.$watchnow returns its unwatch through it:x-init="$watch(...)"keeps watching,@click="unwatch"stops it, and so doesunwatch()from JavaScript.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:No "current evaluation" to track, so
awaitisn't a problem. The function isn't mutated or retained, andrunIfTypeOfFunction()callers are unchanged (startedAtis an optional last parameter). +82 bytes gzipped.Trade-offs
nextStructuralEffectOrderinreactivity.jsandmarkerDispenserinlifecycle.js: there's no safe moment while async expressions still compare against it.await, so it won't call a function skipped meanwhile, even one it reads afterwards. Calling it explicitly,unwatch(), isn't affected:Alternatives considered
dontAutoEvaluateFunctions(): only covers evaluations run inside its callback, not the one already calling the subscriber, nor past anawait.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.await, and leaks when a promise never settles.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 inAlpine.skipAutoEvaluate(). They would all return a teardown, and calling any of them from an expression, likex-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 watchingin 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.