Repository: single-spa/single-spa (language: javascript)

Issue:
Unable to unload applications from SKIP_BECAUSE_BROKEN status
**Describe the bug or question**
When an application moves into SKIP_BECAUSE_BROKEN state, for example not having the proper lifecycle methods exported, the `unloadApplication` API is unable to move the broken application back to the NOT_LOADED state. 

**To Reproduce**
I've made a demo app here using the react example with more detailed instructions in the README.md, but can also be easily reproduced by either breaking an MFE, or linking to a remote bundle that doesn't have the proper lifecycle methods.
https://github.com/Benjamin-Coleman/single-spa-unload-issue

**Expected behavior**
Calling `unloadApplication` moves the app from SKIP_BECAUSE_BROKEN back to NOT_LOADED.


Discussion / hints:
Any updates here?  We have a scenario where single-spa apps are mounted.  

Then, within a given micro-app parcels are used.

If a parcel fails to mount (i.e. Angular, first load fails to find DOM element) it throws an error.  

Everything functions ok until the main app is unmounted.  It is then placed into SKIP_BECAUSE_BROKEN due to the internal parcels that failed to mount...  And nothing we do seems to reset the state of the main app (i.e. unloadApplication does not reset the state).
yarn install fails in the single-spa-skip-root folder of the repository demonstrating the issue:

```sh
~/c/s/single-spa-skip-root (main|✚1) $ yarn
yarn install v1.22.22
[1/4] 🔍  Resolving packages...
[2/4] 🚚  Fetching packages...
info There appears to be trouble with your network connection. Retrying...
info There appears to be trouble with your network connection. Retrying...
info There appears to be trouble with your network connection. Retrying...
info There appears to be trouble with your network connection. Retrying...
error Error: getaddrinfo ENOTFOUND rsgnuget.rockstargames.com
    at GetAddrInfoReqWrap.onlookupall [as oncomplete] (node:dns:120:26)
info Visit https://yarnpkg.com/en/docs/cli/install for documentation about this command.
```
Package lock issue above but closing out and will create new working repro if issue still persists.
`SKIP_BECAUSE_BROKEN` occurs when the failure to load it is deemed user error rather than network error, so retrying to load the same thing again didn't seem like it would make sense. However, I think it should be supported, so I'll implement it into single-spa@7 beta release.
Sounds great, thanks. Any chance you want to help me cover up my mistake from 2 years ago and edit your comment above to remove the private npm registry url? Shouldn't be a big deal either way but better safe than sorry 

Fix patch:
diff --git a/.changeset/big-cameras-raise.md b/.changeset/big-cameras-raise.md
new file mode 100644
index 00000000..41ee5dad
--- /dev/null
+++ b/.changeset/big-cameras-raise.md
@@ -0,0 +1,5 @@
+---
+"single-spa": minor
+---
+
+Allow unloading applications in SKIP_BECAUSE_BROKEN status
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0a60315d..e1c06999 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -5035,7 +5035,7 @@ snapshots:
       '@types/babel__core': 7.1.14
       babel-plugin-istanbul: 6.0.0
       babel-preset-jest: 27.0.6(@babel/core@7.25.2)
-      chalk: 4.1.1
+      chalk: 4.1.2
       graceful-fs: 4.2.6
       slash: 3.0.0
     transitivePeerDependencies:
@@ -6095,7 +6095,7 @@ snapshots:
       '@jest/test-result': 27.0.6
       '@jest/types': 27.0.6
       '@types/node': 16.0.0
-      chalk: 4.1.1
+      chalk: 4.1.2
       co: 4.6.0
       dedent: 0.7.0
       expect: 27.0.6
@@ -6172,7 +6172,7 @@ snapshots:
 
   jest-diff@27.0.6:
     dependencies:
-      chalk: 4.1.1
+      chalk: 4.1.2
       diff-sequences: 27.0.6
       jest-get-type: 27.0.6
       pretty-format: 27.0.6
@@ -6184,7 +6184,7 @@ snapshots:
   jest-each@27.0.6:
     dependencies:
       '@jest/types': 27.0.6
-      chalk: 4.1.1
+      chalk: 4.1.2
       jest-get-type: 27.0.6
       jest-util: 27.0.6
       pretty-format: 27.0.6
@@ -6258,7 +6258,7 @@ snapshots:
       '@jest/test-result': 27.0.6
       '@jest/types': 27.0.6
       '@types/node': 16.0.0
-      chalk: 4.1.1
+      chalk: 4.1.2
       co: 4.6.0
       expect: 27.0.6
       is-generator-fn: 2.1.0
