This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch 
gh-readonly-queue/main/pr-8600-e7d1676e1662879eb210a21acab8d30779b01024
in repository https://gitbox.apache.org/repos/asf/texera.git

commit 957b6c965640c07ace034eb9edcae53ef974324a
Author: yangzhang75 <[email protected]>
AuthorDate: Sat Sep 19 00:26:43 2026 +0000

    fix(workflow): stop the workspace destroying itself on beforeunload (#8600)
    
    ### What changes were proposed in this PR?
    
    Switching between the operator canvas and the Form View is a full-page
    navigation, and both pages ran their entire teardown from a
    `beforeunload` host binding: clear the graph, destroy the Yjs shared
    document, disconnect the computing unit, reset the execution state and
    the results.
    
    A browser does not always discard a document it navigates away from.
    Chrome may keep it in the back/forward cache, and going back restores
    the JavaScript state exactly as it was left, re-running nothing. What
    came back was the page these methods had already gutted:
    
    - an empty canvas that answered no clicks, because the graph had been
    cleared;
    - a workflow id reset to `DEFAULT_WORKFLOW`'s, which the share dialog
    then asked the backend about;
    - an autosave still subscribed to that reset metadata, which wrote the
    default out as a brand-new blank workflow, so the workflow list grew by
    one on every trip.
    
    There was never anything to tear down there. A document that really is
    discarded takes its websockets and its graph with it, and a document
    that comes back needs them. So `beforeunload` now only saves, and the
    teardown stays in `ngOnDestroy`, which runs when the page is genuinely
    replaced. Both views change the same way.
    
    Three more defects turned this into a silent failure, and all are fixed
    here:
    
    - `WorkflowResource.getWorkflowType` read
    `workflowDao.fetchOneByWid(wid)` and dereferenced it, so an id matching
    no row answered 500 with a stack trace rather than 404.
    - The share dialog's publish-state subscription had no error handler. A
    failed request left `isPublic` null, and the template hides the
    Private/Public choice on exactly that (`*ngIf="isPublic !== null"`), so
    the dialog looked complete while offering one control fewer and the only
    way to find out was the network tab. `ngOnInit` also doubles as a
    refresh after an access change, so a failed second read used to keep the
    value from the first: the buttons stayed on screen showing a state
    nothing had confirmed, while the toast said the choice was not shown. It
    is dropped now.
    - The Share button had no gate. `ngAfterViewInit` calls
    `resetAsNewWorkflow()`, so the metadata sits at `DEFAULT_WORKFLOW` (wid
    0) on every canvas load and the real id only arrives with the workflow;
    the menu renders outside the loading spinner's container, so a click in
    that window opened a dialog that asked `GET /workflow/type/0` and came
    back without the Private/Public choice. The button is disabled until the
    id arrives and the handler refuses the same window. This is the second
    route to the symptom, and it is the gesture the issue reports.
    
    #### Before
    
    <!-- Drop the recording here: open a saved workflow, switch to the Form
    View, press the browser's Back button. The canvas comes back blank and
    unclickable, the share dialog has no Private/Public choice, and the
    workflow list has gained a blank workflow. -->
    
    #### After
    
    <!-- Drop the recording here: the same steps on this branch. The canvas
    comes back live, the share dialog keeps both buttons, and no blank
    workflow is created. -->
    
    **One behaviour changes deliberately.** The shared document is no longer
    destroyed on unload, so a co-editor is no longer told explicitly that
    you left; the room notices when the socket closes with the document.
    Destroying it on unload is what made a restored page unusable, and a
    restored page needs its room.
    
    ### Any related issues, documentation, discussions?
    
    Closes #8599.
    
    ### How was this PR tested?
    
    Reproduced first, on a local instance running plain `main`
    (`e7d1676e1`): open a saved workflow, switch to the Form View, press the
    browser's Back button, and the canvas comes back blank and unclickable,
    the share dialog has no Private/Public choice, and the workflow list has
    gained a blank workflow. With this branch deployed to the same instance,
    none of the three happens.
    
    The endpoint was checked against that instance directly: `GET
    /api/workflow/type/0` and `/type/999999` answered 500 before and answer
    404 after.
    
    Unit tests:
    
    - `workspace.component.spec` and `workflow-form.component.spec`:
    `beforeunload` saves and tears nothing down; the existing tests that
    `ngOnDestroy` still tears everything down are unchanged and still pass.
    The workspace test dispatches a real `beforeunload` DOM event rather
    than calling the handler, so the host binding being removed or miswired
    is caught too; `workflow-form.rendered.spec` already did this for the
    form.
    - `menu.component.spec`: the share dialog is not opened for wid 0 or for
    no id at all, and the Share button is disabled until the id arrives.
    - `share-access.component.spec`: a failed publish-state request reports
    itself instead of hiding the choice silently, and a failed refresh drops
    the value the previous read left behind.
    - `WorkflowResourceCoverSpec`: `getWorkflowType` reports the publish
    state, and throws `NotFoundException` for an id that matches no
    workflow.
    
    Each new guard was deletion-checked: restoring the teardown on
    `beforeunload` in either view, dropping the error handler or the
    `isPublic` reset, removing the `[disabled]` on the Share button, or
    removing the handler's own guard each turns exactly the intended tests
    red. Removing the `@HostListener` itself now turns a test red, which it
    would not have before.
    
    Full frontend suite: 224 files, 6126 passed, 1 skipped (pre-existing), 0
    failed. `WorkflowExecutionService/testOnly ...
    WorkflowResourceCoverSpec`: 16 passed. `ng build
    --configuration=production` (AOT), `eslint`, `prettier --check`,
    `scalafmtCheck` on main and test sources: all clean.
    
    **Deliberately out of scope.** Grepping for the same shape found two
    more pages that act on `beforeunload`, and neither is on the path this
    issue reports, so both are left alone and filed instead of widened into
    here: the Hub's workflow detail page clears the graph there, and
    `AgentPanelComponent` deactivates the current agent there. The app's
    other four `beforeunload` bindings only write panel geometry to
    `localStorage` and are unaffected.
    
    **The in-page button, the gesture the issue reports,** reaches the same
    symptom by the second route above rather than through the cache, which
    is why it did not reproduce for me on the Back button's steps: it needs
    the click to land before the workflow does. Found by @mengw15 in review.
    The Back-button path is the one I reproduced in a running instance; the
    button gate that closes this one is covered by tests rather than by a
    manual repro, since it is a race against the workflow fetch.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Yes. Generated-by: Claude Code (Claude Opus 5, Anthropic). Co-authored
    with Claude; the author reviewed the change line by line and reproduced
    both the failure and the fix in a running instance before submission.
    
    🤖 Generated with [Claude Code](https://claude.com/claude-code)
    
    https://claude.ai/code/session_01FVvP3ttj22f9LB4p9u2anY
    
    ---------
    
    Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
 .../dashboard/user/workflow/WorkflowResource.scala |  6 ++++
 .../user/workflow/WorkflowResourceCoverSpec.scala  | 14 +++++++++
 .../share-access/share-access.component.spec.ts    | 27 ++++++++++++++++
 .../user/share-access/share-access.component.ts    | 16 +++++++++-
 .../workspace/component/menu/menu.component.html   |  1 +
 .../component/menu/menu.component.spec.ts          | 36 ++++++++++++++++++++++
 .../app/workspace/component/menu/menu.component.ts |  9 ++++++
 .../workflow-form/workflow-form.component.spec.ts  | 16 ++++++++++
 .../workflow-form/workflow-form.component.ts       | 16 +++++++++-
 .../workflow-form/workflow-form.rendered.spec.ts   | 10 ++++--
 .../component/workspace.component.spec.ts          | 31 +++++++++++++++++++
 .../app/workspace/component/workspace.component.ts | 31 ++++++++++++++++---
 12 files changed, 204 insertions(+), 9 deletions(-)

diff --git 
a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowResource.scala
 
b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowResource.scala
index 17cd7a11fa..46fad49580 100644
--- 
a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowResource.scala
+++ 
b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowResource.scala
@@ -849,7 +849,13 @@ class WorkflowResource extends LazyLogging {
   @RolesAllowed(Array("REGULAR", "ADMIN"))
   @Path("/type/{wid}")
   def getWorkflowType(@PathParam("wid") wid: Integer): String = {
+    // fetchOneByWid answers null for an id that matches no row, and 
dereferencing that answered
+    // every such request with a 500 and a stack trace, which reads as a 
broken server rather than
+    // as a workflow that is not there.
     val workflow: Workflow = workflowDao.fetchOneByWid(wid)
+    if (workflow == null) {
+      throw new NotFoundException(s"Workflow with id $wid not found")
+    }
     if (workflow.getIsPublic) {
       "Public"
     } else {
diff --git 
a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowResourceCoverSpec.scala
 
b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowResourceCoverSpec.scala
index 3d830dfe09..fc116d9c92 100644
--- 
a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowResourceCoverSpec.scala
+++ 
b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowResourceCoverSpec.scala
@@ -249,4 +249,18 @@ class WorkflowResourceCoverSpec
     // The cover must still be present after the rejected delete.
     resource.getCoverImage(testWid, session(owner)).image shouldBe sampleImage
   }
+
+  "getWorkflowType" should "report the workflow's publish state" in {
+    resource.getWorkflowType(testWid) shouldBe "Private"
+  }
+
+  // fetchOneByWid answers null for an id that matches no row, and 
dereferencing it answered with a
+  // 500 and a stack trace. Its one caller is the share dialog, which asks 
with whatever id the page
+  // believes it is on, so a page that has lost its id turned a missing 
workflow into a server error
+  // and, having no error handler, silently dropped the Private/Public choice 
(issue #8599).
+  it should "throw NotFoundException for an id that matches no workflow" in {
+    assertThrows[NotFoundException] {
+      resource.getWorkflowType(testWid + 999999)
+    }
+  }
 }
diff --git 
a/frontend/src/app/dashboard/component/user/share-access/share-access.component.spec.ts
 
b/frontend/src/app/dashboard/component/user/share-access/share-access.component.spec.ts
index 202465b25f..94f20b2cbd 100644
--- 
a/frontend/src/app/dashboard/component/user/share-access/share-access.component.spec.ts
+++ 
b/frontend/src/app/dashboard/component/user/share-access/share-access.component.spec.ts
@@ -181,6 +181,33 @@ describe("ShareAccessComponent", () => {
       expect(c.isPublic).toBe(false);
     });
 
+    // isPublic staying null hides the Private/Public choice, which is right 
for a kind that cannot
+    // be published and wrong when the request merely failed: the dialog then 
looked complete while
+    // silently offering one control fewer, and the only way to find out was 
the network tab.
+    it("says so when the publish state cannot be read, instead of hiding the 
choice silently", () => {
+      workflowPersistSpy.getWorkflowIsPublished.mockReturnValue(throwError(() 
=> new Error("boom")));
+
+      const c = setupComponent({ type: "workflow", id: 9 });
+
+      expect(c.isPublic).toBeNull();
+      expect(notificationSpy.error).toHaveBeenCalled();
+    });
+
+    // ngOnInit is re-entered as a refresh after an access change, so a value 
from the previous read
+    // is still here when the second one fails. Keeping it would leave the 
buttons on screen showing
+    // a state nothing has confirmed, while the toast says the choice is not 
shown.
+    it("drops a previously read publish state when the refresh fails, rather 
than leaving it stale", () => {
+      workflowPublished = true;
+      const c = setupComponent({ type: "workflow", id: 9 });
+      expect(c.isPublic).toBe(true);
+
+      workflowPersistSpy.getWorkflowIsPublished.mockReturnValue(throwError(() 
=> new Error("boom")));
+      c.ngOnInit();
+
+      expect(c.isPublic).toBeNull();
+      expect(notificationSpy.error).toHaveBeenCalled();
+    });
+
     it("loads publish state for dataset via DatasetService.getDataset", () => {
       datasetPublished = true;
       const c = setupComponent({ type: "dataset", id: 12 });
diff --git 
a/frontend/src/app/dashboard/component/user/share-access/share-access.component.ts
 
b/frontend/src/app/dashboard/component/user/share-access/share-access.component.ts
index d5ebb4c595..b5baf5d9db 100644
--- 
a/frontend/src/app/dashboard/component/user/share-access/share-access.component.ts
+++ 
b/frontend/src/app/dashboard/component/user/share-access/share-access.component.ts
@@ -137,10 +137,24 @@ export class ShareAccessComponent implements OnInit, 
OnDestroy {
         this.owner = name;
       });
     // Stays null for kinds that cannot be published, which is what hides the 
publish buttons.
+    // A failed request leaves it null too, and the buttons are equally gone, 
so say so: without
+    // this the dialog looked complete while quietly offering one control 
fewer, and the only way
+    // to find out was the network tab.
     this.descriptor
       ?.isPublic?.(this.id)
       .pipe(untilDestroyed(this))
-      .subscribe(isPublic => (this.isPublic = isPublic));
+      .subscribe({
+        next: isPublic => (this.isPublic = isPublic),
+        error: () => {
+          // `ngOnInit` is re-entered as a refresh after an access change, so 
a value from the
+          // previous read may still be here. Drop it: keeping it would leave 
the buttons on
+          // screen, showing a state nothing has confirmed, while the toast 
says they are gone.
+          this.isPublic = null;
+          this.notificationService.error(
+            `Could not read whether this ${this.type} is public, so that 
choice is not shown.`
+          );
+        },
+      });
   }
 
   ngOnDestroy(): void {
diff --git a/frontend/src/app/workspace/component/menu/menu.component.html 
b/frontend/src/app/workspace/component/menu/menu.component.html
index fe8e50601c..0cb65293bf 100644
--- a/frontend/src/app/workspace/component/menu/menu.component.html
+++ b/frontend/src/app/workspace/component/menu/menu.component.html
@@ -420,6 +420,7 @@
         <button
           nz-button
           id="share-button"
+          [disabled]="!workflowId"
           (click)="onClickOpenShareAccess()">
           <span
             nz-icon
diff --git a/frontend/src/app/workspace/component/menu/menu.component.spec.ts 
b/frontend/src/app/workspace/component/menu/menu.component.spec.ts
index baea315819..1214f513af 100644
--- a/frontend/src/app/workspace/component/menu/menu.component.spec.ts
+++ b/frontend/src/app/workspace/component/menu/menu.component.spec.ts
@@ -906,6 +906,7 @@ describe("MenuComponent", () => {
       vi.spyOn(modalService, "create").mockReturnValue(fakeModalRef);
       const router = TestBed.inject(Router);
       const navigateSpy = vi.spyOn(router, "navigate").mockResolvedValue(true);
+      component.workflowId = 7;
 
       await component.onClickOpenShareAccess();
 
@@ -919,11 +920,30 @@ describe("MenuComponent", () => {
       vi.spyOn(modalService, "create").mockReturnValue(fakeModalRef);
       const router = TestBed.inject(Router);
       const navigateSpy = vi.spyOn(router, "navigate").mockResolvedValue(true);
+      component.workflowId = 7;
 
       await component.onClickOpenShareAccess();
 
       expect(navigateSpy).not.toHaveBeenCalled();
     });
+
+    // The canvas resets to DEFAULT_WORKFLOW (wid 0) on every load and the 
real id only arrives with
+    // the workflow, so a click landing in that window used to open a dialog 
that asked the backend
+    // about workflow 0 and came back without a Private/Public choice (issue 
#8599).
+    it.each([
+      ["the workflow has not loaded yet (wid 0)", 0],
+      ["there is no workflow id at all", undefined],
+    ])("does not open the share dialog while %s", async (_case, wid) => {
+      vi.spyOn(workflowPersistService, 
"retrieveOwners").mockReturnValue(of([]));
+      const createSpy = vi
+        .spyOn(modalService, "create")
+        .mockReturnValue({ afterClose: of(undefined) } as unknown as 
NzModalRef);
+      component.workflowId = wid;
+
+      await component.onClickOpenShareAccess();
+
+      expect(createSpy).not.toHaveBeenCalled();
+    });
   });
 
   it("onClickCreateNewWorkflow resets the graph and navigates back to root", 
() => {
@@ -1445,6 +1465,22 @@ describe("MenuComponent", () => {
       vi.restoreAllMocks();
     });
 
+    // Until the workflow arrives the id is DEFAULT_WORKFLOW's, and the dialog 
opened on it asks the
+    // backend about workflow 0 and comes back without a Private/Public choice 
(issue #8599).
+    it("disables the Share button until the workflow id arrives", () => {
+      component.workflowId = undefined;
+      fixture.detectChanges();
+      expect(q("#share-button").nativeElement.disabled).toBe(true);
+
+      component.workflowId = 0;
+      fixture.detectChanges();
+      expect(q("#share-button").nativeElement.disabled).toBe(true);
+
+      component.workflowId = 7;
+      fixture.detectChanges();
+      expect(q("#share-button").nativeElement.disabled).toBe(false);
+    });
+
     describe("view switch", () => {
       const flag = (formViewEnabled: boolean) =>
         (TestBed.inject(GuiConfigService) as unknown as 
MockGuiConfigService).setConfig({ formViewEnabled });
diff --git a/frontend/src/app/workspace/component/menu/menu.component.ts 
b/frontend/src/app/workspace/component/menu/menu.component.ts
index 2ad2572ddd..cc1fa2c727 100644
--- a/frontend/src/app/workspace/component/menu/menu.component.ts
+++ b/frontend/src/app/workspace/component/menu/menu.component.ts
@@ -344,7 +344,16 @@ export class MenuComponent implements OnInit, OnDestroy {
     });
   }
 
+  /**
+   * The workflow id only arrives with the workflow: the canvas resets to 
`DEFAULT_WORKFLOW` (wid 0)
+   * on every load, so until the fetch lands there is nothing to share. 
Opening the dialog in that
+   * window asked the backend about workflow 0 and came back without a 
Private/Public choice, which
+   * is the gesture issue #8599 reports. The button is disabled for the same 
window.
+   */
   public async onClickOpenShareAccess(): Promise<void> {
+    if (!this.workflowId) {
+      return;
+    }
     const modalRef = this.modalService.create({
       nzContent: ShareAccessComponent,
       nzData: {
diff --git 
a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.spec.ts
 
b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.spec.ts
index 0c3e5e1f8a..18088fdaf7 100644
--- 
a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.spec.ts
+++ 
b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.spec.ts
@@ -256,6 +256,22 @@ describe("WorkflowFormComponent", () => {
       expect(h.workflowConsoleService.clearConsoleMessages).toHaveBeenCalled();
       expect(h.workflowResultService.clearResults).toHaveBeenCalled();
     });
+
+    // The canvas switch is a full-page navigation, and the browser may keep 
this document in its
+    // back/forward cache. Coming back restores the JavaScript state as it was 
left and re-runs
+    // nothing, so anything torn down on the way out would stay torn down on a 
page that still
+    // looks live (issue #8599).
+    it("tears nothing down on beforeunload, so a page restored from the cache 
still works", () => {
+      build(formViewWorkflow).ngOnInit();
+
+      component.onBeforeUnload();
+
+      expect(workflowActionService.clearWorkflow).not.toHaveBeenCalled();
+      expect(h.computingUnitStatusService.disconnect).not.toHaveBeenCalled();
+      
expect(h.executeWorkflowService.resetExecutionAndWorkers).not.toHaveBeenCalled();
+      
expect(h.workflowConsoleService.clearConsoleMessages).not.toHaveBeenCalled();
+      expect(h.workflowResultService.clearResults).not.toHaveBeenCalled();
+    });
   });
 
   describe("title bar and saving", () => {
diff --git 
a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.ts 
b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.ts
index 0c3c182b12..95b58b0b8d 100644
--- 
a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.ts
+++ 
b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.ts
@@ -1977,13 +1977,27 @@ export class WorkflowFormComponent implements OnInit, 
OnDestroy {
     }
   }
 
+  /**
+   * The browser is leaving this document: save, and change nothing else.
+   *
+   * The canvas switch is a full-page navigation, and the browser may keep 
this document in its
+   * back/forward cache rather than discarding it. Coming back restores the 
JavaScript state as it
+   * was left, and nothing re-runs, so anything torn down here would stay torn 
down on a page that
+   * looks live. A document that really is discarded takes its websockets and 
its graph with it, so
+   * there is nothing to tear down on the way out either way.
+   */
+  @HostListener("window:beforeunload")
+  onBeforeUnload(): void {
+    // The queue is deliberately left open: a document restored from the cache 
goes on using it.
+    this.save();
+  }
+
   /**
    * Tear down exactly what the operator canvas tears down: both views drive 
the same
    * singleton services, so anything left bound here follows the user to the 
next page
    * (the symptom was a frozen canvas after a visit -- the old shared model 
still attached).
    * On the way out, save once more so a last edit is not lost.
    */
-  @HostListener("window:beforeunload")
   ngOnDestroy(): void {
     this.destroyed = true;
     // The final save joins the queue behind anything still in flight, then 
the queue is closed: the
diff --git 
a/frontend/src/app/workspace/component/workflow-form/workflow-form.rendered.spec.ts
 
b/frontend/src/app/workspace/component/workflow-form/workflow-form.rendered.spec.ts
index 0cbc713d49..8dc429dc27 100644
--- 
a/frontend/src/app/workspace/component/workflow-form/workflow-form.rendered.spec.ts
+++ 
b/frontend/src/app/workspace/component/workflow-form/workflow-form.rendered.spec.ts
@@ -869,14 +869,20 @@ describe("WorkflowFormComponent (rendered template)", () 
=> {
     expect(el("texera-property-editor")!.hasAttribute("inert")).toBe(false);
   });
 
-  it("tears the workflow down when the browser unloads (the beforeunload host 
binding)", () => {
+  // Dispatching the DOM event, rather than calling the handler, is what would 
catch the host
+  // binding being removed or miswired. What it must do is save and nothing 
else: the browser may
+  // keep this document in its back/forward cache, and coming back re-runs 
nothing, so a teardown
+  // here would leave a page that looks live and is not (issue #8599).
+  it("saves and tears nothing down when the browser unloads (the beforeunload 
host binding)", () => {
     fixture.detectChanges();
     finishLoad();
     const workflowActionService: any = TestBed.inject(WorkflowActionService);
+    const save = vi.spyOn(fixture.componentInstance as any, "save");
 
     window.dispatchEvent(new Event("beforeunload"));
 
-    expect(workflowActionService.clearWorkflow).toHaveBeenCalled();
+    expect(save).toHaveBeenCalled();
+    expect(workflowActionService.clearWorkflow).not.toHaveBeenCalled();
   });
 
   // The held rebuild is drained by a real blur: a focusout bubbling up from a 
control inside the
diff --git a/frontend/src/app/workspace/component/workspace.component.spec.ts 
b/frontend/src/app/workspace/component/workspace.component.spec.ts
index 839358b854..c651f37daa 100644
--- a/frontend/src/app/workspace/component/workspace.component.spec.ts
+++ b/frontend/src/app/workspace/component/workspace.component.spec.ts
@@ -497,6 +497,37 @@ describe("WorkspaceComponent", () => {
       expect(workflowResultService.clearResults).toHaveBeenCalled();
     });
 
+    // A full-page navigation away fires beforeunload, and the browser may 
then keep this document
+    // in its back/forward cache instead of discarding it. Coming back 
restores the JavaScript
+    // state as it was left and re-runs nothing, so anything torn down here 
stays torn down: the
+    // graph came back empty, the workflow id came back as the default, and 
the still-subscribed
+    // autosave then wrote that default out as a new, blank workflow (issue 
#8599).
+    // Dispatching the DOM event, rather than calling the handler, is what 
would catch the host
+    // binding being removed or miswired.
+    it("saves on beforeunload and tears nothing down, so a page restored from 
the cache still works", async () => {
+      await createFixture();
+      fixture.detectChanges();
+
+      window.dispatchEvent(new Event("beforeunload"));
+
+      
expect(workflowPersistService.persistWorkflow).toHaveBeenCalledWith(stubWorkflow);
+      expect(workflowActionService.clearWorkflow).not.toHaveBeenCalled();
+      expect(computingUnitStatusService.disconnect).not.toHaveBeenCalled();
+      
expect(executeWorkflowService.resetExecutionAndWorkers).not.toHaveBeenCalled();
+      
expect(workflowConsoleService.clearConsoleMessages).not.toHaveBeenCalled();
+      expect(workflowResultService.clearResults).not.toHaveBeenCalled();
+    });
+
+    it("skips even the save on beforeunload when the user is not signed in", 
async () => {
+      await createFixture();
+      fixture.detectChanges();
+      userService.isLogin.mockReturnValue(false);
+
+      component.onBeforeUnload();
+
+      expect(workflowPersistService.persistWorkflow).not.toHaveBeenCalled();
+    });
+
     it("clears the workflow session state when the computing unit is switched 
in-canvas (issue #3120)", async () => {
       await createFixture();
       fixture.detectChanges();
diff --git a/frontend/src/app/workspace/component/workspace.component.ts 
b/frontend/src/app/workspace/component/workspace.component.ts
index a4ba9baa5d..f83f43eae5 100644
--- a/frontend/src/app/workspace/component/workspace.component.ts
+++ b/frontend/src/app/workspace/component/workspace.component.ts
@@ -173,13 +173,27 @@ export class WorkspaceComponent implements AfterViewInit, 
OnInit, OnDestroy {
     this.codeEditorService.vc = this.codeEditorViewRef;
   }
 
+  /**
+   * The browser is leaving this document: save the workflow, and change 
nothing else.
+   *
+   * Tearing the session down here was the cause of a page that came back 
dead. A full-page
+   * navigation away (the Form View switch is one) fires this, and the browser 
may then keep the
+   * document in its back/forward cache rather than discarding it. Coming back 
restores the
+   * JavaScript state exactly as it was left, so whatever this method had 
already destroyed stayed
+   * destroyed: an empty graph on a canvas that answered no clicks, and a 
workflow id reset to the
+   * default, which the share dialog then asked the backend about and got an 
error for. Nothing
+   * re-runs on a restore, because the component was never re-created.
+   *
+   * There is nothing to tear down on the way out anyway. A document that is 
really discarded takes
+   * its websockets and its graph with it, and a document that comes back 
needs them.
+   */
   @HostListener("window:beforeunload")
-  ngOnDestroy() {
-    if (this.userService.isLogin() && 
this.workflowPersistService.isWorkflowPersistEnabled()) {
-      const workflow = this.workflowActionService.getWorkflow();
-      
this.workflowPersistService.persistWorkflow(workflow).pipe(untilDestroyed(this)).subscribe();
-    }
+  onBeforeUnload(): void {
+    this.persistBeforeLeaving();
+  }
 
+  ngOnDestroy() {
+    this.persistBeforeLeaving();
     this.codeEditorViewRef.clear();
     this.workflowActionService.clearWorkflow();
     // Tear down the connection and all websocket-derived session state so a
@@ -188,6 +202,13 @@ export class WorkspaceComponent implements AfterViewInit, 
OnInit, OnDestroy {
     this.resetWorkflowSessionState();
   }
 
+  private persistBeforeLeaving(): void {
+    if (this.userService.isLogin() && 
this.workflowPersistService.isWorkflowPersistEnabled()) {
+      const workflow = this.workflowActionService.getWorkflow();
+      
this.workflowPersistService.persistWorkflow(workflow).pipe(untilDestroyed(this)).subscribe();
+    }
+  }
+
   /**
    * Clear websocket-derived session state (execution status, console, 
results).
    * Shared by workspace teardown and in-canvas unit switches.

Reply via email to