Skip to content

fix: $.contextMenu('update') threw when a build menu had not been shown - #800

Merged
bbrala merged 3 commits into
masterfrom
fix/issue-740-update-throws
Jul 29, 2026
Merged

fix: $.contextMenu('update') threw when a build menu had not been shown#800
bbrala merged 3 commits into
masterfrom
fix/issue-740-update-throws

Conversation

@bbrala

@bbrala bbrala commented Jul 29, 2026

Copy link
Copy Markdown
Member

Problem

$.contextMenu('update') walks every registered menu and hands each one to op.update(), which immediately dereferences opt.$menu. A build menu only gets its $menu the first time it is actually shown, so as soon as a page has any build menu registered, a plain $.contextMenu('update') throws:

TypeError: Cannot read properties of undefined (reading '0')

The same happens for a registration that lingered as null after a failed destroy.

That is what the reporter of #740 ran into: they called $.contextMenu('update') from an events.show handler in order to refresh an item's disabled state, and the menu blew up on open. The stack in the issue is from the abandoned 3.x branch, but master has the equivalent defect, reproduced here as a unit test.

While confirming this, a second problem in the same case 'update': block turned up: the context-scoped branch called op.update($context), passing the context element where op.update() expects a menu's options object. It dereferences $context.$menu, which is always undefined, so $.contextMenu('update', {context: ...}) could never do anything but throw.

Fix

  • op.update() returns early for a registration without a menu element, instead of throwing.
  • The context-scoped branch now resolves the menus registered against that context and updates those.

Note on the reporter's actual question

They asked whether calling update() from events.show is the right way to toggle an entry's enabled state per open. On 2.x it is not needed at all: op.show() already runs op.update() on every open, so a function-based item.disabled is re-evaluated each time the menu is shown. Added a sentence to the disabled docs saying so, plus a regression test covering it. The update() crash is still a real bug worth fixing, since update() is a documented public operation.

Tests

test/unit/issue-740-update.test.js:

  • update() from events.show does not throw and applies the disabled function
  • update() does not throw when a build menu has never been shown (fails on master)
  • update() from the events.show of a build menu does not throw (fails on master)
  • update() scoped to a context updates that context's menu (fails on master)
  • a disabled function is re-evaluated on every open without calling update()

npm test (47 tests) and the Playwright acceptance suite (22 tests) are green.

Closes #740

$.contextMenu('update') walks every registered menu and hands each one to
op.update(), which immediately dereferences opt.$menu. A `build` menu only
gets its $menu the first time it is actually shown, so any page that has a
build menu registered (or a registration that lingered as null after a
failed destroy) made a plain $.contextMenu('update') throw a TypeError.
This is what the reporter hit when calling update() from an events.show
handler.

op.update() now bails out for a registration without a menu element, and
the context-scoped branch of the 'update' operation resolves the menus
registered against that context instead of passing the context element
itself, which could never work since op.update() expects a menu's options
object.

Also document that a function-based `disabled` is re-evaluated on every
open, so calling update() from events.show is not needed for that.

Closes #740

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d9a4fadd4c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/jquery.contextMenu.js
Comment on lines +1921 to +1922
if (!opt || !opt.$menu || !opt.$menu.length) {
return false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update the active instance of build menus

When a build menu is already open and its dynamic state changes, $.contextMenu('update') still cannot refresh it. The options object stored in menus is the original registration and never receives $menu; handle.contextmenu creates a separate cloned options object for each invocation and stores that on the trigger. Consequently this guard also returns for an active build menu, silently leaving its labels, visibility, and disabled state stale rather than merely skipping an unshown menu.

Useful? React with 👍 / 👎.

Comment thread src/jquery.contextMenu.js Outdated
if (_hasContext && (!menus[menu] || menus[menu].context !== o.context)) {
continue;
}
op.update(menus[menu]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bind scoped updates to the menu trigger

When a custom-context menu has been opened and $.contextMenu('update', {context: ...}) is called, invoking op.update as a method makes its this value the internal op object. op.update forwards that value as the this context of function-based disabled and visible options, although those callbacks are documented to run against the triggering element. Thus callbacks that inspect this produce the wrong state on the newly repaired scoped-update path; invoke the update with the menu's active $trigger instead.

Useful? React with 👍 / 👎.

… to the trigger

Two follow-ups from review.

A `build` menu is rebuilt into a fresh options object on every invocation,
and only that object gets the $menu. The registration kept in `menus` never
does, so $.contextMenu('update') could not refresh an open build menu at
all, it just skipped it. Track the built options object per namespace and
update that one when it exists.

op.update() forwards its `this` to the function-based `disabled`, `visible`,
`name` and `icon` options, which are documented to run against the trigger
element. Calling it as op.update(...) from the 'update' operation passed the
internal `op` object instead, so those callbacks saw the wrong `this`. Bind
to the menu's active $trigger.
@bbrala

bbrala commented Jul 29, 2026

Copy link
Copy Markdown
Member Author

Thanks, both Codex points were legitimate and are addressed in 9420a73.

"Update the active instance of build menus" — correct, and the more important of the two. A build menu is rebuilt into a fresh options object on every invocation (handle.contextmenu reassigns e.data), and only that object ever gets a $menu. The registration kept in menus never does, so my guard turned the previous crash into a silent no-op rather than actually making update() work for build menus. Now the built options object is tracked per namespace in a builtMenus map, and the update operation refreshes that one when it exists. The entries need no explicit expiry, op.hide() already empties a build menu's options object when it hides, so the $menu guard picks it up from there. They are removed alongside their menus entry in every destroy path.

"Bind scoped updates to the menu trigger" — also correct, and it was not limited to the scoped path: the pre-existing op.update(menus[menu]) in the non-context branch had the same problem, so function-based disabled, visible, name and icon callbacks have been receiving the internal op object as this from $.contextMenu('update') all along, instead of the trigger element the docs promise. Now invoked as op.update.call(target.$trigger || $(), target). The empty-jQuery fallback keeps a this.data(...) / this.hasClass(...) in a user callback harmless for a registration that has never been shown.

Two regression tests added, both verified failing without the follow-up commit:

  • update() refreshes an open build menu, not just static ones
  • update() runs function-based options against the trigger element (asserts this.jquery and this.is('.trigger'), so it fails on the old op-as-this behaviour rather than only on the resulting class)

Nothing was deliberately left unaddressed. npm test (49) and the Playwright suite (22) are green locally.

The promise-resolution path called op.update() as a plain function, so
function-based disabled/visible/name/icon options received the internal
`op` object as `this` instead of the trigger element the docs promise.
Same defect the previous commit fixed for $.contextMenu('update'), one
call site further along.

Also corrects the builtMenus comment: entries are removed on destroy,
not on hide.
@bbrala

bbrala commented Jul 29, 2026

Copy link
Copy Markdown
Member Author

👍

@bbrala
bbrala merged commit b01bfff into master Jul 29, 2026
9 checks 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.

Disabling an entry gives " Cannot read property 'data' of null"

1 participant