@@ -6280,7 +6280,7 @@ snapshots:
 
   jest-matcher-utils@27.0.6:
     dependencies:
-      chalk: 4.1.1
+      chalk: 4.1.2
       jest-diff: 27.0.6
       jest-get-type: 27.0.6
       pretty-format: 27.0.6
diff --git a/spec/apps/skip-because-broken-unload/skip-because-broken-unload.app.ts b/spec/apps/skip-because-broken-unload/skip-because-broken-unload.app.ts
new file mode 100644
index 00000000..c1804045
--- /dev/null
+++ b/spec/apps/skip-because-broken-unload/skip-because-broken-unload.app.ts
@@ -0,0 +1,42 @@
+let numBootstrapCalls = 0,
+  numMountCalls = 0,
+  numUnloadCalls = 0,
+  numUnmountCalls = 0;
+
+export function reset() {
+  numBootstrapCalls = 0;
+  numMountCalls = 0;
+  numUnloadCalls = 0;
+  numUnmountCalls = 0;
+}
+
+export function bootstrap() {
+  numBootstrapCalls++;
+  return Promise.resolve();
+}
+// Commenting this code is what causes the SKIP_BECAUSE_BROKEN, as needed for the spec.js to function as expected
+// export function mount() {
+//   numMountCalls++;
+//   return Promise.resolve();
+// }
+export function unmount() {
+  numUnmountCalls++;
+  return Promise.resolve();
+}
+export function unload() {
+  numUnloadCalls++;
+  return Promise.resolve();
+}
+export function getNumBootstrapCalls() {
+  return numBootstrapCalls;
+}
+export function getNumMountCalls() {
+  return numMountCalls;
+}
+export function getNumUnloadCalls() {
+  return numUnloadCalls;
+}
+
+export function getNumUnmountCalls() {
+  return numUnmountCalls;
+}
diff --git a/spec/apps/skip-because-broken-unload/skip-because-broken-unload.spec.ts b/spec/apps/skip-because-broken-unload/skip-because-broken-unload.spec.ts
new file mode 100644
index 00000000..fb5a017d
--- /dev/null
+++ b/spec/apps/skip-because-broken-unload/skip-because-broken-unload.spec.ts
@@ -0,0 +1,32 @@
+import * as singleSpa from "single-spa";
+
+const activeHash = `#skip-because-broken`;
+
+describe(`happy-unload app :`, () => {
+  let myApp;
+
+  beforeAll(() => {
+    singleSpa.registerApplication(
+      "skip-because-broken-unload",
+      () => import("./skip-because-broken-unload.app"),
+      (location) => location.hash === activeHash
+    );
+    singleSpa.start();
+  });
+
+  it(`can unload an app in SKIP_BECAUSE_BROKEN`, async () => {
+    location.hash = activeHash;
+
+    await singleSpa.triggerAppChange();
+
+    expect(singleSpa.getAppStatus("skip-because-broken-unload")).toBe(
+      singleSpa.SKIP_BECAUSE_BROKEN
+    );
+
+    await singleSpa.unloadApplication("skip-because-broken-unload");
+
+    expect(singleSpa.getAppStatus("skip-because-broken-unload")).toBe(
+      singleSpa.NOT_LOADED
+    );
+  });
+});
diff --git a/src/lifecycles/unload.ts b/src/lifecycles/unload.ts
index 6e3147b5..eead17fc 100644
--- a/src/lifecycles/unload.ts
+++ b/src/lifecycles/unload.ts
@@ -46,8 +46,12 @@ export function toUnloadPromise(app: LoadedApp): Promise<LoadedApp> {
       return unloadInfo.promise!.then(() => app);
     }
 
-    if (app.status !== NOT_MOUNTED && app.status !== LOAD_ERROR) {
-      /* The app cannot be unloaded until it is unmounted.
+    if (
+      app.status !== NOT_MOUNTED &&
+      app.status !== LOAD_ERROR &&
+      app.status !== SKIP_BECAUSE_BROKEN
+    ) {
+      /* The app cannot be unloaded unless in certain statuses
        */
       return app;
     }
@@ -58,10 +62,9 @@ export function toUnloadPromise(app: LoadedApp): Promise<LoadedApp> {
       startTime = performance.now();
     }
 
-    const unloadPromise =
-      app.status === LOAD_ERROR
-        ? Promise.resolve()
-        : reasonableTime(app, "unload");
+    const unloadPromise = app.unload
+      ? reasonableTime(app, "unload")
+      : Promise.resolve();
 
     app.status = UNLOADING;
 


Test patch: