Skip to content

Conversation

@maximbelyayev
Copy link
Contributor

@maximbelyayev maximbelyayev commented Feb 3, 2026

Addresses: #4079

Problem

Currently using x-anchor with a reference to an element does not allow for reactivity / changes to the reference. The element with x-anchor is permanently anchored to the initial reference throughout its lifecycle.

In the below example, the header element with id baz is anchored to the button with id foo on init. The button with id bar has a @click directive to change the reference variable to reference itself.

<div x-data="{ reference: null }" x-init="reference = document.getElementById('foo')">
    <button id="foo">toggle foo</button>
    <button id="bar" @click="reference = $el">toggle bar</button>
    <h1 id="baz" x-anchor="reference">contents</h1>
</div>

The expected behavior is that when clicking bar button, the header element re-anchors to bar. However, the actual behavior is that the header remains anchored to foo button.

The current behavior is detrimental in certain use-cases, such as where a dropdown menu has multiple detached triggers that can each open and anchor to itself the same content.

Ideally, x-anchor should react to changes to the reference, and immediately re-calculate the CSS positioning.

Investigation

The expression is evaluated once at mount and then passed to @floating-ui/dom autoUpdate:

Alpine.directive('anchor', Alpine.skipDuringClone((el, { expression, modifiers, value }, { cleanup, evaluate }) => {
        ...
        let reference = evaluate(expression)
        if (! reference) throw 'Alpine: no element provided to x-anchor...'
        let compute = () => {
            ...
        }
        let release = autoUpdate(reference, el, () => compute())
        cleanup(() => release())
    },

This means changes to the expression are not captured and re-evaluated to form an updated reference, which can be passed to @floating-ui/dom API.

Solution

Introduce the effect API to the directive and store the previous reference, such that if the x-anchor expression changes and does not evaluate to the previous reference, we release the autoUpdate attached to the previous reference and create a new one.

Alpine.directive('anchor', Alpine.skipDuringClone((el, { expression, modifiers, value }, { cleanup, evaluate }) => {
        ...
        let previousReference = null
        let release = null;  
        let effector = effect(() => {
            let reference = evaluate(expression)
            if (! reference) throw 'Alpine: no element provided to x-anchor...'
            if (previousReference !== reference) {
                if (release) release();
                previousReference = reference
                let compute = () => {
                   ...
                }
                release = autoUpdate(reference, el, () => compute())
            }
        })
        cleanup(() => {
            effector()
            if (release) release()
        })
    },

maximbelyayev and others added 5 commits February 3, 2026 16:06
- Remove semicolons to match project style
- Fix trailing whitespace
- Add missing newline at EOF
- Simplify test to check style.left changes instead of complex bounding rect math
- Remove unused beAnchoredTo/getBoundingRect utilities from utils.js
- Remove unused haveProperty import

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@calebporzio
Copy link
Collaborator

PR Review: #4735 — feat(x-anchor): allow dynamic reference to be used with x-anchor

Type: Bug fix (labeled as feature, but this is really fixing a missing reactivity contract)
Verdict: Merge

What's happening (plain English)

  1. x-anchor="reference" evaluates reference once when the directive mounts, gets back a DOM element, and hands it to @floating-ui/dom's autoUpdate().
  2. autoUpdate() watches for scroll/resize and re-positions the anchored element — but it has no idea Alpine's reactive reference variable changed. It's permanently locked to the first element.
  3. So if you have two buttons and swap reference from one to the other, the anchored element stays stuck to the original button forever.
  4. The fix wraps the evaluate(expression) call in Alpine's effect(), which is the standard way to make directives reactive. When reference changes, the effect re-runs, tears down the old autoUpdate() listener, and creates a new one pointing at the new element.

This is the idiomatic Alpine pattern — it's how x-bind, x-text, and basically every other reactive directive works. The anchor directive was just missing it.

Other approaches considered

  1. Alpine.effect() (what this PR does) — Wraps evaluate in an effect, compares reference by identity, tears down/rebuilds autoUpdate. This is the standard Alpine pattern and the laziest correct solution.
  2. MutationObserver on the expression — Way too heavy. Alpine already has a reactive system; no need to build a parallel one.
  3. Re-evaluating on every autoUpdate tick — Would work but wasteful. The reference rarely changes; re-evaluating on every scroll/resize frame is unnecessary overhead.

The PR's approach is correct and minimal.

Changes Made

  • Removed semicolons to match project style (JS: no semicolons)
  • Fixed trailing whitespace
  • Added missing newline at EOF
  • Simplified the test: replaced complex beAnchoredTo utility (with margin box rect calculations) with a straightforward style.left change assertion. The test just needs to prove the position updates when the reference changes — no need for geometric precision.
  • Removed unused beAnchoredTo and getBoundingRect utilities from tests/cypress/utils.js
  • Removed unused haveProperty import from the test file

Test Results

  • anchor.spec.js: 2 passing (both "can anchor an element" and "can anchor to dynamic reference")
  • Verified regression: test correctly fails on main (element stays at original position) and passes with the fix
  • CI: build passing ✅

Code Review

The implementation is clean and surgical. A few notes:

  • packages/anchor/src/index.js:24-53 — The effect/teardown/rebuild pattern is correct. Identity comparison (previousReference !== reference) is the right check since DOM elements are reference types.
  • packages/anchor/src/index.js:33-34 — Pre-existing issue (not introduced by this PR): previousValue is declared inside compute() so it resets to undefined on every call, meaning the JSON dedup check on line 43 never actually prevents redundant reactive updates. This is a pre-existing bug and not this PR's problem to fix.
  • The variable name effector (line 24) is a bit unusual — stopEffect or releaseEffect would better communicate that it's a cleanup function — but this is a minor nit and not worth blocking over.

Security

No security concerns identified.

Verdict

Merge. This is the simplest correct fix for a real gap in x-anchor's reactivity. The approach is idiomatic Alpine (effect() wrapping evaluate()), the test is verified, and the change is minimal. The linked issue #4079 confirms this is a real user need (dropdown menus with multiple triggers sharing one anchored panel).


Reviewed by Claude

@calebporzio calebporzio merged commit 2cfc2ba into alpinejs:main Feb 9, 2026
1 check passed
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