Repository: node-red/node-red (js)

Issue:
Problem with context variables
### Current Behavior

When changing project the global context variables are kept for the new project.

### Expected Behavior

Delete the context variables from another project.

### Steps To Reproduce

1. Create a project.
2. Define global context variables.
3. Create another project.
4. Update the global context.

The context variables of the previous project are maintained.

### Example flow

```
paste your flow here
```


### Environment

- Node-RED version: 2.1.3
- Node.js version: v12.22.6
- npm version: 6.14.15
- Platform/OS: Rasperry Pi/Raspbian
- Browser: Chrome


Pull request description:
Clear context contents when switching projects
- [x] New Feature

Whilst originally treated as a bug fix, the ongoing conversation in #3240 has made the valid point that clearing context across project switches is not always desirable.

This needs to be an optional behaviour.


Patch:
diff --git a/packages/node_modules/@node-red/editor-api/lib/editor/projects.js b/packages/node_modules/@node-red/editor-api/lib/editor/projects.js
index 02dc58c58..ad505a46e 100644
--- a/packages/node_modules/@node-red/editor-api/lib/editor/projects.js
+++ b/packages/node_modules/@node-red/editor-api/lib/editor/projects.js
@@ -122,6 +122,7 @@ module.exports = {
             }
 
             if (req.body.active) {
+                opts.clearContext = req.body.hasOwnProperty('clearContext')?req.body.clearContext:true
                 runtimeAPI.projects.setActiveProject(opts).then(function() {
                     listProjects(req,res);
                 }).catch(function(err) {
diff --git a/packages/node_modules/@node-red/editor-client/locales/en-US/editor.json b/packages/node_modules/@node-red/editor-client/locales/en-US/editor.json
index cbc29185d..a2e6174fa 100755
--- a/packages/node_modules/@node-red/editor-client/locales/en-US/editor.json
+++ b/packages/node_modules/@node-red/editor-client/locales/en-US/editor.json
@@ -1093,7 +1093,8 @@
             "not-git": "Not a git repository",
             "no-resource": "Repository not found",
             "cant-get-ssh-key-path": "Error! Can't get selected SSH key path.",
-            "unexpected_error": "unexpected_error"
+            "unexpected_error": "unexpected_error",
+            "clearContext": "Clear context when switching projects"
         },
         "delete": {
             "confirm": "Are you sure you want to delete this project?"
diff --git a/packages/node_modules/@node-red/editor-client/src/js/ui/projects/projects.js b/packages/node_modules/@node-red/editor-client/src/js/ui/projects/projects.js
index c9143ae1c..190561e15 100755
--- a/packages/node_modules/@node-red/editor-client/src/js/ui/projects/projects.js
+++ b/packages/node_modules/@node-red/editor-client/src/js/ui/projects/projects.js
@@ -1212,6 +1212,9 @@ RED.projects = (function() {
                             }
                         }).appendTo(row);
 
+                        row = $('<div class="form-row red-ui-projects-dialog-screen-create-row red-ui-projects-dialog-screen-create-row-open"></div>').hide().appendTo(container);
+                        $('<span style="display: flex; align-items: center;"><input style="padding:0; margin: 0 5px 0 0" checked type="checkbox" id="red-ui-projects-dialog-screen-clear-context"> <label for="red-ui-projects-dialog-screen-clear-context" style="padding:0; margin: 0"> <span data-i18n="projects.create.clearContext"></span></label></span>').appendTo(row).i18n();
+
                         row = $('<div class="form-row red-ui-projects-dialog-screen-create-row red-ui-projects-dialog-screen-create-row-empty red-ui-projects-dialog-screen-create-row-clone"></div>').appendTo(container);
                         $('<label for="red-ui-projects-dialog-screen-create-project-name">'+RED._("projects.create.project-name")+'</label>').appendTo(row);
 
@@ -1501,7 +1504,8 @@ RED.projects = (function() {
                                             };
                                         }
                                     } else if (projectType === 'open') {
-                                        return switchProject(selectedProject.name,function(err,data) {
+                                        var clearContext = $("#red-ui-projects-dialog-screen-clear-context").prop("checked")
+                                        return switchProject(selectedProject.name, clearContext, function(err,data) {
                                             if (err) {
                                                 if (err.code !== 'credentials_load_failed') {
                                                     console.log(RED._("projects.create.unexpected_error"),err)
@@ -1595,7 +1599,7 @@ RED.projects = (function() {
         }
     }
 
-    function switchProject(name,done) {
+    function switchProject(name,clearContext,done) {
         RED.deploy.setDeployInflight(true);
         RED.projects.settings.switchProject(name);
         sendRequest({
@@ -1614,7 +1618,7 @@ RED.projects = (function() {
                     '*': done
                 },
             }
-        },{active:true}).then(function() {
+        },{active:true, clearContext:clearContext}).then(function() {
             dialog.dialog( "close" );
             RED.events.emit("project:change", {name:name});
         }).always(function() {
@@ -1687,7 +1691,7 @@ RED.projects = (function() {
             dialogHeight = 590 - (750 - winHeight);
         }
         $(".red-ui-projects-dialog-box").height(dialogHeight);
-        $(".red-ui-projects-dialog-project-list-inner-container").height(Math.max(500,dialogHeight) - 180);
+        $(".red-ui-projects-dialog-project-list-inner-container").height(Math.max(500,dialogHeight) - 210);
         dialog.dialog('option','title',screen.title||"");
         dialog.dialog("open");
     }
diff --git a/packages/node_modules/@node-red/runtime/lib/api/projects.js b/packages/node_modules/@node-red/runtime/lib/api/projects.js
index 14d1d0ec1..aef736637 100644
--- a/packages/node_modules/@node-red/runtime/lib/api/projects.js
+++ b/packages/node_modules/@node-red/runtime/lib/api/projects.js
@@ -99,6 +99,7 @@ var api = module.exports = {
     * @param {Object} opts
     * @param {User} opts.user - the user calling the api
     * @param {String} opts.id - the id of the project to activate
+    * @param {boolean} opts.clearContext - whether to clear context
     * @param {Object} opts.req - the request to log (optional)
     * @return {Promise<Object>} - resolves when complete
     * @memberof @node-red/runtime_projects
@@ -107,7 +108,7 @@ var api = module.exports = {
         var currentProject = runtime.storage.projects.getActiveProject(opts.user);
         runtime.log.audit({event: "projects.set",id:opts.id}, opts.req);
         if (!currentProject || opts.id !== currentProject.name) {
-            return runtime.storage.projects.setActiveProject(opts.user, opts.id);
+            return runtime.storage.projects.setActiveProject(opts.user, opts.id, opts.clearContext);
         }
     },
 
diff --git a/packages/node_modules/@node-red/runtime/lib/nodes/context/index.js b/packages/node_modules/@node-red/runtime/lib/nodes/context/index.js
index 967fae295..aff44db04 100644
--- a/packages/node_modules/@node-red/runtime/lib/nodes/context/index.js
+++ b/packages/node_modules/@node-red/runtime/lib/nodes/context/index.js
@@ -14,30 +14,30 @@
  * limitations under the License.
  **/
 
-var clone = require("clone");
-var log = require("@node-red/util").log;
-var util = require("@node-red/util").util;
-var memory = require("./memory");
+const clone = require("clone");
+const log = require("@node-red/util").log;
+const util = require("@node-red/util").util;
+const memory = require("./memory");
 
-var settings;
+let settings;
 
 // A map of scope id to context instance
-var contexts = {};
+let contexts = {};
 
 // A map of store name to instance
-var stores = {};
-var storeList = [];
-var defaultStore;
+let stores = {};
+let storeList = [];
+let defaultStore;
 
 // Whether there context storage has been configured or left as default
-var hasConfiguredStore = false;
+let hasConfiguredStore = false;
 
 // Unknown Stores
-var unknownStores = {};
+let unknownStores = {};
 
 function logUnknownStore(name) {
     if (name) {
-        var count = unknownStores[name] || 0;
+        let count = unknownStores[name] || 0;
         if (count == 0) {
             log.warn(log._("context.unknown-store", {name: name}));
             count++;
@@ -52,8 +52,8 @@ function init(_settings) {
     stores = {};
     storeList = [];
     hasConfiguredStore = false;
-    var seed = settings.functionGlobalContext || {};
-    contexts['global'] = createContext("global",seed);
+    initialiseGlobalContext();
+
     // create a default memory store - used by the unit tests that skip the full
     // `load()` initialisation sequence.
     // If the user has any stores configured, this will be disgarded
@@ -61,6 +61,11 @@ function init(_settings) {
     defaultStore = "memory";
 }
 
+function initialiseGlobalContext() {
+    const seed = settings.functionGlobalContext || {};
+    contexts['global'] = createContext("global",seed);
+}
+
 function load() {
     return new Promise(function(resolve,reject) {
         // load & init plugins in settings.contextStorage
@@ -233,12 +238,15 @@ function validateContextKey(key) {
 
 function createContext(id,seed,parent) {
     // Seed is only set for global context - sourced from functionGlobalContext
-    var scope = id;
-    var obj = seed || {};
-    var seedKeys;
-    var insertSeedValues;
+    const scope = id;
+    const obj = {};
+    let seedKeys;
+    let insertSeedValues;
     if (seed) {
         seedKeys = Object.keys(seed);
+        seedKeys.forEach(key => {
+            obj[key] = seed[key];
+        })
         insertSeedValues = function(keys,values) {
             if (!Array.isArray(keys)) {
                 if (values[0] === undefined) {
@@ -540,8 +548,28 @@ function getContext(nodeId, flowId) {
     return newContext;
 }
 
+/**
+ * Delete the context of the given node/flow/global
+ *
+ * If the user has configured a context store, this
+ * will no-op a request to delete node/flow context.
+ */
 function deleteContext(id,flowId) {
-    if(!hasConfiguredStore){
+    if (id === "global") {
+        // 1. delete global from all configured stores
+        var promises = [];
+        for(var plugin in stores){
+            if(stores.hasOwnProperty(plugin)){
+                promises.push(stores[plugin].delete('global'));
+            }
+        }
+        return Promise.all(promises).then(function() {
+            // 2. delete global context
+            delete contexts['global'];
+            // 3. reinitialise global context
+            initialiseGlobalContext();
+        })
+    } else if (!hasConfiguredStore) {
         // only delete context if there's no configured storage.
         var contextId = id;
         if (flowId) {
@@ -549,12 +577,19 @@ function deleteContext(id,flowId) {
         }
         delete contexts[contextId];
         return stores["_"].delete(contextId);
-    }else{
+    } else {
         return Promise.resolve();
     }
 }
 
+/**
+ * Delete any contexts that are no longer in use
+ * @param flowConfig object includes allNodes as object of id->node
+ *
+ * If flowConfig is undefined, all flow/node contexts will be removed
+ **/
 function clean(flowConfig) {
+    flowConfig = flowConfig || { allNodes: {} };
     var promises = [];
     for(var plugin in stores){
         if(stores.hasOwnProperty(plugin)){
@@ -572,6 +607,16 @@ function clean(flowConfig) {
     return Promise.all(promises);
 }
 
+/**
+ * Deletes all contexts, including global and reinitialises global to
+ * initial state.
+ */
+function clear() {
+    return clean().then(function() {
+        return deleteContext('global')
+    })
+}
+
 function close() {
     var promises = [];
     for(var plugin in stores){
@@ -594,5 +639,6 @@ module.exports = {
     getFlowContext:getFlowContext,
     delete: deleteContext,
     clean: clean,
+    clear: clear,
     close: close
 };
diff --git a/packages/node_modules/@node-red/runtime/lib/nodes/index.js b/packages/node_modules/@node-red/runtime/lib/nodes/index.js
index b2346176f..5b859a5f8 100644
--- a/packages/node_modules/@node-red/runtime/lib/nodes/index.js
+++ b/packages/node_modules/@node-red/runtime/lib/nodes/index.js
@@ -206,6 +206,7 @@ module.exports = {
     eachNode: flows.eachNode,
     getContext: context.get,
 
+    clearContext: context.clear,
 
     installerEnabled: registry.installerEnabled,
     installModule: installModule,
diff --git a/packages/node_modules/@node-red/runtime/lib/storage/localfilesystem/projects/index.js b/packages/node_modules/@node-red/runtime/lib/storage/localfilesystem/projects/index.js
index b98900f49..b2b8c7052 100644
--- a/packages/node_modules/@node-red/runtime/lib/storage/localfilesystem/projects/index.js
+++ b/packages/node_modules/@node-red/runtime/lib/storage/localfilesystem/projects/index.js
@@ -377,8 +377,17 @@ function getActiveProject(user) {
     return activeProject;
 }
 
-function reloadActiveProject(action) {
+function reloadActiveProject(action, clearContext) {
+    // Stop the current flows
     return runtime.nodes.stopFlows().then(function() {
+        if (clearContext) {
+            // Reset context to remove any old values
+            return runtime.nodes.clearContext()
+        } else {
+            return Promise.resolve()
+        }
+    }).then(function() {
+        // Load the new project flows and start them
         return runtime.nodes.loadFlows(true).then(function() {
             events.emit("runtime-event",{id:"project-update", payload:{ project: activeProject.name, action:action}});
         }).catch(function(err) {
@@ -387,6 +396,9 @@ function reloadActiveProject(action) {
             events.emit("runtime-event",{id:"project-update", payload:{ project: activeProject.name, action:action}});
             throw err;
         });
+    }).catch(function(err) {
+        console.log(err.stack);
+        throw err;
     });
 }
 function createProject(user, metadata) {
@@ -424,7 +436,7 @@ function createProject(user, metadata) {
         return getProject(user, metadata.name);
     })
 }
-function setActiveProject(user, projectName) {
+function setActiveProject(user, projectName, clearContext) {
     return loadProject(projectName).then(function(project) {
         var globalProjectSettings = settings.get("projects")||{};
         globalProjectSettings.activeProject = project.name;
@@ -434,7 +446,7 @@ function setActiveProject(user, projectName) {
             // console.log("Updated file targets to");
             // console.log(flowsFullPath)
             // console.log(credentialsFile)
-            return reloadActiveProject("loaded");
+            return reloadActiveProject("loaded", clearContext);
         })
     });
 }


Test patch:
diff --git a/test/unit/@node-red/runtime/lib/nodes/context/index_spec.js b/test/unit/@node-red/runtime/lib/nodes/context/index_spec.js
index 7e05eba8f..591504e55 100644
--- a/test/unit/@node-red/runtime/lib/nodes/context/index_spec.js
+++ b/test/unit/@node-red/runtime/lib/nodes/context/index_spec.js
@@ -156,6 +156,22 @@ describe('context', function() {
             });
         });
 
+        it('deletes global context',function() {
+            Context.init({functionGlobalContext: {foo:"bar"}});
+            return Context.load().then(function(){
+                var globalContextA = Context.get("global")
+
+                globalContextA.get('foo').should.eql('bar')
+                globalContextA.set("another","value");
+
+                return Context.delete("global").then(function(){
+                    var globalContextB = Context.get("global")
+                    globalContextB.get('foo').should.eql('bar')
+                    should.not.exist(globalContextB.get("another"));
+                });
+            });
+        });
+
         it('enumerates context keys - sync', function() {
             var flowContextA = Context.getFlowContext("flowA")
             var context = Context.get("1","flowA");
@@ -316,6 +332,31 @@ describe('context', function() {
             });
         });
 
+
+        describe("clear", function() {
+            it('clears all context',function() {
+                Context.init({functionGlobalContext: {foo:"bar"}});
+                return Context.load().then(function(){
+                    var globalContextA = Context.get("global")
+                    globalContextA.get('foo').should.eql('bar')
+                    globalContextA.set("another","value");
+
+                    var flowContextA = Context.getFlowContext("flowA")
+                    flowContextA.set("foo","abc");
+                    flowContextA.get("foo").should.equal("abc");
+
+                    return Context.clear().then(function(){
+                        var globalContextB = Context.getFlowContext("global")
+                        globalContextB.get('foo').should.eql('bar')
+                        should.not.exist(globalContextB.get("another"));
+
+                        flowContextA = Context.getFlowContext("flowA")
+                        should.not.exist(flowContextA.get("foo"))
+
+                    });
+                });
+            });
+        })
     });
 
     describe('external context storage',function() {