Problem/Motivation
Since Drupal 10.4, the contextual "Edit" (edit mode) toggle in the toolbar — the top-right button that reveals contextual links across the page — is shown on the first page load but becomes hidden on subsequent loads, even though the page still has contextual links.
Contextual links are cached in the browser's sessionStorage after the first request (Drupal.contextual.<id> entries). On later requests they are restored from that cache instead of being fetched over Ajax.
Reported downstream against Admin Toolbar (#3541815), but it reproduces with only core toolbar + contextual enabled (Admin Toolbar uninstalled), so it is a core bug.
Confirmed on Drupal 10.4.0, 10.4.6, 10.4.8, 10.6.3, 10.6.12. Not reproducible on 11.2.x (see Root cause for why).
Steps to reproduce
- Install a standard site (10.6.x), log in as a user with access contextual links + access toolbar.
- Visit a page that has contextual links (e.g. the front page /
/userwith a block placed). - First load: the Edit toggle is visible in the toolbar.
- Reload the page. The toggle gets the
hiddenclass and disappears, although the contextual links are still on the page. ClearingsessionStoragemakes it come back once, then it disappears again on the next reload.
Root cause
The edit mode toggle's visibility is driven by Drupal.contextualToolbar.StateModel: isVisible = (contextualCount > 0) (js/toolbar/models/StateModel.js), and contextualCount is only ever updated by countContextualLinks, which is bound to the contextual collection's reset remove add events. There is no initial count — contextualCount defaults to 0 and only changes when a future event fires.
Cached contextual links are added to Drupal.contextual.collection inside a window.setTimeout(...) in contextual.js, whose comment assumes the toolbar behavior has already bound its add listener. On a cached load in 10.4+, that ordering is not guaranteed: the collection can be fully populated (all add events fire) before initContextualToolbar() constructs the StateModel and its views. The model therefore never sees the add events, contextualCount stays 0, isVisible stays false, and the toggle renders hidden — despite N contextual links existing.
Proof: with the toggle wrongly hidden, Drupal.contextual.collection.length is N (e.g. 9) but Drupal.contextualToolbar.model.get('contextualCount') is 0. Firing one add on the collection (or setting the count from collection.length) immediately un-hides the toggle.
Why 11.x is unaffected
11.x removed this deprecated Backbone code and replaced it with js/toolbar/contextualToolbarModelView.js, whose constructor sets this._contextualCount = Drupal.contextual.instances.count; — i.e. it counts the links that already exist at construction time, so it never loses the race. The old Backbone StateModel still shipped in 10.4/10.5/10.6 lacks that initial count. This is a 10.x-only fix; 11.x/12.x already behave correctly via the rewrite.
Proposed resolution
Count the contextual links already present in the collection once, after the toolbar's views have been constructed (so the VisualView's change listener is bound and re-renders the DOM). In initContextualToolbar() (js/contextual.toolbar.js), after the new VisualView() / new AuralView() calls:
contextualToolbar.model.countContextualLinks(null, Drupal.contextual.collection);(Doing this inside StateModel.initialize() instead is not sufficient: the views are constructed after the model, so they miss the resulting change event and the toggle stays hidden even though the model is correct.)
Remaining tasks
- JS fix in
contextual.toolbar.js. (done) EditModeTest::testEditModeToggleVisibleAfterReload()FunctionalJavascript regression test — loads the page twice, asserts the toggle is visible / not.hiddenon the cached second load. (done)- Reviews.
Related issues
- #3203920: Replace Contextual Links BackboneJS usage with VanillaJS equivalent — the 11.x rewrite (committed to 11.x only, 2025-05-12, not backported). Its
contextualToolbarModelView.jscounts existing contextual links in the constructor, which is why 11.x is unaffected. 10.4/10.5/10.6 still run the old Backbone code, hence this issue. - #2650910: Contextual links button is always rendered even when no links are available (with warm client-side cache) — same warm-cache (sessionStorage) init code path; opposite symptom (button wrongly shown). Not a duplicate.
- Downstream report: Admin Toolbar #3541815.
User interface changes
None.
API changes
None.
Issue fork drupal-3611072
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #3
sriram_s commentedOpened MR !16330 against 10.6.x with the fix and a FunctionalJavascript regression test (EditModeTest::testEditModeToggleVisibleAfterReload).
Verified locally with an A/B/A test on Drupal 10.6.12: on a cached (sessionStorage) reload the edit mode toggle gets the `hidden` class while contextualCount is 0 despite 9 contextual links being present; with the fix the toggle stays visible (contextualCount = 9, isVisible = true). A negative check confirms the toggle still stays hidden when there are genuinely no contextual links.
Setting to Needs review for CI + reviews.
Credit: originally reported by @tichris59 (who isolated the offending sessionStorage key in the Admin Toolbar issue), with the 10.4-vs-11.x data point from @boddy.
Comment #4
smustgrave commentedThank you for reporting. We will need to merge into main first so changing NW for that.
Comment #5
sriram_s commentedThanks @smustgrave! Quick heads-up before I reshape this: I just retested on a clean Drupal 11.4.4 site locally and the bug doesn't reproduce there — the contextual edit toggle stays visible across cached (sessionStorage) reloads, the exact case that fails on 10.6.
That's because 11.x runs the vanilla-JS rewrite from #3203920 (Drupal.contextualToolbar.StateModel doesn't even exist there anymore); its contextualToolbarModelView.js counts the existing contextual links in its constructor, so the init-order race can't happen. #3203920 was committed to 11.x only and not backported.
So this is effectively a 10.x-only bug — the old Backbone StateModel still shipped in 10.4–10.6 never does that initial count. Given main is already correct, how would you like to route it? Happy to treat it as a backport-only fix on 10.6.x (the current MR !16330 already targets 10.6.x and passes CI), or whatever fits core's workflow best.