From f225cfa279b10620abfc8abb19689e8faa43f0ad Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Thu, 1 Oct 2026 17:09:36 +0200 Subject: [PATCH] fix(wrapper): resolve MenuItem and MenuSubMenuItem on Spotify 1.3.3 --- .../webpack/component-resolvers.js | 19 +++++++ .../webpack/component-resolvers.test.mts | 56 +++++++++++++++++++ .../webpack/react-components.js | 6 +- 3 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 src/jsHelper/spicetifyWrapper/webpack/component-resolvers.test.mts diff --git a/src/jsHelper/spicetifyWrapper/webpack/component-resolvers.js b/src/jsHelper/spicetifyWrapper/webpack/component-resolvers.js index 609b2a4f55..d584d73b16 100644 --- a/src/jsHelper/spicetifyWrapper/webpack/component-resolvers.js +++ b/src/jsHelper/spicetifyWrapper/webpack/component-resolvers.js @@ -57,6 +57,25 @@ export function findDropdownComponent({ modules, chunks, require }) { ); } +// From 1.3.3 the menu item and submenu item are exports that only switch +// between the v2 and legacy rows; the legacy row is no longer exported. +export function findMenuItem({ functionModules }) { + return ( + functionModules.find((f) => /forceV2LeadingIcon:\s*\w+\.forceV2LeadingIcon/.test(fnStr(f))) ?? + functionModules.find((f) => fnStr(f).includes("handleMouseEnter") && fnStr(f).includes("onClick")) + ); +} + +export function findSubMenuItem({ functionModules, chunks, require }) { + const legacy = functionModules.find((f) => fnStr(f).includes("subMenuIcon")); + if (legacy) return legacy; + + const chunk = chunks.find(([, value]) => fnStr(value).includes("data-context-menu-submenu-header")); + if (!chunk) return undefined; + const exports = getObjectValues(require(chunk[0])).filter((m) => typeof m === "function"); + return exports.length === 1 ? exports[0] : undefined; +} + export function wrapProvider(component) { if (!component) return null; return (props) => diff --git a/src/jsHelper/spicetifyWrapper/webpack/component-resolvers.test.mts b/src/jsHelper/spicetifyWrapper/webpack/component-resolvers.test.mts new file mode 100644 index 0000000000..446c218391 --- /dev/null +++ b/src/jsHelper/spicetifyWrapper/webpack/component-resolvers.test.mts @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { findMenuItem, findSubMenuItem } from "./component-resolvers.js"; + +type Props = Record; + +// Shapes taken from Spotify 1.3.3: a waveform scrubber that also mentions +// handleMouseEnter and onClick, the menu item dispatcher, and the v2 row it renders. +const waveform = ({ onTriggerPlay }: Props) => ({ handleMouseEnter: () => undefined, onClick: onTriggerPlay }); +const menuItemDispatcher = (e: Props) => ({ + textValue: e.textValue, + forceV2LeadingIcon: e.forceV2LeadingIcon, + showFullTextOnHover: e.showFullTextOnHover, +}); +const menuRowV2 = function ({ forceV2LeadingIcon: a, showFullTextOnHover: b }: Props) { + return [a, b]; +}; +const legacyMenuItem = ({ onClick }: Props) => { + const handleMouseEnter = () => undefined; + return { handleMouseEnter, onClick }; +}; + +test("MenuItem resolves to the dispatcher ahead of other handleMouseEnter components", () => { + assert.equal(findMenuItem({ functionModules: [waveform, menuRowV2, menuItemDispatcher] }), menuItemDispatcher); +}); + +test("MenuItem falls back to the exported legacy row on builds without the dispatcher", () => { + assert.equal(findMenuItem({ functionModules: [legacyMenuItem] }), legacyMenuItem); +}); + +test("MenuSubMenuItem resolves through the module that renders the submenu header", () => { + const subMenuDispatcher = (e: Props) => e; + const modules: Record = { + "11": { A: { menuItem: "menu-item" } }, + "93262": { g: subMenuDispatcher }, + }; + const chunks = [ + ["11", () => "unrelated factory"], + ["93262", () => '{"data-context-menu-submenu-header":!0}'], + ]; + const require = (id: string) => modules[id]; + + assert.equal(findSubMenuItem({ functionModules: [waveform], chunks, require }), subMenuDispatcher); +}); + +test("MenuSubMenuItem keeps the exported subMenuIcon component on older builds", () => { + const legacySubMenu = () => ({ className: "main-contextMenu-subMenuIcon" }); + assert.equal(findSubMenuItem({ functionModules: [waveform, legacySubMenu], chunks: [], require: () => undefined }), legacySubMenu); +}); + +test("MenuSubMenuItem is undefined when the header module exports more than one component", () => { + const chunks = [["1", () => "data-context-menu-submenu-header"]]; + const require = () => ({ a: () => 1, b: () => 2 }); + assert.equal(findSubMenuItem({ functionModules: [], chunks, require }), undefined); +}); diff --git a/src/jsHelper/spicetifyWrapper/webpack/react-components.js b/src/jsHelper/spicetifyWrapper/webpack/react-components.js index 5e46783803..4237a9cb93 100644 --- a/src/jsHelper/spicetifyWrapper/webpack/react-components.js +++ b/src/jsHelper/spicetifyWrapper/webpack/react-components.js @@ -1,6 +1,6 @@ import { fnStr } from "../shared/string.js"; import { findCards } from "./cards.js"; -import { findDropdownComponent, wrapProvider } from "./component-resolvers.js"; +import { findDropdownComponent, findMenuItem, findSubMenuItem, wrapProvider } from "./component-resolvers.js"; import { findMenuOverrides, findMenus } from "./menus.js"; function findNavigationComponent({ require, exportedMemoFRefs }) { @@ -36,8 +36,8 @@ export function createReactComponents({ ...Spicetify.ReactComponent, TextComponent: modules.find((m) => m?.h1 && m?.render), Menu: functionModules.find((m) => fnStr(m).includes("getInitialFocusElement") && fnStr(m).includes("children")), - MenuItem: functionModules.find((m) => fnStr(m).includes("handleMouseEnter") && fnStr(m).includes("onClick")), - MenuSubMenuItem: functionModules.find((f) => fnStr(f).includes("subMenuIcon")), + MenuItem: findMenuItem({ functionModules }), + MenuSubMenuItem: findSubMenuItem({ functionModules, chunks, require }), Slider: wrapProvider(functionModules.find((m) => fnStr(m).includes("progressBarRef"))), RemoteConfigProvider: functionModules.find((m) => fnStr(m).includes("resolveSuspense") && fnStr(m).includes("configuration")), RightClickMenu: functionModules.find(