Set anonymous/dynamic functions to Menu

Summary:

Google apps script runs in a stateless environment. Anything stored in global object is not maintained across sessions. If you add something to the global object during a session, it is not available in the next session run. Use Immediately Invoked Functions or Call functions in global scope to fill the global scope(this object) before any function is actually called by the user interface.

Explanation:

The workaround mentioned by mr…@bbtv.com in comment#17 of this issue and by Tanaike in this answer both utilises filling up the global scope using Closures/Immediately Invoked Functions(IIFE).

To understand this, You need to understand when Script function names are read and loaded. The following steps happen in order:

  1. Menu Click(Button click seems to skip step 1 and 2 and therefore doesn’t seem to be interceptable)

  2. All Script in script editor is executed and list of functions names in global this is created(In this step, no function is run/called, but all of the script is fully executed). This is equivalent to loading a webpage with your script: <script>...code.gs...</script>

  3. Check if the currently invoked button’s/menu’s function name is present in global this,

  4. If present, execute the function(i.e.,calling the function, that the linked button/menu’s function name refers to). This is like adding myFunction() at the end of the already loaded script. If not found, throw Error: Script function not found

  5. Script ends. This is like closing the loaded webpage. All “state” is lost. No global scope or this is stored permanently.

When dynamically adding menu items using this[function-name], it is important to realize when you’re adding the function. If you’re adding it during onOpen, then this has those functions in global scope during onOpen execution, but it immediately lost after onOpen script execution is complete.

function onOpen(){
  this['a'] = () => 'a';
  SpreadsheetApp.getUi()
    .createMenu("Test")
    .addItem("Call function a","a")
    .addToUi()
}

This will successfully add a function to Ui menu, but notice that a is only added to global this scope during onOpen execution. This this is then lost after that execution is complete and a new this(global scope) is created, next time any function is called(Step 1 to 5 repeats). So, when a menu click occurs, step 2 creates a new this and looks for a function named a in all the script, but wouldn’t find any, because this newly created this doesn’t have a(because onOpen is declared, but not executed and therefore a is not added to this this time).

Solution:

During or before Step2, You would need to add the function to the global this:

function onOpen(){
  SpreadsheetApp.getUi()
    .createMenu("Test")
    .addItem("Call function a","a")
    .addToUi()
}
(function IIFE(){
  this['a'] = () => 'a';
})(); 

The IIFE function above intercepts Step 2 “every time”, any function is called. So, the a is always present in this at or after Step 3. In Tanaike’s solution, this is done by installFunctions() in a global scope. That function is executed every time any function is called. The same is true in case of createMenuFunctions(this); in comment#17.

Documentation excerpts:

From add-on documentation link,

Warning: When your onOpen(e) function runs, the entire script is loaded and any global statements are executed. These statements execute under the same authorization mode as onOpen(e) and fail if the mode prohibits them. This prevents onOpen(e) from running. If your published add-on fails to add its menu items, look in the browser’s JavaScript console to see if an error was thrown, then examine your script to see whether the onOpen(e) function or global variables call services that aren’t allowed in AuthMode.NONE.

Sample script:

/**Runs every time any script function is called*/
(function IIFE(scope) {
  'use strict';
  scope['options'] = ['a', 'b', 'c']; //pollute current scope
  options.forEach(
    option =>
      (scope[option] = () =>
        SpreadsheetApp.getUi().alert(`You clicked option ${option}`))
  );
})(this);//pass global scope

function onOpen() {
  const testMenu = SpreadsheetApp.getUi().createMenu('Test');
  options.forEach(option =>
    testMenu.addItem('Call function ' + option, option)
  );
  testMenu.addToUi();
}

References:

  • IIFE

  • Related answer

  • Stateless vs Stateful

Leave a Comment

tech