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

voidmatcha pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/zeppelin.git


The following commit(s) were added to refs/heads/master by this push:
     new 2897390bc4 [ZEPPELIN-6758] Fix notebook-scoped AngularObject binding 
in New UI
2897390bc4 is described below

commit 2897390bc466f40e397116c7688546796d40a3ef
Author: Minho Jang <[email protected]>
AuthorDate: Wed Nov 4 21:01:06 2026 +0900

    [ZEPPELIN-6758] Fix notebook-scoped AngularObject binding in New UI
    
    ### What is this PR for?
    This PR fixes notebook-scoped `AngularModel` values not appearing in 
`%angular` output in the Angular-based New UI.
    
    The server sends notebook-scoped updates without a `paragraphId`. The 
existing consumer only accepts updates whose `paragraphId` matches the current 
paragraph, so it discards these updates even though both paragraphs finish 
successfully.
    
    The fix:
    - Applies updates and removals according to their notebook and paragraph 
scopes.
    - Retains values received before output creation and hydrates newly created 
or recreated output contexts.
    - Preserves the original scope and interpreter group when updating an 
existing model from the UI.
    - Cleans up retained bindings when paragraphs or published views are 
removed.
    - Reapplies server values after template-local edits, even when the 
incoming value matches the cache.
    - Preserves retained values when loading revisions that do not replay the 
Angular object registry.
    - Aligns SDK scope types with server payloads and includes 
interpreter-group identity in interpreter-originated removal messages.
    
    
    ### What type of PR is it?
    Bug Fix
    
    ### What is the Jira issue?
    * https://issues.apache.org/jira/browse/ZEPPELIN-6758
    
    ### How should this be tested?
    
    From `zeppelin-web-angular`, run the affected consumer and SDK tests:
    
    ```bash
    npm run test:shell -- \
    src/app/services/ng-z.service.spec.ts \
    src/app/services/runtime-compiler.service.spec.ts \
    src/app/core/paragraph-base/paragraph-base.spec.ts \
    src/app/pages/workspace/notebook/notebook.component.spec.ts \
    src/app/pages/workspace/published/paragraph/paragraph.component.spec.ts \
    src/app/pages/workspace/share/result/result.component.spec.ts \
    projects/zeppelin-sdk/src/angular-object-values.spec.ts \
    projects/zeppelin-sdk/src/interfaces/notebook-wire-fields.spec.ts
    ```
    
    Check the SDK contracts and build the Angular application:
    
    ```bash
    npm run typecheck:sdk-contracts
    npm run build:angular -- --configuration development
    ```
    
    For the server-side removal change, run 
`NotebookServerTest#interpreterRemovalPreservesGroupAndRoutesAllScopes` using 
the project's server-test setup. It checks interpreter-group metadata and 
notebook/global removal routing.
    
    #### Manual testing
    
    Use the Angular-based New UI with a working Spark interpreter.
    
    1. Create a notebook-scoped model:
      ```scala
    %spark
    import org.apache.zeppelin.display.angular.notebookscope.AngularModel
    
    val controlValue = AngularModel("controlValue", "hello")
      ```
    2. Run a separate paragraph:
      ```html
    %angular
    <div>value={{controlValue}}</div>
      ```
      Verify that the output displays `value=hello`.
    3. Update and then remove the model from Spark:
      ```scala
    %spark
    controlValue() = "updated"
      ```
      ```scala
    %spark
    controlValue.remove()
      ```
      Verify that the output changes to `value=updated` and then clears the 
value.
    
    Scope isolation, native UI-to-server updates, output recreation, and 
navigation cases are covered by the regression tests listed above.
    
    ### Questions:
    * Does the license files need to update? No
    * Is there breaking changes for older versions? No
    * Does this needs documentation? No
    
    
    Closes #5594 from miinhho/ZEPPELIN-6758-notebook-angular-object.
    
    Signed-off-by: YONGJAE LEE <[email protected]>
---
 .../org/apache/zeppelin/socket/NotebookServer.java |   8 +-
 .../apache/zeppelin/socket/NotebookServerTest.java |  91 ++++-
 .../paragraph/angular-object-binding.spec.ts       | 178 ++++++++
 .../zeppelin-sdk/src/angular-object-values.spec.ts |  46 ++-
 .../src/interfaces/message-paragraph.interface.ts  |  16 +-
 .../src/interfaces/notebook-wire-fields.spec.ts    |   9 -
 .../projects/zeppelin-sdk/src/message.ts           |   2 +-
 .../core/paragraph-base/angular-context-manager.ts |  22 +-
 .../app/core/paragraph-base/paragraph-base.spec.ts |  25 +-
 .../src/app/core/paragraph-base/paragraph-base.ts  |  47 ++-
 .../workspace/notebook/notebook.component.spec.ts  | 130 +++++-
 .../pages/workspace/notebook/notebook.component.ts |  33 +-
 .../notebook/paragraph/paragraph.component.ts      |  14 +-
 .../paragraph/paragraph.component.spec.ts          |  94 ++++-
 .../published/paragraph/paragraph.component.ts     |  33 +-
 .../src/app/services/message.service.ts            |   2 +-
 .../src/app/services/ng-z.service.spec.ts          | 453 +++++++++++++++++++++
 .../src/app/services/ng-z.service.ts               | 277 +++++++++++--
 .../app/services/runtime-compiler.service.spec.ts  | 178 ++++++++
 .../src/app/services/runtime-compiler.service.ts   |  20 +-
 20 files changed, 1523 insertions(+), 155 deletions(-)

diff --git 
a/zeppelin-server/src/main/java/org/apache/zeppelin/socket/NotebookServer.java 
b/zeppelin-server/src/main/java/org/apache/zeppelin/socket/NotebookServer.java
index d7a853982e..d9d4e77d8e 100644
--- 
a/zeppelin-server/src/main/java/org/apache/zeppelin/socket/NotebookServer.java
+++ 
b/zeppelin-server/src/main/java/org/apache/zeppelin/socket/NotebookServer.java
@@ -153,7 +153,7 @@ public class NotebookServer implements 
AngularObjectRegistryListener,
   // Read requests whose direct reply and failures carry the request msgId 
back to the sender.
   // Broadcasts of the same ops stay uncorrelated.
   private static final Set<OP> MSG_ID_CORRELATED_READS = 
Collections.unmodifiableSet(
-      new HashSet<>(Arrays.asList(OP.GET_NOTE, OP.NOTE_REVISION)));
+      new HashSet<>(Arrays.asList(OP.GET_NOTE, OP.RELOAD_NOTE, 
OP.NOTE_REVISION)));
 
   private final ExecutorService executorService = 
Executors.newFixedThreadPool(10);
 
@@ -949,11 +949,12 @@ public class NotebookServer implements 
AngularObjectRegistryListener,
       return;
     }
     getNotebookService().getNote(noteId, true, context,
-        new WebSocketServiceCallback<Note>(conn) {
+        new WebSocketServiceCallback<Note>(conn, fromMessage.msgId) {
           @Override
           public void onSuccess(Note note, ServiceContext context) throws 
IOException {
             connectionManager.addNoteConnection(note.getId(), conn);
-            conn.send(serializeMessage(new Message(OP.NOTE).put("note", 
note)));
+            conn.send(serializeMessage(
+                new Message(OP.NOTE).withMsgId(fromMessage.msgId).put("note", 
note)));
             updateAngularObjectRegistry(conn, note);
             sendAllAngularObjects(note, context.getAutheInfo().getUser(), 
conn);
           }
@@ -2288,6 +2289,7 @@ public class NotebookServer implements 
AngularObjectRegistryListener,
         connectionManager.broadcast(noteId,
             new Message(OP.ANGULAR_OBJECT_REMOVE)
                 .put("name", angularObject.getName())
+                .put("interpreterGroupId", interpreterGroupId)
                 .put("noteId", angularObject.getNoteId())
                 .put("paragraphId", angularObject.getParagraphId()));
         break;
diff --git 
a/zeppelin-server/src/test/java/org/apache/zeppelin/socket/NotebookServerTest.java
 
b/zeppelin-server/src/test/java/org/apache/zeppelin/socket/NotebookServerTest.java
index 6aae0af52a..31880b677c 100644
--- 
a/zeppelin-server/src/test/java/org/apache/zeppelin/socket/NotebookServerTest.java
+++ 
b/zeppelin-server/src/test/java/org/apache/zeppelin/socket/NotebookServerTest.java
@@ -85,6 +85,8 @@ import org.junit.jupiter.api.AfterAll;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
 import org.mockito.ArgumentCaptor;
 import org.mockito.Mockito;
 import org.slf4j.Logger;
@@ -841,6 +843,58 @@ class NotebookServerTest extends AbstractTestRestApi {
     }
   }
 
+  @Test
+  void interpreterRemovalPreservesGroupAndRoutesAllScopes() throws Exception {
+    final String noteId = "angular-removal-note";
+    final String otherNoteId = "angular-removal-other-note";
+    final String groupId = "angular-setting:session";
+    final Notebook mockedNotebook = mock(Notebook.class, RETURNS_DEEP_STUBS);
+    when(mockedNotebook.getInterpreterSettingManager().getSettingIds())
+        .thenReturn(Collections.singletonList("angular-setting"));
+    final NoteInfo noteInfo = mock(NoteInfo.class);
+    final NoteInfo otherNoteInfo = mock(NoteInfo.class);
+    when(noteInfo.getId()).thenReturn(noteId);
+    when(otherNoteInfo.getId()).thenReturn(otherNoteId);
+    when(mockedNotebook.getNotesInfo()).thenReturn(Arrays.asList(noteInfo, 
otherNoteInfo));
+    final NotebookSocket conn = mock(NotebookSocket.class);
+    final NotebookSocket otherConn = mock(NotebookSocket.class);
+    notebookServer.getConnectionManager().noteSocketMap.put(noteId, 
Collections.singleton(conn));
+    notebookServer.getConnectionManager().noteSocketMap.put(otherNoteId, 
Collections.singleton(otherConn));
+
+    try {
+      notebookServer.setNotebook(() -> mockedNotebook);
+      for (String paragraphId : Arrays.asList("paragraph", null)) {
+        AngularObject<String> object = AngularObjectBuilder.build("value", 
"old", noteId, paragraphId);
+
+        notebookServer.onRemoveAngularObject(groupId, object);
+
+        Message expected = new Message(OP.ANGULAR_OBJECT_REMOVE)
+            .put("name", "value")
+            .put("interpreterGroupId", groupId)
+            .put("noteId", noteId)
+            .put("paragraphId", paragraphId);
+        verify(conn).send(notebookServer.serializeMessage(expected));
+      }
+      verify(otherConn, never()).send(anyString());
+      reset(conn);
+
+      AngularObject<String> globalObject = AngularObjectBuilder.build("value", 
"old", null, null);
+      notebookServer.onRemoveAngularObject(groupId, globalObject);
+
+      Message expectedGlobal = new Message(OP.ANGULAR_OBJECT_REMOVE)
+          .put("name", "value")
+          .put("interpreterGroupId", groupId)
+          .put("noteId", null)
+          .put("paragraphId", null);
+      verify(conn).send(notebookServer.serializeMessage(expectedGlobal));
+      verify(otherConn).send(notebookServer.serializeMessage(expectedGlobal));
+    } finally {
+      notebookServer.setNotebook(() -> NotebookServerTest.notebook);
+      notebookServer.getConnectionManager().noteSocketMap.remove(noteId);
+      notebookServer.getConnectionManager().noteSocketMap.remove(otherNoteId);
+    }
+  }
+
   @Test
   void testCreateNoteWithDefaultInterpreterId() throws IOException {
     // create two sockets and open it
@@ -1282,14 +1336,15 @@ class NotebookServerTest extends AbstractTestRestApi {
     }
   }
 
-  @Test
-  void getNoteReplyCarriesTheRequestMsgId() throws IOException {
+  @ParameterizedTest
+  @EnumSource(value = OP.class, names = {"GET_NOTE", "RELOAD_NOTE"})
+  void noteReadReplyCarriesTheRequestMsgId(OP op) throws IOException {
     String noteId = notebook.createNote("read-reply-msg-id", anonymous);
     try {
       NotebookSocket socket = createWebSocket();
 
       notebookServer.onMessage(socket,
-          new Message(OP.GET_NOTE).withMsgId("read-1").put("id", 
noteId).toJson());
+          new Message(op).withMsgId("read-1").put("id", noteId).toJson());
 
       Message reply = onlySent(socket, OP.NOTE);
       assertEquals("read-1", reply.msgId);
@@ -1298,13 +1353,14 @@ class NotebookServerTest extends AbstractTestRestApi {
     }
   }
 
-  @Test
-  void getNoteWithoutMsgIdKeepsTheReplyUncorrelated() throws IOException {
+  @ParameterizedTest
+  @EnumSource(value = OP.class, names = {"GET_NOTE", "RELOAD_NOTE"})
+  void noteReadWithoutMsgIdKeepsTheReplyUncorrelated(OP op) throws IOException 
{
     String noteId = notebook.createNote("read-reply-without-msg-id", 
anonymous);
     try {
       NotebookSocket socket = createWebSocket();
 
-      notebookServer.onMessage(socket, new Message(OP.GET_NOTE).put("id", 
noteId).toJson());
+      notebookServer.onMessage(socket, new Message(op).put("id", 
noteId).toJson());
 
       String reply = onlySentPayload(socket, OP.NOTE);
       assertNull(notebookServer.deserializeMessage(reply).msgId);
@@ -1314,12 +1370,13 @@ class NotebookServerTest extends AbstractTestRestApi {
     }
   }
 
-  @Test
-  void getNoteFailuresCarryTheRequestMsgId() throws IOException {
+  @ParameterizedTest
+  @EnumSource(value = OP.class, names = {"GET_NOTE", "RELOAD_NOTE"})
+  void noteReadFailuresCarryTheRequestMsgId(OP op) throws IOException {
     NotebookSocket socket = createWebSocket();
 
     notebookServer.onMessage(socket,
-        new Message(OP.GET_NOTE).withMsgId("read-missing").put("id", 
"missing-note").toJson());
+        new Message(op).withMsgId("read-missing").put("id", 
"missing-note").toJson());
 
     assertEquals("read-missing", onlySent(socket, OP.ERROR_INFO).msgId);
 
@@ -1329,10 +1386,10 @@ class NotebookServerTest extends AbstractTestRestApi {
       reset(socket);
 
       notebookServer.onMessage(socket,
-          new Message(OP.GET_NOTE).withMsgId("read-forbidden").put("id", 
noteId).toJson());
+          new Message(op).withMsgId("read-forbidden").put("id", 
noteId).toJson());
 
       assertEquals("read-forbidden", onlySent(socket, OP.AUTH_INFO).msgId);
-      assertTrue(sentOps(socket).stream().noneMatch(op -> op == OP.NOTE));
+      assertTrue(sentOps(socket).stream().noneMatch(sentOp -> sentOp == 
OP.NOTE));
     } finally {
       notebook.removeNote(noteId, anonymous);
     }
@@ -1381,6 +1438,12 @@ class NotebookServerTest extends AbstractTestRestApi {
 
     assertEquals("read-throws", onlySent(socket, OP.ERROR_INFO).msgId);
 
+    reset(socket);
+    notebookServer.onMessage(socket,
+        new Message(OP.RELOAD_NOTE).withMsgId("reload-throws").put("id", 
42).toJson());
+
+    assertEquals("reload-throws", onlySent(socket, OP.ERROR_INFO).msgId);
+
     reset(socket);
     notebookServer.onMessage(socket, new 
Message(OP.NOTE_REVISION).withMsgId("revision-throws")
         .put("noteId", 42).put("revisionId", "some-revision").toJson());
@@ -1392,13 +1455,15 @@ class NotebookServerTest extends AbstractTestRestApi {
   void unrelatedFailuresAndNoteBroadcastsStayUncorrelated() throws IOException 
{
     NotebookSocket socket = createWebSocket();
     notebookServer.onMessage(socket,
-        new Message(OP.RELOAD_NOTE).withMsgId("reload-missing").put("id", 
"missing-note").toJson());
+        new Message(OP.PARAGRAPH_CLEAR_ALL_OUTPUT)
+            .withMsgId("clear-missing").put("id", "missing-note").toJson());
 
     assertNull(onlySent(socket, OP.ERROR_INFO).msgId);
 
     reset(socket);
     notebookServer.onMessage(socket,
-        new Message(OP.RELOAD_NOTE).withMsgId("reload-throws").put("id", 
42).toJson());
+        new Message(OP.PARAGRAPH_CLEAR_ALL_OUTPUT)
+            .withMsgId("clear-throws").put("id", 42).toJson());
 
     assertNull(onlySent(socket, OP.ERROR_INFO).msgId);
 
diff --git 
a/zeppelin-web-angular/e2e/tests/notebook/paragraph/angular-object-binding.spec.ts
 
b/zeppelin-web-angular/e2e/tests/notebook/paragraph/angular-object-binding.spec.ts
new file mode 100644
index 0000000000..5bd817fc10
--- /dev/null
+++ 
b/zeppelin-web-angular/e2e/tests/notebook/paragraph/angular-object-binding.spec.ts
@@ -0,0 +1,178 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { randomUUID } from 'node:crypto';
+import { expect, test } from '@playwright/test';
+import type { Interpreter } from '../../../../src/app/interfaces/interpreter';
+import { NotebookActionBarPage } from 
'../../../models/notebook-action-bar-page';
+import { NotebookKeyboardPage } from '../../../models/notebook-keyboard-page';
+import {
+  addPageAnnotationBeforeEach,
+  performLoginIfRequired,
+  PAGES,
+  setParagraphText,
+  waitForZeppelinReady
+} from '../../../utils';
+
+test.describe('Notebook-scoped Angular objects', () => {
+  addPageAnnotationBeforeEach(PAGES.WORKSPACE.NOTEBOOK_PARAGRAPH);
+  addPageAnnotationBeforeEach(PAGES.WORKSPACE.SHARE_RESULT);
+
+  test('displays retained values, synchronizes both directions, and removes 
the binding', async ({ page }) => {
+    await page.goto('/#/');
+    await waitForZeppelinReady(page);
+    await performLoginIfRequired(page);
+    const created = await page.request.post('/api/notebook', {
+      data: {
+        notePath: `E2E_TEST_FOLDER/AngularObject_${randomUUID()}`,
+        defaultInterpreterGroup: 'python',
+        addingEmptyParagraph: false,
+        paragraphs: [{ text: '%python' }, { text: '%python' }]
+      }
+    });
+    expect(created.ok()).toBeTruthy();
+    const noteId = (await created.json()).body as string;
+
+    try {
+      const note = await page.request.get(`/api/notebook/${noteId}`);
+      expect(note.ok()).toBeTruthy();
+      const [model, output] = (await note.json()).body.paragraphs;
+      const notebook = new NotebookKeyboardPage(page);
+      const value = page.getByLabel('Notebook model', { exact: true });
+      const runPython = async (code: string, paragraphId = model.id) => {
+        await setParagraphText(page, noteId, paragraphId, `%python\n${code}`);
+        const response = await 
page.request.post(`/api/notebook/run/${noteId}/${paragraphId}`);
+        expect(response.ok()).toBeTruthy();
+        const result = (await response.json()).body;
+        expect(result.code, JSON.stringify(result)).toBe('SUCCESS');
+      };
+      const template = `<output aria-label="Notebook model">{{ notebookModel 
}}</output>
+<button type="button" (click)="z.set('notebookModel', 'from-ui', 
'${output.id}')">Update model</button>`;
+      await page.goto(`/#/notebook/${noteId}`);
+      await expect(notebook.getParagraphByIndex(1)).toBeVisible();
+
+      // Explicit note ID: the Python wrapper's default bind is not 
notebook-scoped.
+      await runPython(`z.angularBind('notebookModel', 'initial', 
'${noteId}')`);
+      await expect(value).toHaveCount(0);
+      await runPython(`print(${JSON.stringify(`%angular\n${template}`)})`, 
output.id);
+      await expect(value).toHaveText('initial');
+
+      await runPython(`z.angularBind('notebookModel', 'from-server', 
'${noteId}')`);
+      await expect(value).toHaveText('from-server');
+
+      await setParagraphText(page, noteId, model.id, 
`%python\nprint('SERVER-MODEL=' + z.angular('notebookModel'))`);
+      await page.getByRole('button', { name: 'Update model', exact: true 
}).click();
+      await expect(value).toHaveText('from-ui');
+      // Send the read through the same WebSocket as the edit to preserve 
message order.
+      await notebook.focusParagraphHost(0);
+      await notebook.pressRunParagraph();
+      await 
expect(notebook.getParagraphByIndex(0).getByText('SERVER-MODEL=from-ui', { 
exact: true })).toBeVisible();
+
+      // The Python wrapper calls a private overload; use the public Java 
context API.
+      await runPython(`z.z.angularUnbind('notebookModel')`);
+      await expect(value).toBeEmpty();
+    } finally {
+      const deleted = await page.request.delete(`/api/notebook/${noteId}`);
+      expect(deleted.ok()).toBeTruthy();
+    }
+  });
+
+  test('reconciles bindings when reloading a note after interpreter restart', 
async ({ page }) => {
+    await page.goto('/#/');
+    await waitForZeppelinReady(page);
+    await performLoginIfRequired(page);
+    const settingsResponse = await 
page.request.get('/api/interpreter/setting');
+    expect(settingsResponse.ok()).toBeTruthy();
+    const settings = (await settingsResponse.json()).body as Interpreter[];
+    const python = settings.find(setting => setting.name === 'python');
+    if (!python) {
+      throw new Error('The Python interpreter setting is required');
+    }
+
+    const name = `angular_reload_${randomUUID()}`;
+    const settingResponse = await 
page.request.post('/api/interpreter/setting', {
+      data: {
+        name,
+        group: python.group,
+        properties: python.properties,
+        dependencies: python.dependencies,
+        option: { ...python.option, isExistingProcess: false }
+      }
+    });
+    expect(settingResponse.ok()).toBeTruthy();
+    const settingId = ((await settingResponse.json()).body as Interpreter).id;
+
+    try {
+      const created = await page.request.post('/api/notebook', {
+        data: {
+          notePath: `E2E_TEST_FOLDER/AngularReload_${randomUUID()}`,
+          defaultInterpreterGroup: name,
+          addingEmptyParagraph: false,
+          paragraphs: [{ text: `%${name}` }, { text: `%${name}` }]
+        }
+      });
+      expect(created.ok()).toBeTruthy();
+      const noteId = (await created.json()).body as string;
+
+      try {
+        const note = await page.request.get(`/api/notebook/${noteId}`);
+        expect(note.ok()).toBeTruthy();
+        const [model, output] = (await note.json()).body.paragraphs;
+        const runPython = async (code: string, paragraphId = model.id) => {
+          await setParagraphText(page, noteId, paragraphId, 
`%${name}\n${code}`);
+          const response = await 
page.request.post(`/api/notebook/run/${noteId}/${paragraphId}`);
+          expect(response.ok()).toBeTruthy();
+          const result = (await response.json()).body;
+          expect(result.code, JSON.stringify(result)).toBe('SUCCESS');
+        };
+        const value = page.getByLabel('Reloaded model', { exact: true });
+        const template = `<output aria-label="Reloaded model">{{ reloadModel 
}}</output>
+<button type="button" (click)="z.set('reloadModel', 'from-ui', 
'${output.id}')">Update reloaded model</button>`;
+        const renderAngular = 
`print(${JSON.stringify(`%angular\n${template}`)})`;
+        await page.goto(`/#/notebook/${noteId}`);
+        await runPython(`z.angularBind('reloadModel', 'before-restart', 
'${noteId}')`);
+        await runPython(renderAngular, output.id);
+        await expect(value).toHaveText('before-restart');
+        // Persist the completed output without rerunning it.
+        await setParagraphText(page, noteId, output.id, 
`%${name}\n${renderAngular}`);
+
+        const restarted = await 
page.request.put(`/api/interpreter/setting/restart/${settingId}`, {
+          data: { noteId }
+        });
+        expect(restarted.ok()).toBeTruthy();
+        await runPython(`assert z.angular('reloadModel') is None`);
+        await expect(value).toHaveText('before-restart');
+
+        await new NotebookActionBarPage(page).reloadButton.click();
+        await expect(value).toHaveCount(1);
+        await expect(value).toBeEmpty();
+
+        await runPython(`z.angularBind('reloadModel', 'after-restart', 
'${noteId}')`);
+        await expect(value).toHaveText('after-restart');
+        await setParagraphText(page, noteId, model.id, 
`%${name}\nprint('RELOADED-MODEL=' + z.angular('reloadModel'))`);
+        await page.getByRole('button', { name: 'Update reloaded model', exact: 
true }).click();
+        const notebook = new NotebookKeyboardPage(page);
+        await notebook.focusParagraphHost(0);
+        await notebook.pressRunParagraph();
+        await expect(
+          notebook.getParagraphByIndex(0).getByText('RELOADED-MODEL=from-ui', 
{ exact: true })
+        ).toBeVisible();
+      } finally {
+        const deleted = await page.request.delete(`/api/notebook/${noteId}`);
+        expect(deleted.ok()).toBeTruthy();
+      }
+    } finally {
+      const deleted = await 
page.request.delete(`/api/interpreter/setting/${settingId}`);
+      expect(deleted.ok()).toBeTruthy();
+    }
+  });
+});
diff --git 
a/zeppelin-web-angular/projects/zeppelin-sdk/src/angular-object-values.spec.ts 
b/zeppelin-web-angular/projects/zeppelin-sdk/src/angular-object-values.spec.ts
index 59ac839563..7fa54c4c03 100644
--- 
a/zeppelin-web-angular/projects/zeppelin-sdk/src/angular-object-values.spec.ts
+++ 
b/zeppelin-web-angular/projects/zeppelin-sdk/src/angular-object-values.spec.ts
@@ -10,9 +10,14 @@
  * limitations under the License.
  */
 
-import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest';
+import { afterEach, assertType, describe, expect, expectTypeOf, it, vi } from 
'vitest';
 
 import { OP } from './interfaces/message-operator.interface';
+import {
+  AngularObjectRemove,
+  AngularObjectUpdate,
+  AngularObjectUpdated
+} from './interfaces/message-paragraph.interface';
 import { Message } from './message';
 
 afterEach(() => vi.restoreAllMocks());
@@ -22,6 +27,45 @@ it('accepts arbitrary values through the public Angular 
object helpers', () => {
   
expectTypeOf<Parameters<Message['angularObjectClientBind']>[2]>().toEqualTypeOf<unknown>();
 });
 
+it('accepts notebook and global wire scopes without requiring Gson-omitted 
fields', () => {
+  const updates: AngularObjectUpdate[] = [
+    {
+      noteId: 'route-note',
+      interpreterGroupId: 'group',
+      angularObject: { name: 'value', object: false, noteId: 'route-note' }
+    },
+    {
+      noteId: 'route-note',
+      paragraphId: null,
+      interpreterGroupId: 'group',
+      angularObject: { name: 'value', object: false, noteId: null, 
paragraphId: null }
+    },
+    {
+      noteId: 'route-note',
+      interpreterGroupId: 'group',
+      angularObject: { name: 'value', object: false }
+    }
+  ];
+  const removals: AngularObjectRemove[] = [
+    { noteId: 'route-note', name: 'value', interpreterGroupId: 'group' },
+    { noteId: 'route-note', paragraphId: null, name: 'value', 
interpreterGroupId: 'group' },
+    { name: 'value', interpreterGroupId: 'group' },
+    { noteId: null, name: 'value', interpreterGroupId: 'group' },
+    { ...updates[0], angularObject: updates[0].angularObject },
+    { ...updates[1], angularObject: updates[1].angularObject },
+    { ...updates[2], angularObject: updates[2].angularObject }
+  ];
+  const outgoing: AngularObjectUpdated[] = [
+    { noteId: 'route-note', name: 'value', value: false, interpreterGroupId: 
'group' },
+    { noteId: 'route-note', paragraphId: null, name: 'value', value: false, 
interpreterGroupId: 'group' }
+  ];
+
+  assertType<AngularObjectUpdate[]>(updates);
+  assertType<AngularObjectRemove[]>(removals);
+  assertType<AngularObjectUpdated[]>(outgoing);
+  
expectTypeOf<Parameters<Message['angularObjectUpdate']>[1]>().toEqualTypeOf<string
 | null | undefined>();
+});
+
 describe('Angular object send values', () => {
   it.each([false, 42, null, ['a', false], { count: 0, enabled: false }])(
     'preserves %j in update and bind payloads',
diff --git 
a/zeppelin-web-angular/projects/zeppelin-sdk/src/interfaces/message-paragraph.interface.ts
 
b/zeppelin-web-angular/projects/zeppelin-sdk/src/interfaces/message-paragraph.interface.ts
index 88a4c06f09..2b98bfc691 100644
--- 
a/zeppelin-web-angular/projects/zeppelin-sdk/src/interfaces/message-paragraph.interface.ts
+++ 
b/zeppelin-web-angular/projects/zeppelin-sdk/src/interfaces/message-paragraph.interface.ts
@@ -234,21 +234,21 @@ export interface MoveParagraph {
 
 export interface AngularObjectUpdated {
   noteId: string;
-  paragraphId: string;
+  paragraphId?: string | null;
   name: string;
   value: unknown;
   interpreterGroupId: string;
 }
 
 interface AngularObjectRemoveContext {
-  noteId: string;
-  paragraphId: string;
+  noteId?: string | null;
+  paragraphId?: string | null;
   angularObject?: {
     name: string;
     object: unknown;
     // Omitted for global (noteId) and note (paragraphId) scoped objects.
-    noteId?: string;
-    paragraphId?: string;
+    noteId?: string | null;
+    paragraphId?: string | null;
   };
   interpreterGroupId?: string;
 }
@@ -258,13 +258,13 @@ export type AngularObjectRemove = 
AngularObjectRemoveContext &
 
 export interface AngularObjectUpdate {
   noteId: string;
-  paragraphId: string;
+  paragraphId?: string | null;
   interpreterGroupId: string;
   angularObject: {
     name: string;
     object: unknown;
-    noteId: string;
-    paragraphId: string;
+    noteId?: string | null;
+    paragraphId?: string | null;
   };
 }
 
diff --git 
a/zeppelin-web-angular/projects/zeppelin-sdk/src/interfaces/notebook-wire-fields.spec.ts
 
b/zeppelin-web-angular/projects/zeppelin-sdk/src/interfaces/notebook-wire-fields.spec.ts
index f35ec59b52..55d7217ac1 100644
--- 
a/zeppelin-web-angular/projects/zeppelin-sdk/src/interfaces/notebook-wire-fields.spec.ts
+++ 
b/zeppelin-web-angular/projects/zeppelin-sdk/src/interfaces/notebook-wire-fields.spec.ts
@@ -19,15 +19,6 @@ it('separates received wire fields from backward-compatible 
import input', () =>
   
expectTypeOf<ParagraphItem>().toHaveProperty('progress').toEqualTypeOf<number>();
   
expectTypeOf<NonNullable<Note['note']>>().toHaveProperty('version').toEqualTypeOf<string
 | undefined>();
   
expectTypeOf<EditorSettingReceived['editor']>().toHaveProperty('completionKey').toEqualTypeOf<string
 | undefined>();
-  
expectTypeOf<AngularObjectRemove>().toHaveProperty('angularObject').toEqualTypeOf<
-    | {
-        name: string;
-        object: unknown;
-        noteId?: string;
-        paragraphId?: string;
-      }
-    | undefined
-  >();
   
expectTypeOf<AngularObjectRemove>().toHaveProperty('interpreterGroupId').toEqualTypeOf<string
 | undefined>();
   const legacyImportParagraph = {
     text: '%md legacy import',
diff --git a/zeppelin-web-angular/projects/zeppelin-sdk/src/message.ts 
b/zeppelin-web-angular/projects/zeppelin-sdk/src/message.ts
index 21fb29bb94..2f1ec939fa 100644
--- a/zeppelin-web-angular/projects/zeppelin-sdk/src/message.ts
+++ b/zeppelin-web-angular/projects/zeppelin-sdk/src/message.ts
@@ -321,7 +321,7 @@ export class Message {
 
   angularObjectUpdate(
     noteId: string,
-    paragraphId: string,
+    paragraphId: string | null | undefined,
     name: string,
     value: unknown,
     interpreterGroupId: string
diff --git 
a/zeppelin-web-angular/src/app/core/paragraph-base/angular-context-manager.ts 
b/zeppelin-web-angular/src/app/core/paragraph-base/angular-context-manager.ts
index 7fc1afd9cf..7122fd08d3 100644
--- 
a/zeppelin-web-angular/src/app/core/paragraph-base/angular-context-manager.ts
+++ 
b/zeppelin-web-angular/src/app/core/paragraph-base/angular-context-manager.ts
@@ -12,18 +12,32 @@
 
 import { Observable } from 'rxjs';
 
+export interface AngularObjectBinding {
+  noteId: string | null;
+  paragraphId: string | null;
+  interpreterGroupId?: string;
+}
+
 export interface AngularContext {
+  noteId: string;
   paragraphId: string;
   key: string;
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  value: any;
+  value: unknown;
   emit: boolean;
   set: boolean;
+  binding?: AngularObjectBinding;
 }
 
 export interface AngularContextManager {
-  setContextValue(key: string, value: unknown, paragraphId: string, emit?: 
boolean): void;
-  unsetContextValue(key: string, paragraphId: string, emit?: boolean): unknown;
+  registerParagraphBindings(noteId: string, paragraphId: string): void;
+  setContextValue(
+    key: string,
+    value: unknown,
+    paragraphId: string,
+    emit?: boolean,
+    binding?: AngularObjectBinding
+  ): void;
+  unsetContextValue(key: string, paragraphId: string, emit?: boolean, 
binding?: AngularObjectBinding): void;
   contextChanged(): Observable<AngularContext>;
   runParagraphAction(): Observable<string>;
 }
diff --git 
a/zeppelin-web-angular/src/app/core/paragraph-base/paragraph-base.spec.ts 
b/zeppelin-web-angular/src/app/core/paragraph-base/paragraph-base.spec.ts
index ca3ec2c922..201085ae0a 100644
--- a/zeppelin-web-angular/src/app/core/paragraph-base/paragraph-base.spec.ts
+++ b/zeppelin-web-angular/src/app/core/paragraph-base/paragraph-base.spec.ts
@@ -11,7 +11,7 @@
  */
 
 import { ChangeDetectorRef } from '@angular/core';
-import { AngularObjectRemove, DatasetType, Message, OP, ParagraphItem, 
ParagraphState } from '@zeppelin/sdk';
+import { DatasetType, Message, OP, ParagraphItem, ParagraphState } from 
'@zeppelin/sdk';
 import { EMPTY } from 'rxjs';
 import { afterEach, describe, expect, it, vi } from 'vitest';
 
@@ -22,7 +22,6 @@ class TestParagraph extends ParagraphBase {
   changeColWidth = vi.fn();
   updateParagraphResult = vi.fn();
   protected currentNoteId = 'note';
-  unsetContextValue = vi.fn();
 
   hydrate(snapshot: ParagraphItem) {
     this.setParagraphSnapshot(snapshot);
@@ -32,7 +31,7 @@ class TestParagraph extends ParagraphBase {
     super(
       { receive: () => EMPTY, receiveEnvelope: () => EMPTY } as unknown as 
Message,
       { isParagraphRunning: item => item.status === 'RUNNING', 
isEntireNoteRunning: () => false },
-      { unsetContextValue: (...args) => this.unsetContextValue(...args) } as 
AngularContextManager,
+      { registerParagraphBindings: vi.fn() } as unknown as 
AngularContextManager,
       { markForCheck: vi.fn() } as unknown as ChangeDetectorRef
     );
     this.paragraph = paragraph;
@@ -74,25 +73,6 @@ const beginOutput = (component: TestParagraph) =>
 const appendOutput = (component: TestParagraph) =>
   component.onParagraphAppendOutput({ noteId: 'note', paragraphId: 'A', index: 
0, data: 'second\n' });
 
-describe('ParagraphBase Angular object removal', () => {
-  const payloads: AngularObjectRemove[] = [
-    { noteId: 'note', paragraphId: 'A', name: 'bound' },
-    { noteId: 'note', paragraphId: 'A', angularObject: { name: 'bound', 
object: 42 }, interpreterGroupId: 'group' }
-  ];
-
-  it.each(payloads)('removes the binding for %j', data => {
-    const component = new TestParagraph(paragraph('A'));
-    component.angularObjectRemove(data);
-    expect(component.unsetContextValue).toHaveBeenCalledWith('bound', 'A', 
false);
-  });
-
-  it('does not remove a binding belonging to another paragraph', () => {
-    const component = new TestParagraph(paragraph('A'));
-    component.angularObjectRemove({ noteId: 'note', paragraphId: 'other', 
name: 'bound' });
-    expect(component.unsetContextValue).not.toHaveBeenCalled();
-  });
-});
-
 describe('ParagraphBase streaming state isolation', () => {
   it.each(['append', 'update'])('ignores %s from another note with the same 
paragraph ID', kind => {
     const component = new TestParagraph(paragraph('A'));
@@ -307,6 +287,7 @@ const createSaveTestParagraph = (text: string, dirtyText?: 
string): SaveTestPara
     { receive: () => EMPTY, receiveEnvelope: () => EMPTY } as unknown as 
Message,
     { isParagraphRunning: () => false, isEntireNoteRunning: () => false },
     {
+      registerParagraphBindings: vi.fn(),
       setContextValue: vi.fn(),
       unsetContextValue: vi.fn(),
       contextChanged: () => EMPTY,
diff --git a/zeppelin-web-angular/src/app/core/paragraph-base/paragraph-base.ts 
b/zeppelin-web-angular/src/app/core/paragraph-base/paragraph-base.ts
index 75a2a81dba..aabdd3ecb0 100644
--- a/zeppelin-web-angular/src/app/core/paragraph-base/paragraph-base.ts
+++ b/zeppelin-web-angular/src/app/core/paragraph-base/paragraph-base.ts
@@ -14,6 +14,7 @@ import { ChangeDetectorRef } from '@angular/core';
 import {
   AngularObjectRemove,
   AngularObjectUpdate,
+  getAngularObjectRemovalName,
   GraphConfig,
   Message,
   MessageReceiveDataTypeMap,
@@ -24,7 +25,6 @@ import {
   ParagraphEditorSetting,
   ParagraphSettings,
   ParagraphStates,
-  getAngularObjectRemovalName,
   isTerminalParagraphState,
   ParagraphItem,
   ParagraphIResultsMsgItem,
@@ -217,10 +217,22 @@ export abstract class ParagraphBase extends 
MessageListenersManager {
     if (!this.paragraph) {
       throw new Error('paragraph is not defined');
     }
-    if (data.paragraphId === this.paragraph.id) {
-      const { name, object } = data.angularObject;
-      this.angularContextManager.setContextValue(name, object, 
data.paragraphId, false);
+    const noteId = this.currentNoteId;
+    const object = data.angularObject;
+    const paragraphId = object.paragraphId ?? data.paragraphId ?? null;
+    if (
+      !noteId ||
+      data.noteId !== noteId ||
+      (object.noteId != null && object.noteId !== noteId) ||
+      (paragraphId !== null && paragraphId !== this.paragraph.id)
+    ) {
+      return;
     }
+    this.angularContextManager.setContextValue(object.name, object.object, 
this.paragraph.id, false, {
+      noteId: object.noteId ?? null,
+      paragraphId,
+      interpreterGroupId: data.interpreterGroupId
+    });
   }
 
   @MessageListener(OP.ANGULAR_OBJECT_REMOVE)
@@ -228,11 +240,24 @@ export abstract class ParagraphBase extends 
MessageListenersManager {
     if (!this.paragraph) {
       throw new Error('paragraph is not defined');
     }
-    if (data.paragraphId === this.paragraph.id) {
-      const name = getAngularObjectRemovalName(data);
-      if (name !== undefined) {
-        this.angularContextManager.unsetContextValue(name, data.paragraphId, 
false);
-      }
+    const noteId = this.currentNoteId;
+    const objectNoteId = data.angularObject ? (data.angularObject.noteId ?? 
null) : (data.noteId ?? null);
+    const paragraphId = data.angularObject?.paragraphId ?? data.paragraphId ?? 
null;
+    if (
+      !noteId ||
+      (data.noteId != null && data.noteId !== noteId) ||
+      (objectNoteId !== null && objectNoteId !== noteId) ||
+      (paragraphId !== null && paragraphId !== this.paragraph.id)
+    ) {
+      return;
+    }
+    const name = getAngularObjectRemovalName(data);
+    if (name !== undefined) {
+      this.angularContextManager.unsetContextValue(name, this.paragraph.id, 
false, {
+        noteId: objectNoteId,
+        paragraphId,
+        interpreterGroupId: data.interpreterGroupId
+      });
     }
   }
 
@@ -460,6 +485,10 @@ export abstract class ParagraphBase extends 
MessageListenersManager {
   }
   protected setParagraphSnapshot(paragraph: ParagraphItem | undefined): void {
     this.paragraph = paragraph;
+    const noteId = this.currentNoteId;
+    if (paragraph && noteId) {
+      this.angularContextManager.registerParagraphBindings(noteId, 
paragraph.id);
+    }
     this.results = [];
     this.configs = {};
     if (paragraph) {
diff --git 
a/zeppelin-web-angular/src/app/pages/workspace/notebook/notebook.component.spec.ts
 
b/zeppelin-web-angular/src/app/pages/workspace/notebook/notebook.component.spec.ts
index 62186de41f..1d8f07926f 100644
--- 
a/zeppelin-web-angular/src/app/pages/workspace/notebook/notebook.component.spec.ts
+++ 
b/zeppelin-web-angular/src/app/pages/workspace/notebook/notebook.component.spec.ts
@@ -20,7 +20,6 @@ import { OP, type MessageReceiveDataTypeMap, type 
WebSocketMessage } from '@zepp
 
 import type {
   MessageService,
-  NgZService,
   NoteStatusService,
   NoteVarShareService,
   ReactFeatureService,
@@ -28,6 +27,7 @@ import type {
   ThemeService,
   TicketService
 } from '@zeppelin/services';
+import { NgZService } from '../../../services/ng-z.service';
 
 vi.mock('./paragraph/paragraph.component', () => ({
   NotebookParagraphComponent: class NotebookParagraphComponent {}
@@ -39,21 +39,22 @@ const createComponent = () => {
   const messageService = {
     receive: vi.fn(() => NEVER),
     receiveEnvelope: vi.fn(() => NEVER),
-    consumeLocalAddFocusMsgId: vi.fn(() => false)
+    consumeLocalAddFocusMsgId: vi.fn(() => false),
+    getInterpreterBindings: vi.fn()
   };
 
   const router = {
     navigate: vi.fn(() => Promise.resolve(true))
   } as unknown as Router;
+  const ngZService = new NgZService();
+  const routeParams: Record<string, string> = { noteId: 'note-b' };
 
   const component = new NotebookComponent(
     messageService as unknown as MessageService,
-    {} as NgZService,
+    ngZService,
     {
       snapshot: {
-        params: {
-          noteId: 'note-b'
-        }
+        params: routeParams
       }
     } as unknown as ActivatedRoute,
     {
@@ -62,17 +63,19 @@ const createComponent = () => {
     {} as NoteStatusService,
     {} as NoteVarShareService,
     {} as TicketService,
-    {} as SecurityService,
+    { getPermissions: () => NEVER } as unknown as SecurityService,
     router,
-    {} as Title,
-    {} as ThemeService,
+    { setTitle: vi.fn() } as unknown as Title,
+    { updateMonacoTheme: vi.fn() } as unknown as ThemeService,
     {} as ReactFeatureService
   );
 
   return {
     component,
     router,
-    messageService
+    messageService,
+    ngZService,
+    routeParams
   };
 };
 
@@ -194,4 +197,111 @@ describe('NotebookComponent', () => {
 
     
expect(messageService.consumeLocalAddFocusMsgId).toHaveBeenCalledWith('local-add-msg');
   });
+
+  it('clears models missing from a reconnected NOTE snapshot without 
disconnecting existing outputs', () => {
+    const { component, ngZService } = createComponent();
+    const note = {
+      id: 'note-b',
+      name: 'Notebook',
+      config: {},
+      paragraphs: [{ id: 'A', config: {} }]
+    } as NonNullable<MessageReceiveDataTypeMap[OP.NOTE]['note']>;
+    component.note = note;
+    ngZService.registerParagraphBindings(note.id, 'A');
+    const context: Record<string, unknown> = {};
+    ngZService.bindParagraphContext('A', context);
+    const binding = { noteId: note.id, paragraphId: null, interpreterGroupId: 
'spark' };
+    ngZService.setContextValue('controlValue', 'deleted-while-disconnected', 
'A', false, binding);
+
+    component.getNote({ op: OP.NOTE, data: { note } });
+    expect(context.controlValue).toBe('deleted-while-disconnected');
+
+    component.getNote({ op: OP.NOTE, msgId: 'reload-read', data: { note } });
+
+    expect(context.controlValue).toBeUndefined();
+    ngZService.setContextValue('currentValue', 'replayed', 'A', false, 
binding);
+    expect(context.currentValue).toBe('replayed');
+    expect(context.controlValue).toBeUndefined();
+  });
+
+  it('cleans up removed paragraph state when a revision replaces the notebook 
snapshot', () => {
+    const { component, ngZService } = createComponent();
+    component.note = {
+      id: 'note-b',
+      config: {},
+      paragraphs: [{ id: 'removed', config: {} }]
+    } as NonNullable<typeof component.note>;
+    ngZService.registerParagraphBindings('note-b', 'removed');
+    ngZService.setContextValue('localValue', 'old-paragraph', 'removed');
+
+    component.getNoteRevision({
+      noteId: 'note-b',
+      revisionId: 'r1',
+      note: { ...component.note, paragraphs: [] }
+    });
+
+    ngZService.registerParagraphBindings('note-b', 'removed');
+    const context: Record<string, unknown> = {};
+    ngZService.bindParagraphContext('removed', context);
+    expect(context.localValue).toBeUndefined();
+  });
+
+  it('releases bindings outside the visible snapshot when switching a 
single-paragraph route', () => {
+    const { component, ngZService, routeParams } = createComponent();
+    const note = {
+      id: 'note-b',
+      name: 'Notebook',
+      config: {},
+      paragraphs: [
+        { id: 'A', config: {} },
+        { id: 'B', config: {} }
+      ]
+    } as NonNullable<typeof component.note>;
+    component.note = { ...note, paragraphs: [note.paragraphs[0]] };
+    ngZService.registerParagraphBindings(note.id, 'A');
+    ngZService.setContextValue('privateValue', 'previous-output', 'A');
+    routeParams.paragraphId = 'B';
+
+    component.getNote({ op: OP.NOTE, msgId: 'single-paragraph-read', data: { 
note } });
+
+    expect(component.note?.paragraphs.map(paragraph => 
paragraph.id)).toEqual(['B']);
+    const staleOutput: Record<string, unknown> = {};
+    ngZService.bindParagraphContext('A', staleOutput);
+    ngZService.setContextValue('privateValue', 'late-old-output', 'A');
+    expect(staleOutput.privateValue).toBeUndefined();
+  });
+
+  it('preserves bindings while viewing a revision and reconciles them only on 
a live read', () => {
+    const { component, ngZService } = createComponent();
+    const note = {
+      id: 'note-b',
+      name: 'Notebook',
+      config: {},
+      paragraphs: [{ id: 'A', config: {} }]
+    } as NonNullable<typeof component.note>;
+    component.note = note;
+    ngZService.registerParagraphBindings(note.id, 'A');
+    const context: Record<string, unknown> = {};
+    const release = ngZService.bindParagraphContext('A', context);
+    const binding = { noteId: note.id, paragraphId: null, interpreterGroupId: 
'spark' };
+    ngZService.setContextValue('controlValue', 'before-revision', 'A', false, 
binding);
+    ngZService.setContextValue('localValue', 'client-local', 'A');
+
+    component.getNoteRevision({ noteId: note.id, revisionId: 'r1', note });
+
+    expect(context.controlValue).toBe('before-revision');
+    expect(context.localValue).toBe('client-local');
+    release();
+    const recreatedOutput: Record<string, unknown> = {};
+    ngZService.bindParagraphContext('A', recreatedOutput);
+    expect(recreatedOutput.controlValue).toBe('before-revision');
+    expect(recreatedOutput.localValue).toBe('client-local');
+
+    component.getNote({ op: OP.NOTE, msgId: 'return-to-live', data: { note } 
});
+
+    expect(recreatedOutput.controlValue).toBeUndefined();
+    expect(recreatedOutput.localValue).toBeUndefined();
+    ngZService.setContextValue('controlValue', 'live-model', 'A', false, 
binding);
+    expect(recreatedOutput.controlValue).toBe('live-model');
+  });
 });
diff --git 
a/zeppelin-web-angular/src/app/pages/workspace/notebook/notebook.component.ts 
b/zeppelin-web-angular/src/app/pages/workspace/notebook/notebook.component.ts
index 21d761b99c..0e3cce4244 100644
--- 
a/zeppelin-web-angular/src/app/pages/workspace/notebook/notebook.component.ts
+++ 
b/zeppelin-web-angular/src/app/pages/workspace/notebook/notebook.component.ts
@@ -89,17 +89,21 @@ export class NotebookComponent extends 
MessageListenersManager implements OnInit
   useReactFooter = false;
   useReactAssistant = false;
 
-  @MessageListener(OP.NOTE)
-  getNote(data: MessageReceiveDataTypeMap[OP.NOTE]) {
-    const note = data.note;
+  @MessageEnvelopeListener(OP.NOTE)
+  getNote(message: WebSocketMessage<MessageReceiveDataTypeMap, OP.NOTE>) {
+    const note = message.data?.note;
     if (isNil(note)) {
       this.router.navigate(['/']).then();
     } else {
-      this.removeParagraphFromNgZ();
-      this.note = note;
       const { paragraphId } = this.activatedRoute.snapshot.params;
+      const nextNote = paragraphId ? this.cleanParagraphExcept(note, 
paragraphId) : note;
+      this.removeObsoleteParagraphBindings(nextNote);
+      // Read replies replay the registry; ordinary NOTE broadcasts do not.
+      if (message.msgId) {
+        this.ngZService.clearNoteContextValues(note.id);
+      }
+      this.note = nextNote;
       if (paragraphId) {
-        this.note = this.cleanParagraphExcept(this.note, paragraphId);
         this.initializeLookAndFeel(this.note);
       } else {
         this.initializeLookAndFeel(this.note);
@@ -146,6 +150,7 @@ export class NotebookComponent extends 
MessageListenersManager implements OnInit
       return;
     }
     const definedNote = this.note;
+    this.ngZService.removeParagraphBindings(data.id);
     const paragraphIndex = definedNote.paragraphs.findIndex(p => p.id === 
data.id);
     definedNote.paragraphs = definedNote.paragraphs.filter((p, index) => index 
!== paragraphIndex);
     const adjustedCursorIndex =
@@ -208,6 +213,7 @@ export class NotebookComponent extends 
MessageListenersManager implements OnInit
     if (isNil(note)) {
       this.router.navigate(['/']).then();
     } else {
+      this.removeObsoleteParagraphBindings(note);
       this.note = note;
       this.initializeLookAndFeel(this.note);
       this.cdr.markForCheck();
@@ -525,11 +531,15 @@ export class NotebookComponent extends 
MessageListenersManager implements OnInit
     });
   }
 
-  removeParagraphFromNgZ(): void {
-    if (this.note && Array.isArray(this.note.paragraphs)) {
-      this.note.paragraphs.forEach(p => {
-        this.ngZService.removeParagraph(p.id);
-      });
+  removeObsoleteParagraphBindings(nextNote?: NonNullable<Note['note']>): void {
+    if (this.note) {
+      const retainedIds =
+        nextNote?.id === this.note.id ? new Set(nextNote.paragraphs.map(p => 
p.id)) : new Set<string>();
+      for (const paragraph of this.note.paragraphs) {
+        if (!retainedIds.has(paragraph.id)) {
+          this.ngZService.removeParagraphBindings(paragraph.id);
+        }
+      }
     }
   }
 
@@ -537,6 +547,7 @@ export class NotebookComponent extends 
MessageListenersManager implements OnInit
     super.ngOnDestroy();
     this.killSaveTimer();
     this.saveNote();
+    this.removeObsoleteParagraphBindings();
     this.destroy$.next();
     this.destroy$.complete();
     this.titleService.setTitle('Zeppelin');
diff --git 
a/zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/paragraph.component.ts
 
b/zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/paragraph.component.ts
index 72c293e960..bd788f8189 100644
--- 
a/zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/paragraph.component.ts
+++ 
b/zeppelin-web-angular/src/app/pages/workspace/notebook/paragraph/paragraph.component.ts
@@ -640,9 +640,19 @@ export class NotebookParagraphComponent
       .contextChanged()
       .pipe(takeUntil(this.destroy$))
       .subscribe(change => {
-        if (change.paragraphId === this.paragraph.id && change.emit) {
+        if (change.noteId === this.note.id && change.paragraphId === 
this.paragraph.id && change.emit) {
           if (change.set) {
-            this.messageService.angularObjectClientBind(this.note.id, 
change.key, change.value, change.paragraphId);
+            if (change.binding?.interpreterGroupId !== undefined) {
+              this.messageService.angularObjectUpdate(
+                this.note.id,
+                change.binding.paragraphId,
+                change.key,
+                change.value,
+                change.binding.interpreterGroupId
+              );
+            } else {
+              this.messageService.angularObjectClientBind(this.note.id, 
change.key, change.value, change.paragraphId);
+            }
           } else {
             this.messageService.angularObjectClientUnbind(this.note.id, 
change.key, change.paragraphId);
           }
diff --git 
a/zeppelin-web-angular/src/app/pages/workspace/published/paragraph/paragraph.component.spec.ts
 
b/zeppelin-web-angular/src/app/pages/workspace/published/paragraph/paragraph.component.spec.ts
index 461c88955e..f969fdd438 100644
--- 
a/zeppelin-web-angular/src/app/pages/workspace/published/paragraph/paragraph.component.spec.ts
+++ 
b/zeppelin-web-angular/src/app/pages/workspace/published/paragraph/paragraph.component.spec.ts
@@ -12,9 +12,10 @@
 
 import { QueryList } from '@angular/core';
 import { convertToParamMap } from '@angular/router';
-import { DatasetType, Note, ParagraphItem, ParagraphState } from 
'@zeppelin/sdk';
+import { DatasetType, Note, OP, ParagraphItem, ParagraphState } from 
'@zeppelin/sdk';
 import { BehaviorSubject, EMPTY, of } from 'rxjs';
 import { afterEach, describe, expect, it, vi } from 'vitest';
+import { NgZService } from '../../../../services/ng-z.service';
 
 vi.mock('@zeppelin/services', () => ({
   MessageService: class {},
@@ -51,7 +52,7 @@ const snapshot = (noteId: string, status: ParagraphState, 
text: string): Note =>
 const components: PublishedParagraphComponent[] = [];
 afterEach(() => components.splice(0).forEach(component => 
component.ngOnDestroy()));
 
-const setup = (useReact = false) => {
+const setup = (useReact = false, ngZService = new NgZService()) => {
   const params = new BehaviorSubject({ noteId: 'note-A', paragraphId: 
'same-paragraph' });
   const component = new PublishedParagraphComponent(
     { receive: () => EMPTY, receiveEnvelope: () => EMPTY, getNote: vi.fn() } 
as never,
@@ -60,12 +61,12 @@ const setup = (useReact = false) => {
     {} as never,
     { isEnabled: () => useReact } as never,
     { isParagraphRunning: (p: ParagraphItem) => p.status === 'RUNNING' } as 
never,
-    {} as never,
+    ngZService,
     { markForCheck: vi.fn() } as never
   );
   component.notebookParagraphResultComponents = new QueryList();
   components.push(component);
-  return { component, params };
+  return { component, params, ngZService };
 };
 
 const append = (component: PublishedParagraphComponent, noteId: string, data: 
string) =>
@@ -74,43 +75,110 @@ const append = (component: PublishedParagraphComponent, 
noteId: string, data: st
 describe('Published paragraph authoritative snapshots', () => {
   it('resumes from a same-note refreshed snapshot instead of cached output', 
() => {
     const { component } = setup();
-    component.getNote(snapshot('note-A', 'RUNNING', 'old'));
+    component.getNote({ op: OP.NOTE, data: snapshot('note-A', 'RUNNING', 
'old') });
     append(component, 'note-A', ' tail');
-    component.getNote(snapshot('note-A', 'RUNNING', 'fresh'));
+    component.getNote({ op: OP.NOTE, data: snapshot('note-A', 'RUNNING', 
'fresh') });
     append(component, 'note-A', ' next');
     expect(component.results).toEqual([{ type: DatasetType.TEXT, data: 'fresh 
next' }]);
   });
 
   it('unfreezes terminal output when the reused route loads a running 
paragraph', () => {
     const { component, params } = setup();
-    component.getNote(snapshot('note-A', 'FINISHED', 'A final'));
+    component.getNote({ op: OP.NOTE, data: snapshot('note-A', 'FINISHED', 'A 
final') });
     append(component, 'note-A', ' ignored');
     params.next({ noteId: 'note-B', paragraphId: 'same-paragraph' });
-    component.getNote(snapshot('note-B', 'RUNNING', 'B first'));
+    component.getNote({ op: OP.NOTE, data: snapshot('note-B', 'RUNNING', 'B 
first') });
     append(component, 'note-B', ' second');
     expect(component.results).toEqual([{ type: DatasetType.TEXT, data: 'B 
first second' }]);
   });
 
   it('rejects stale NOTE and stream events while waiting for the new route 
snapshot', () => {
     const { component, params } = setup();
-    component.getNote(snapshot('note-A', 'RUNNING', 'A first'));
+    component.getNote({ op: OP.NOTE, data: snapshot('note-A', 'RUNNING', 'A 
first') });
     append(component, 'note-A', ' tail');
     params.next({ noteId: 'note-B', paragraphId: 'same-paragraph' });
-    component.getNote(snapshot('note-A', 'RUNNING', 'stale A'));
+    component.getNote({ op: OP.NOTE, data: snapshot('note-A', 'RUNNING', 
'stale A') });
     append(component, 'note-A', ' stale tail');
     append(component, 'note-B', ' before snapshot');
     expect(component.results).toEqual([]);
-    component.getNote(snapshot('note-B', 'RUNNING', 'B first'));
+    component.getNote({ op: OP.NOTE, data: snapshot('note-B', 'RUNNING', 'B 
first') });
     append(component, 'note-A', ' foreign');
     append(component, 'note-B', ' second');
     expect(component.results).toEqual([{ type: DatasetType.TEXT, data: 'B 
first second' }]);
   });
+
+  it('does not hydrate a removed server model when a published view is 
reopened in the same application', () => {
+    const service = new NgZService();
+    const first = setup(false, service);
+    first.component.getNote({ op: OP.NOTE, data: snapshot('note-A', 
'FINISHED', 'old output') });
+    first.component.angularObjectUpdate({
+      noteId: 'note-A',
+      paragraphId: 'same-paragraph',
+      interpreterGroupId: 'spark',
+      angularObject: {
+        name: 'controlValue',
+        object: 'deleted-while-away',
+        noteId: 'note-A',
+        paragraphId: 'same-paragraph'
+      }
+    });
+    components.pop()!.ngOnDestroy();
+
+    const second = setup(false, service);
+    second.component.getNote({ op: OP.NOTE, data: snapshot('note-A', 
'FINISHED', 'saved output') });
+    const context: Record<string, unknown> = {};
+    service.bindParagraphContext('same-paragraph', context);
+
+    expect(context.controlValue).toBeUndefined();
+  });
+
+  it('releases the previous published paragraph when the route changes within 
a notebook', () => {
+    const { component, params, ngZService } = setup();
+    component.getNote({ op: OP.NOTE, data: snapshot('note-A', 'FINISHED', 
'first output') });
+    ngZService.setContextValue('privateValue', 'previous-paragraph', 
'same-paragraph');
+    params.next({ noteId: 'note-A', paragraphId: 'next-paragraph' });
+    const next = snapshot('note-A', 'FINISHED', 'next output');
+    next.note!.paragraphs[0].id = 'next-paragraph';
+    component.getNote({ op: OP.NOTE, data: next });
+    const staleOutput: Record<string, unknown> = {};
+    ngZService.bindParagraphContext('same-paragraph', staleOutput);
+    ngZService.setContextValue('privateValue', 'late-old-output', 
'same-paragraph');
+    expect(staleOutput.privateValue).toBeUndefined();
+    const currentOutput: Record<string, unknown> = {};
+    ngZService.bindParagraphContext('next-paragraph', currentOutput);
+    ngZService.setContextValue('privateValue', 'current-paragraph', 
'next-paragraph');
+    expect(currentOutput.privateValue).toBe('current-paragraph');
+  });
+
+  it('preserves values on published NOTE broadcasts but clears missing models 
on an authoritative read', () => {
+    const { component, ngZService } = setup();
+    const data = snapshot('note-A', 'FINISHED', 'output');
+    component.getNote({ op: OP.NOTE, data });
+    component.angularObjectUpdate({
+      noteId: 'note-A',
+      interpreterGroupId: 'spark',
+      angularObject: { name: 'controlValue', object: 'old-model', noteId: 
'note-A' }
+    });
+    const context: Record<string, unknown> = {};
+    ngZService.bindParagraphContext('same-paragraph', context);
+
+    component.getNote({ op: OP.NOTE, data });
+    expect(context.controlValue).toBe('old-model');
+    component.getNote({ op: OP.NOTE, msgId: 'published-read', data });
+    expect(context.controlValue).toBeUndefined();
+    component.angularObjectUpdate({
+      noteId: 'note-A',
+      interpreterGroupId: 'spark',
+      angularObject: { name: 'controlValue', object: 'current-model', noteId: 
'note-A' }
+    });
+    expect(context.controlValue).toBe('current-model');
+  });
 });
 
 describe('Published React streaming output', () => {
   it.each(['append', 'update'])('refreshes React props after a streaming %s', 
operation => {
     const { component } = setup(true);
-    component.getNote(snapshot('note-A', 'RUNNING', 'initial'));
+    component.getNote({ op: OP.NOTE, data: snapshot('note-A', 'RUNNING', 
'initial') });
     const initialProps = component.reactProps;
     if (operation === 'append') {
       append(component, 'note-A', ' tail');
@@ -134,7 +202,7 @@ describe('Published React streaming output', () => {
     component.reactFailed = true;
     const updateResult = vi.fn();
     component.notebookParagraphResultComponents.reset([{ updateResult } as 
never]);
-    component.getNote(snapshot('note-A', 'RUNNING', 'initial'));
+    component.getNote({ op: OP.NOTE, data: snapshot('note-A', 'RUNNING', 
'initial') });
     append(component, 'note-A', ' tail');
     expect(updateResult).toHaveBeenCalledWith(expect.anything(), { type: 
DatasetType.TEXT, data: 'initial tail' });
   });
diff --git 
a/zeppelin-web-angular/src/app/pages/workspace/published/paragraph/paragraph.component.ts
 
b/zeppelin-web-angular/src/app/pages/workspace/published/paragraph/paragraph.component.ts
index bf925cb5ec..d1558329bf 100644
--- 
a/zeppelin-web-angular/src/app/pages/workspace/published/paragraph/paragraph.component.ts
+++ 
b/zeppelin-web-angular/src/app/pages/workspace/published/paragraph/paragraph.component.ts
@@ -19,13 +19,14 @@ import {
   ViewChildren
 } from '@angular/core';
 import { ActivatedRoute, Router } from '@angular/router';
-import { publishedSymbol, MessageListener, ParagraphBase, Published } from 
'@zeppelin/core';
+import { publishedSymbol, MessageEnvelopeListener, MessageListener, 
ParagraphBase, Published } from '@zeppelin/core';
 import {
   MessageReceiveDataTypeMap,
   OP,
   ParagraphConfigResult,
   ParagraphItem,
-  ParagraphIResultsMsgItem
+  ParagraphIResultsMsgItem,
+  WebSocketMessage
 } from '@zeppelin/sdk';
 import { MessageService, NgZService, NoteStatusService, ReactFeatureService } 
from '@zeppelin/services';
 import { isNil } from 'lodash';
@@ -57,6 +58,8 @@ export class PublishedParagraphComponent extends 
ParagraphBase implements Publis
   @ViewChildren(NotebookParagraphResultComponent)
   notebookParagraphResultComponents!: 
QueryList<NotebookParagraphResultComponent>;
 
+  private readonly ngZService: NgZService;
+
   protected get currentNoteId(): string | null {
     return this.noteId;
   }
@@ -72,6 +75,7 @@ export class PublishedParagraphComponent extends 
ParagraphBase implements Publis
     cdr: ChangeDetectorRef
   ) {
     super(messageService, noteStatusService, ngZService, cdr);
+    this.ngZService = ngZService;
     this.activatedRoute.queryParamMap.subscribe(params => {
       this.useReact = this.reactFeature.isEnabled('publishedParagraph', 
params);
     });
@@ -80,6 +84,9 @@ export class PublishedParagraphComponent extends 
ParagraphBase implements Publis
       if (typeof params.noteId !== 'string') {
         throw new Error(`noteId path parameter should be string, but got 
${typeof params.noteId} instead.`);
       }
+      if (this.paragraph) {
+        this.ngZService.removeParagraphBindings(this.paragraph.id);
+      }
       this.noteId = params.noteId;
       this.paragraphId = params.paragraphId!;
       this.setParagraphSnapshot(undefined);
@@ -87,11 +94,18 @@ export class PublishedParagraphComponent extends 
ParagraphBase implements Publis
     });
   }
 
-  @MessageListener(OP.NOTE)
-  getNote(data: MessageReceiveDataTypeMap[OP.NOTE]) {
-    const note = data.note;
+  @MessageEnvelopeListener(OP.NOTE)
+  getNote(message: WebSocketMessage<MessageReceiveDataTypeMap, OP.NOTE>) {
+    const note = message.data?.note;
     if (!isNil(note) && note.id === this.noteId) {
-      this.setParagraphSnapshot(note.paragraphs.find(p => p.id === 
this.paragraphId));
+      const paragraph = note.paragraphs.find(p => p.id === this.paragraphId);
+      if (this.paragraph && this.paragraph.id !== paragraph?.id) {
+        this.ngZService.removeParagraphBindings(this.paragraph.id);
+      }
+      if (message.msgId) {
+        this.ngZService.clearNoteContextValues(note.id);
+      }
+      this.setParagraphSnapshot(paragraph);
       if (this.paragraph) {
         if (!this.paragraph.results) {
           this.showRunConfirmationModal();
@@ -113,6 +127,13 @@ export class PublishedParagraphComponent extends 
ParagraphBase implements Publis
     this.cdr.markForCheck();
   }
 
+  override ngOnDestroy(): void {
+    super.ngOnDestroy();
+    if (this.paragraph) {
+      this.ngZService.removeParagraphBindings(this.paragraph.id);
+    }
+  }
+
   @MessageListener(OP.ERROR_INFO)
   handleError(data: MessageReceiveDataTypeMap[OP.ERROR_INFO]) {
     if (data.info && data.info.includes('404')) {
diff --git a/zeppelin-web-angular/src/app/services/message.service.ts 
b/zeppelin-web-angular/src/app/services/message.service.ts
index 7f71458510..5559cfbebe 100644
--- a/zeppelin-web-angular/src/app/services/message.service.ts
+++ b/zeppelin-web-angular/src/app/services/message.service.ts
@@ -195,7 +195,7 @@ export class MessageService extends Message implements 
OnDestroy {
 
   angularObjectUpdate(
     noteId: string,
-    paragraphId: string,
+    paragraphId: string | null | undefined,
     name: string,
     value: unknown,
     interpreterGroupId: string
diff --git a/zeppelin-web-angular/src/app/services/ng-z.service.spec.ts 
b/zeppelin-web-angular/src/app/services/ng-z.service.spec.ts
new file mode 100644
index 0000000000..3745293ee0
--- /dev/null
+++ b/zeppelin-web-angular/src/app/services/ng-z.service.spec.ts
@@ -0,0 +1,453 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { ElementRef, QueryList } from '@angular/core';
+import { AngularObjectRemove, AngularObjectUpdate, Note, OP, ParagraphItem } 
from '@zeppelin/sdk';
+import { EMPTY, Subject } from 'rxjs';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('@zeppelin/services', () => ({
+  MessageService: class {},
+  NgZService: class {},
+  NoteStatusService: class {},
+  NoteVarShareService: class {},
+  ShortcutService: class {}
+}));
+// The hoisted module mock must load the real listener owner before unrelated 
view modules.
+vi.mock('@zeppelin/core', async () => ({
+  ...(await import('../core/paragraph-base/paragraph-base')),
+  ...(await import('../core/message-listener/message-listener'))
+}));
+vi.mock('../pages/workspace/share/result/result.component', () => ({
+  NotebookParagraphResultComponent: class {}
+}));
+vi.mock('../pages/workspace/notebook/paragraph/code-editor/code-editor.component',
 () => ({
+  NotebookParagraphCodeEditorComponent: class {}
+}));
+
+import { NotebookParagraphComponent } from 
'../pages/workspace/notebook/paragraph/paragraph.component';
+import { NgZService } from './ng-z.service';
+
+const disposables: Array<() => void> = [];
+afterEach(() => disposables.splice(0).forEach(dispose => dispose()));
+
+const setup = () => {
+  const service = new NgZService();
+  const updates = new Subject<AngularObjectUpdate>();
+  const removals = new Subject<AngularObjectRemove>();
+  const message = {
+    receive: (op: OP) =>
+      op === OP.ANGULAR_OBJECT_UPDATE ? updates : op === 
OP.ANGULAR_OBJECT_REMOVE ? removals : EMPTY,
+    receiveEnvelope: () => EMPTY,
+    angularObjectUpdate: vi.fn(),
+    angularObjectClientBind: vi.fn(),
+    angularObjectClientUnbind: vi.fn()
+  };
+  const createParagraph = (id: string, noteId = 'note', revisionView = false) 
=> {
+    const component = new NotebookParagraphComponent(
+      message as never,
+      new ElementRef(document.createElement('div')),
+      { set: vi.fn() } as never,
+      {} as never,
+      { forkByElement: () => ({ bindShortcut: () => EMPTY }) } as never,
+      { isParagraphRunning: () => false, isEntireNoteRunning: () => false } as 
never,
+      { markForCheck: vi.fn() } as never,
+      service
+    );
+    component.paragraph = {
+      id,
+      text: '',
+      status: 'FINISHED',
+      config: {},
+      settings: { params: {}, forms: {} }
+    } as ParagraphItem;
+    component.note = { id: noteId, paragraphs: [component.paragraph], config: 
{} } as NonNullable<Note['note']>;
+    component.revisionView = revisionView;
+    component.notebookParagraphResultComponents = new QueryList();
+    component.ngOnInit();
+    disposables.push(() => component.ngOnDestroy());
+    return component;
+  };
+  const a = createParagraph('A');
+  const b = createParagraph('B');
+  disposables.push(() => service.ngOnDestroy());
+  const update = (value: unknown, paragraphId?: string, group = 'spark') =>
+    updates.next({
+      noteId: 'note',
+      paragraphId,
+      interpreterGroupId: group,
+      angularObject: { name: 'controlValue', object: value, noteId: 'note', 
paragraphId }
+    });
+  const bind = (paragraphId: string) => {
+    const context: Record<string, unknown> = {};
+    const release = service.bindParagraphContext(paragraphId, context);
+    return { context, release };
+  };
+  return { service, updates, removals, message, createParagraph, a, b, update, 
bind };
+};
+
+describe('Paragraph Angular object binding', () => {
+  it.each(['notebook', 'global'] as const)('notifies each paragraph once per 
%s server update and removal', scope => {
+    const { service, updates, removals, createParagraph, bind, message } = 
setup();
+    const paragraphIds = ['A', 'B', ...Array.from({ length: 18 }, (_, index) 
=> `extra-${index}`)];
+    paragraphIds.slice(2).forEach(id => createParagraph(id));
+    const contexts = paragraphIds.map(id => bind(id).context);
+    const changes = vi.fn();
+    const subscription = service.contextChanged().subscribe(changes);
+    disposables.push(() => subscription.unsubscribe());
+    const angularObject = {
+      name: 'controlValue',
+      object: 'server',
+      noteId: scope === 'notebook' ? 'note' : null,
+      paragraphId: null
+    };
+    updates.next({ noteId: 'note', interpreterGroupId: 'spark', angularObject 
});
+    expect(contexts.map(context => 
context.controlValue)).toEqual(paragraphIds.map(() => 'server'));
+    expect(changes.mock.calls.map(([change]) => 
change.paragraphId)).toEqual(paragraphIds);
+
+    changes.mockClear();
+    contexts.forEach(context => (context.controlValue = 'template-local'));
+    updates.next({ noteId: 'note', interpreterGroupId: 'spark', angularObject 
});
+    expect(contexts.map(context => 
context.controlValue)).toEqual(paragraphIds.map(() => 'server'));
+    expect(changes.mock.calls.map(([change]) => 
change.paragraphId)).toEqual(paragraphIds);
+
+    changes.mockClear();
+    removals.next({ noteId: 'note', interpreterGroupId: 'spark', angularObject 
});
+    expect(contexts.map(context => 
context.controlValue)).toEqual(paragraphIds.map(() => undefined));
+    expect(changes.mock.calls.map(([change]) => 
change.paragraphId)).toEqual(paragraphIds);
+    expect(message.angularObjectUpdate).not.toHaveBeenCalled();
+    expect(message.angularObjectClientBind).not.toHaveBeenCalled();
+    expect(message.angularObjectClientUnbind).not.toHaveBeenCalled();
+  });
+
+  it('retains initial values received by paragraph listeners until outputs are 
created', () => {
+    const { update, bind } = setup();
+    update('initial');
+    update('latest');
+    expect(bind('A').context.controlValue).toBe('latest');
+    expect(bind('B').context.controlValue).toBe('latest');
+  });
+
+  it('updates multiple outputs and hydrates replacements without retaining 
destroyed contexts', () => {
+    const { update, bind } = setup();
+    const first = bind('A');
+    const second = bind('A');
+    update('initial');
+    first.release();
+    update('next');
+    expect(first.context.controlValue).toBe('initial');
+    expect(second.context.controlValue).toBe('next');
+    expect(bind('A').context.controlValue).toBe('next');
+  });
+
+  it('keeps paragraph overrides isolated from notebook updates and restores 
fallback after removal', () => {
+    const { update, bind, removals } = setup();
+    update('private', 'A');
+    update('shared');
+    const a = bind('A').context;
+    const b = bind('B').context;
+    update('next-shared');
+    expect(a.controlValue).toBe('private');
+    expect(b.controlValue).toBe('next-shared');
+    removals.next({ noteId: 'note', paragraphId: 'A', name: 'controlValue', 
interpreterGroupId: 'spark' });
+    expect(a.controlValue).toBe('next-shared');
+  });
+
+  it.each(['top-level', 'nested'] as const)('handles %s notebook removals 
without resurrecting values', shape => {
+    const { update, removals, bind } = setup();
+    update('initial');
+    const a = bind('A');
+    const b = bind('B');
+    removals.next(
+      shape === 'top-level'
+        ? { noteId: 'note', name: 'controlValue', interpreterGroupId: 'spark' }
+        : {
+            noteId: 'note',
+            interpreterGroupId: 'spark',
+            angularObject: { name: 'controlValue', object: 'initial', noteId: 
'note' }
+          }
+    );
+    expect(a.context.controlValue).toBeUndefined();
+    expect(b.context.controlValue).toBeUndefined();
+    a.release();
+    expect(bind('A').context.controlValue).toBeUndefined();
+  });
+
+  it('retains deletion received before output creation', () => {
+    const { update, removals, bind } = setup();
+    update('initial');
+    removals.next({ noteId: 'note', name: 'controlValue', interpreterGroupId: 
'spark' });
+    expect(bind('A').context.controlValue).toBeUndefined();
+  });
+
+  it('rejects foreign-note and different-paragraph messages in the paragraph 
listener', () => {
+    const { updates, removals, update, bind } = setup();
+    update('private', 'A');
+    const a = bind('A').context;
+    updates.next({
+      noteId: 'other',
+      paragraphId: 'A',
+      interpreterGroupId: 'spark',
+      angularObject: { name: 'controlValue', object: 'foreign', noteId: 
'other', paragraphId: 'A' }
+    });
+    removals.next({ noteId: 'other', paragraphId: 'A', name: 'controlValue' });
+    update('other-paragraph', 'B');
+    removals.next({ noteId: 'note', paragraphId: 'B', name: 'controlValue', 
interpreterGroupId: 'spark' });
+    expect(a.controlValue).toBe('private');
+  });
+
+  it('uses the existing paragraph outbound subscriber to update the original 
notebook model only once', () => {
+    const { update, bind, service, message } = setup();
+    update('initial', undefined, 'original-group');
+    const a = bind('A').context;
+    const b = bind('B').context;
+    service.setContextValue('controlValue', false, 'A');
+    expect(a.controlValue).toBe(false);
+    expect(b.controlValue).toBe(false);
+    expect(message.angularObjectUpdate).toHaveBeenCalledExactlyOnceWith(
+      'note',
+      null,
+      'controlValue',
+      false,
+      'original-group'
+    );
+    expect(message.angularObjectClientBind).not.toHaveBeenCalled();
+    update('remote', undefined, 'original-group');
+    expect(a.controlValue).toBe('remote');
+    expect(message.angularObjectUpdate).toHaveBeenCalledTimes(1);
+  });
+
+  it('preserves paragraph scope and interpreter group for edits without 
updating the notebook fallback', () => {
+    const { update, bind, service, message } = setup();
+    update('shared', undefined, 'shared-group');
+    update('private', 'A', 'private-group');
+    const a = bind('A').context;
+    const b = bind('B').context;
+    service.setContextValue('controlValue', 0, 'A');
+    expect(a.controlValue).toBe(0);
+    expect(b.controlValue).toBe('shared');
+    expect(message.angularObjectUpdate).toHaveBeenCalledExactlyOnceWith(
+      'note',
+      'A',
+      'controlValue',
+      0,
+      'private-group'
+    );
+  });
+
+  it('keeps a client-created paragraph value ahead of later shared updates and 
edits its original scope', () => {
+    const { update, bind, service, message } = setup();
+    service.setContextValue('controlValue', 'client-private', 'A');
+    update('shared');
+    const a = bind('A').context;
+    expect(a.controlValue).toBe('client-private');
+    expect(bind('B').context.controlValue).toBe('shared');
+    service.setContextValue('controlValue', 'edited-private', 'A');
+    expect(a.controlValue).toBe('edited-private');
+    expect(message.angularObjectClientBind).toHaveBeenLastCalledWith('note', 
'controlValue', 'edited-private', 'A');
+    expect(message.angularObjectUpdate).not.toHaveBeenCalled();
+  });
+
+  it('removes a client-created value even when another scope shares its name', 
() => {
+    const { service, update, bind, removals } = setup();
+    service.setContextValue('controlValue', 'client-private', 'A');
+    update('shared');
+    const a = bind('A').context;
+    removals.next({ noteId: 'note', paragraphId: 'A', name: 'controlValue', 
interpreterGroupId: 'spark' });
+    expect(a.controlValue).toBe('shared');
+    removals.next({ noteId: 'note', name: 'controlValue', interpreterGroupId: 
'spark' });
+    expect(a.controlValue).toBeUndefined();
+    expect(bind('A').context.controlValue).toBeUndefined();
+  });
+
+  it('reconciles a server snapshot without losing live output registrations or 
retaining removed values', () => {
+    const { service, update, bind } = setup();
+    update('old-server-value');
+    const a = bind('A').context;
+    service.clearNoteContextValues('note');
+    expect(a.controlValue).toBeUndefined();
+    expect(bind('B').context.controlValue).toBeUndefined();
+    update('new-server-value');
+    expect(a.controlValue).toBe('new-server-value');
+  });
+
+  it('binds existing shared values into a newly registered paragraph without a 
new server event', () => {
+    const { update, createParagraph, bind, service } = setup();
+    update('shared');
+    createParagraph('C');
+    expect(bind('C').context.controlValue).toBe('shared');
+    service.removeParagraphBindings('C');
+    expect(bind('A').context.controlValue).toBe('shared');
+  });
+
+  it.each([undefined, null])('treats a top-level noteId=%j removal as global, 
not notebook scoped', noteId => {
+    const { updates, removals, update, bind } = setup();
+    updates.next({
+      noteId: 'note',
+      interpreterGroupId: 'spark',
+      angularObject: { name: 'controlValue', object: 'global' }
+    });
+    update('notebook');
+    const a = bind('A').context;
+    removals.next({ noteId, name: 'controlValue', interpreterGroupId: 'spark' 
});
+    expect(a.controlValue).toBe('notebook');
+    removals.next({ noteId: 'note', name: 'controlValue', interpreterGroupId: 
'spark' });
+    expect(a.controlValue).toBeUndefined();
+  });
+
+  it('preserves the original identity even when the initial bound value is 
undefined', () => {
+    const { service, update, bind, message } = setup();
+    update(undefined);
+    expect(bind('A').context.controlValue).toBeUndefined();
+    service.setContextValue('controlValue', 'edited', 'A');
+    expect(message.angularObjectUpdate).toHaveBeenCalledExactlyOnceWith(
+      'note',
+      null,
+      'controlValue',
+      'edited',
+      'spark'
+    );
+    expect(message.angularObjectClientBind).not.toHaveBeenCalled();
+  });
+
+  it('supports local z.set bindings in revision paragraphs without depending 
on NOTE messages', () => {
+    const { createParagraph, service, bind, message } = setup();
+    createParagraph('revision', 'revision-note', true);
+    service.setContextValue('localValue', 'revision-input', 'revision');
+    expect(bind('revision').context.localValue).toBe('revision-input');
+    expect(message.angularObjectClientBind).toHaveBeenCalledExactlyOnceWith(
+      'revision-note',
+      'localValue',
+      'revision-input',
+      'revision'
+    );
+  });
+
+  it('removes only the named interpreter-group binding and keeps the surviving 
model editable', () => {
+    const { update, removals, bind, service, message } = setup();
+    update('first-model', 'A', 'group-one');
+    update('second-model', 'A', 'group-two');
+    const context = bind('A').context;
+
+    removals.next({ noteId: 'note', paragraphId: 'A', name: 'controlValue', 
interpreterGroupId: 'group-one' });
+    expect(context.controlValue).toBe('second-model');
+    service.setContextValue('controlValue', 'edited-survivor', 'A');
+    expect(message.angularObjectUpdate).toHaveBeenCalledExactlyOnceWith(
+      'note',
+      'A',
+      'controlValue',
+      'edited-survivor',
+      'group-two'
+    );
+
+    removals.next({ noteId: 'note', paragraphId: 'A', name: 'controlValue' });
+    expect(context.controlValue).toBeUndefined();
+    expect(bind('A').context.controlValue).toBeUndefined();
+  });
+
+  it('does not guess the interpreter group when a removal matches multiple 
model identities', () => {
+    const { update, removals, bind } = setup();
+    update('first-model', 'A', 'group-one');
+    update('second-model', 'A', 'group-two');
+    const context = bind('A').context;
+    const originalValue = context.controlValue;
+
+    removals.next({ noteId: 'note', paragraphId: 'A', name: 'controlValue' });
+    expect(context.controlValue).toBe(originalValue);
+    removals.next({ noteId: 'note', paragraphId: 'A', name: 'controlValue', 
interpreterGroupId: 'group-one' });
+    expect(context.controlValue).toBe('second-model');
+    removals.next({ noteId: 'note', paragraphId: 'A', name: 'controlValue', 
interpreterGroupId: 'group-two' });
+    expect(context.controlValue).toBeUndefined();
+  });
+
+  it('isolates retained values, clearing, and outbound edits between 
notebooks', () => {
+    const { createParagraph, update, updates, bind, service, message } = 
setup();
+    createParagraph('other-paragraph', 'other-note');
+    update('first-notebook');
+    updates.next({
+      noteId: 'other-note',
+      interpreterGroupId: 'other-group',
+      angularObject: { name: 'controlValue', object: 'second-notebook', 
noteId: 'other-note' }
+    });
+    const first = bind('A').context;
+    const second = bind('other-paragraph').context;
+    expect(first.controlValue).toBe('first-notebook');
+    expect(second.controlValue).toBe('second-notebook');
+
+    service.clearNoteContextValues('note');
+    service.setContextValue('controlValue', 'edited-second', 
'other-paragraph');
+    expect(first.controlValue).toBeUndefined();
+    expect(second.controlValue).toBe('edited-second');
+    expect(message.angularObjectUpdate).toHaveBeenCalledExactlyOnceWith(
+      'other-note',
+      null,
+      'controlValue',
+      'edited-second',
+      'other-group'
+    );
+  });
+  it('preserves a private draft when other scopes or interpreter groups 
change', () => {
+    const { update, removals, bind } = setup();
+    update('private', 'B', 'selected-group');
+    const context = bind('B').context;
+    context.controlValue = 'private-draft';
+
+    update('other-paragraph', 'A');
+    update('notebook');
+    update('hidden-private', 'B', 'other-group');
+    expect(context.controlValue).toBe('private-draft');
+    removals.next({ noteId: 'note', paragraphId: 'B', name: 'controlValue', 
interpreterGroupId: 'other-group' });
+    removals.next({ noteId: 'note', name: 'controlValue', interpreterGroupId: 
'spark' });
+    expect(context.controlValue).toBe('private-draft');
+
+    update('new-private', 'B', 'selected-group');
+    expect(context.controlValue).toBe('new-private');
+  });
+
+  it('refreshes every consumer when removal selects a surviving shared 
interpreter-group binding', () => {
+    const { update, removals, bind } = setup();
+    update('first', undefined, 'first-group');
+    update('second', undefined, 'second-group');
+    const a = bind('A').context;
+    const b = bind('B').context;
+    expect(a.controlValue).toBe('first');
+    expect(b.controlValue).toBe('first');
+
+    removals.next({ noteId: 'note', name: 'controlValue', interpreterGroupId: 
'first-group' });
+    expect(a.controlValue).toBe('second');
+    expect(b.controlValue).toBe('second');
+    removals.next({ noteId: 'note', name: 'controlValue', interpreterGroupId: 
'second-group' });
+    expect(a.controlValue).toBeUndefined();
+    expect(b.controlValue).toBeUndefined();
+  });
+
+  it('reconciles a same-value client update for shared consumers and sends an 
explicit no-change unbind', () => {
+    const { update, bind, service, message } = setup();
+    update('shared');
+    const a = bind('A').context;
+    const b = bind('B').context;
+    b.controlValue = 'draft';
+    service.setContextValue('controlValue', 'shared', 'A');
+    expect(a.controlValue).toBe('shared');
+    expect(b.controlValue).toBe('shared');
+    expect(message.angularObjectUpdate).toHaveBeenCalledExactlyOnceWith(
+      'note',
+      null,
+      'controlValue',
+      'shared',
+      'spark'
+    );
+
+    service.unsetContextValue('not-cached', 'A');
+    
expect(message.angularObjectClientUnbind).toHaveBeenCalledExactlyOnceWith('note',
 'not-cached', 'A');
+  });
+});
diff --git a/zeppelin-web-angular/src/app/services/ng-z.service.ts 
b/zeppelin-web-angular/src/app/services/ng-z.service.ts
index f7979c9a09..1e3e0cd0d6 100644
--- a/zeppelin-web-angular/src/app/services/ng-z.service.ts
+++ b/zeppelin-web-angular/src/app/services/ng-z.service.ts
@@ -11,20 +11,34 @@
  */
 
 import { Injectable, OnDestroy } from '@angular/core';
-
-import { AngularContext } from '@zeppelin/core';
-import { isRecord } from '@zeppelin/utility';
 import { Subject } from 'rxjs';
 
+import {
+  AngularContext,
+  AngularContextManager,
+  AngularObjectBinding
+} from '../core/paragraph-base/angular-context-manager';
+
+interface ContextValue {
+  value: unknown;
+  binding?: AngularObjectBinding;
+}
+
+interface ParagraphBindings {
+  noteId: string;
+  outputContexts: Set<Record<string, unknown>>;
+  localValues: Map<string, ContextValue>;
+  resolvedValues: Map<string, ContextValue>;
+}
+
 @Injectable({
   providedIn: 'root'
 })
-export class NgZService implements OnDestroy {
-  private paragraphMap: Map<string, unknown> = new Map<string, Record<string, 
unknown>>();
-  private contextChange$ = new Subject<AngularContext>();
-  private runParagraph$ = new Subject<string>();
-
-  constructor() {}
+export class NgZService implements AngularContextManager, OnDestroy {
+  private readonly paragraphBindingsById = new Map<string, 
ParagraphBindings>();
+  private readonly boundValuesByNote = new Map<string, Map<string, Map<string, 
ContextValue>>>();
+  private readonly contextChange$ = new Subject<AngularContext>();
+  private readonly runParagraph$ = new Subject<string>();
 
   contextChanged() {
     return this.contextChange$.asObservable();
@@ -34,48 +48,241 @@ export class NgZService implements OnDestroy {
     return this.runParagraph$.asObservable();
   }
 
-  removeParagraph(paragraphId: string) {
-    this.paragraphMap.delete(paragraphId);
+  registerParagraphBindings(noteId: string, paragraphId: string): void {
+    const existing = this.paragraphBindingsById.get(paragraphId);
+    if (existing?.noteId === noteId) {
+      return;
+    }
+    this.removeParagraphBindings(paragraphId);
+    this.paragraphBindingsById.set(paragraphId, {
+      noteId,
+      outputContexts: new Set(),
+      localValues: new Map(),
+      resolvedValues: new Map()
+    });
+  }
+
+  removeParagraphBindings(paragraphId: string): void {
+    const paragraph = this.paragraphBindingsById.get(paragraphId);
+    if (!paragraph) {
+      return;
+    }
+    this.paragraphBindingsById.delete(paragraphId);
+    if (![...this.paragraphBindingsById.values()].some(target => target.noteId 
=== paragraph.noteId)) {
+      this.boundValuesByNote.delete(paragraph.noteId);
+      return;
+    }
+    const noteBindings = this.boundValuesByNote.get(paragraph.noteId);
+    for (const [key, values] of noteBindings ?? []) {
+      for (const [identity, entry] of values) {
+        if (entry.binding?.paragraphId === paragraphId) {
+          values.delete(identity);
+        }
+      }
+      if (values.size === 0) {
+        noteBindings!.delete(key);
+      }
+    }
+  }
+
+  clearNoteContextValues(noteId: string): void {
+    const keys = new Set(this.boundValuesByNote.get(noteId)?.keys());
+    this.boundValuesByNote.delete(noteId);
+    for (const [paragraphId, paragraph] of this.paragraphBindingsById) {
+      if (paragraph.noteId !== noteId) {
+        continue;
+      }
+      const paragraphKeys = new Set([...keys, 
...paragraph.localValues.keys()]);
+      paragraph.localValues.clear();
+      for (const key of paragraphKeys) {
+        this.updateParagraphContextValue(paragraphId, paragraph, key, false, 
false);
+      }
+    }
   }
 
   runParagraph(paragraphId: string) {
     this.runParagraph$.next(paragraphId);
   }
 
-  bindParagraph(paragraphId: string, context: Record<string, unknown>) {
-    this.paragraphMap.set(paragraphId, context);
+  bindParagraphContext(paragraphId: string, context: Record<string, unknown>): 
() => void {
+    const paragraph = this.paragraphBindingsById.get(paragraphId);
+    if (!paragraph) {
+      return () => {};
+    }
+    paragraph.outputContexts.add(context);
+    for (const key of new Set([
+      ...paragraph.localValues.keys(),
+      ...(this.boundValuesByNote.get(paragraph.noteId)?.keys() ?? [])
+    ])) {
+      const current = this.resolveContextValue(paragraphId, paragraph, key);
+      if (current) {
+        paragraph.resolvedValues.set(key, current);
+      }
+      context[key] = current?.value;
+    }
+    return () => paragraph.outputContexts.delete(context);
   }
 
-  setContextValue(key: string, value: unknown, paragraphId: string, emit = 
true) {
-    const context = this.paragraphMap.get(paragraphId);
-    if (isRecord(context)) {
-      context[key] = value;
+  setContextValue(key: string, value: unknown, paragraphId: string, emit = 
true, binding?: AngularObjectBinding): void {
+    const paragraph = this.paragraphBindingsById.get(paragraphId);
+    if (!paragraph) {
+      return;
+    }
+    let updatedValue: ContextValue;
+    if (binding) {
+      let noteBindings = this.boundValuesByNote.get(paragraph.noteId);
+      if (!noteBindings) {
+        noteBindings = new Map();
+        this.boundValuesByNote.set(paragraph.noteId, noteBindings);
+      }
+      let values = noteBindings.get(key);
+      if (!values) {
+        values = new Map();
+        noteBindings.set(key, values);
+      }
+      const identity = JSON.stringify([binding.interpreterGroupId, 
binding.noteId, binding.paragraphId]);
+      const previous = values.get(identity);
+      updatedValue = previous && previous.value === value ? previous : { 
value, binding };
+      if (updatedValue !== previous) {
+        values.set(identity, updatedValue);
+      }
+      // Equal cached values must still reconcile fields changed directly by a 
template.
+      if (binding.paragraphId !== null) {
+        
this.paragraphBindingsById.get(binding.paragraphId)?.localValues.delete(key);
+      }
+    } else {
+      const current = this.resolveContextValue(paragraphId, paragraph, key);
+      if (current?.binding) {
+        updatedValue = current;
+        current.value = value;
+      } else {
+        updatedValue = { value };
+        paragraph.localValues.set(key, updatedValue);
+      }
+    }
+    this.refreshNoteContextValue(paragraph.noteId, key, paragraphId, emit, 
true, updatedValue);
+  }
+
+  unsetContextValue(key: string, paragraphId: string, emit = true, binding?: 
AngularObjectBinding): void {
+    const paragraph = this.paragraphBindingsById.get(paragraphId);
+    if (!paragraph) {
+      return;
+    }
+    const scope: AngularObjectBinding = binding ?? { noteId: paragraph.noteId, 
paragraphId };
+    if (scope.noteId === paragraph.noteId && scope.paragraphId === 
paragraphId) {
+      // Client bind excludes this connection from its echo; a local value may 
have no cached server entry yet.
+      paragraph.localValues.delete(key);
+    }
+    const values = this.boundValuesByNote.get(paragraph.noteId)?.get(key);
+    const matches = [...(values ?? [])].filter(
+      ([, entry]) =>
+        entry.binding?.noteId === scope.noteId &&
+        entry.binding.paragraphId === scope.paragraphId &&
+        (scope.interpreterGroupId === undefined || 
entry.binding.interpreterGroupId === scope.interpreterGroupId)
+    );
+    if (scope.interpreterGroupId !== undefined || matches.length <= 1) {
+      for (const [identity] of matches) {
+        values!.delete(identity);
+      }
+    }
+    if (values?.size === 0) {
+      this.boundValuesByNote.get(paragraph.noteId)!.delete(key);
+    }
+    this.refreshNoteContextValue(paragraph.noteId, key, paragraphId, emit, 
false);
+  }
+
+  ngOnDestroy(): void {
+    this.paragraphBindingsById.clear();
+    this.boundValuesByNote.clear();
+    this.contextChange$.complete();
+    this.runParagraph$.complete();
+  }
+
+  private resolveContextValue(
+    paragraphId: string,
+    paragraph: ParagraphBindings,
+    key: string
+  ): ContextValue | undefined {
+    let resolved = paragraph.localValues.get(key);
+    let priority = resolved ? 2 : -1;
+    for (const entry of 
this.boundValuesByNote.get(paragraph.noteId)?.get(key)?.values() ?? []) {
+      const scope = entry.binding!;
+      if (scope.paragraphId !== null && scope.paragraphId !== paragraphId) {
+        continue;
+      }
+      // Match the server's lookup order, including client-created paragraph 
values.
+      const candidatePriority = scope.paragraphId !== null ? 2 : scope.noteId 
!== null ? 1 : 0;
+      if (candidatePriority > priority) {
+        resolved = entry;
+        priority = candidatePriority;
+      }
+    }
+    return resolved;
+  }
+
+  private refreshNoteContextValue(
+    noteId: string,
+    key: string,
+    sourceParagraphId: string,
+    emit: boolean,
+    set: boolean,
+    updatedValue?: ContextValue
+  ): void {
+    // Server messages already reach every matching paragraph listener.
+    if (!emit) {
+      const paragraph = this.paragraphBindingsById.get(sourceParagraphId);
+      if (paragraph) {
+        this.updateParagraphContextValue(sourceParagraphId, paragraph, key, 
false, set, updatedValue);
+      }
+      return;
+    }
+    for (const [paragraphId, paragraph] of this.paragraphBindingsById) {
+      if (paragraph.noteId === noteId) {
+        this.updateParagraphContextValue(
+          paragraphId,
+          paragraph,
+          key,
+          paragraphId === sourceParagraphId,
+          set,
+          updatedValue
+        );
+      }
     }
-    this.contextChange$.next({
-      paragraphId,
-      key,
-      value,
-      emit,
-      set: true
-    });
   }
 
-  unsetContextValue(key: string, paragraphId: string, emit = true) {
-    const context = this.paragraphMap.get(paragraphId);
-    if (isRecord(context)) {
-      context[key] = undefined;
+  private updateParagraphContextValue(
+    paragraphId: string,
+    paragraph: ParagraphBindings,
+    key: string,
+    emit: boolean,
+    set: boolean,
+    updatedValue?: ContextValue
+  ): void {
+    const current = this.resolveContextValue(paragraphId, paragraph, key);
+    // Other scopes and interpreter groups must not overwrite this output's 
local edits.
+    if (
+      !emit &&
+      paragraph.resolvedValues.get(key) === current &&
+      (updatedValue === undefined || current !== updatedValue)
+    ) {
+      return;
+    }
+    if (current) {
+      paragraph.resolvedValues.set(key, current);
+    } else {
+      paragraph.resolvedValues.delete(key);
+    }
+    for (const context of paragraph.outputContexts) {
+      context[key] = current?.value;
     }
     this.contextChange$.next({
+      noteId: paragraph.noteId,
       paragraphId,
       key,
+      value: current?.value,
+      binding: current?.binding,
       emit,
-      value: undefined,
-      set: false
+      set
     });
   }
-
-  ngOnDestroy(): void {
-    this.paragraphMap.clear();
-    this.contextChange$.complete();
-  }
 }
diff --git 
a/zeppelin-web-angular/src/app/services/runtime-compiler.service.spec.ts 
b/zeppelin-web-angular/src/app/services/runtime-compiler.service.spec.ts
new file mode 100644
index 0000000000..79bfedb357
--- /dev/null
+++ b/zeppelin-web-angular/src/app/services/runtime-compiler.service.spec.ts
@@ -0,0 +1,178 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { NgComponentOutlet } from '@angular/common';
+import { Component, provideZoneChangeDetection } from '@angular/core';
+import { TestBed } from '@angular/core/testing';
+import { expect, it } from 'vitest';
+import { NgZService } from './ng-z.service';
+import { DynamicTemplate, RuntimeCompilerService } from 
'./runtime-compiler.service';
+
+@Component({
+  imports: [NgComponentOutlet],
+  template:
+    '<ng-container *ngComponentOutlet="dynamic?.component; ngModuleFactory: 
dynamic?.moduleFactory"></ng-container>'
+})
+class OutputHost {
+  dynamic: DynamicTemplate | null = null;
+}
+
+it('renders retained values, supports native Angular input, and does not 
resurrect removals on output recreation', async () => {
+  TestBed.configureTestingModule({ imports: [OutputHost], providers: 
[provideZoneChangeDetection()] });
+  const service = TestBed.inject(NgZService);
+  service.registerParagraphBindings('note', 'A');
+  const binding = { noteId: 'note', paragraphId: null, interpreterGroupId: 
'spark-group' };
+  service.setContextValue('controlValue', 'hello', 'A', false, binding);
+  const compiler = TestBed.inject(RuntimeCompilerService);
+  const dynamic = await compiler.createAndCompileTemplate(
+    'A',
+    "<output>{{controlValue}}</output><button (click)=\"z.set('controlValue', 
'edited', 'A')\">Edit</button>"
+  );
+  const fixture = TestBed.createComponent(OutputHost);
+  fixture.componentInstance.dynamic = dynamic;
+  fixture.detectChanges();
+  const element = fixture.nativeElement as HTMLElement;
+  expect(element.querySelector('output')?.textContent).toBe('hello');
+  element.querySelector('button')!.click();
+  fixture.detectChanges();
+  expect(element.querySelector('output')?.textContent).toBe('edited');
+  fixture.componentInstance.dynamic = null;
+  fixture.detectChanges();
+  service.unsetContextValue('controlValue', 'A', false, binding);
+  fixture.componentInstance.dynamic = dynamic;
+  fixture.detectChanges();
+  expect(element.querySelector('output')?.textContent).toBe('');
+});
+
+it('uses current binding state when a model is removed while template 
compilation is pending', async () => {
+  TestBed.configureTestingModule({ imports: [OutputHost], providers: 
[provideZoneChangeDetection()] });
+  const service = TestBed.inject(NgZService);
+  service.registerParagraphBindings('note', 'A');
+  const binding = { noteId: 'note', paragraphId: null, interpreterGroupId: 
'spark' };
+  service.setContextValue('controlValue', 'removed-during-compilation', 'A', 
false, binding);
+  const pending = 
TestBed.inject(RuntimeCompilerService).createAndCompileTemplate(
+    'A',
+    '<output>{{controlValue}}</output>'
+  );
+  service.unsetContextValue('controlValue', 'A', false, binding);
+
+  const fixture = TestBed.createComponent(OutputHost);
+  fixture.componentInstance.dynamic = await pending;
+  fixture.detectChanges();
+  const element = fixture.nativeElement as HTMLElement;
+  expect(element.querySelector('output')?.textContent).toBe('');
+  service.setContextValue('controlValue', 'rebound', 'A', false, binding);
+  fixture.detectChanges();
+  expect(element.querySelector('output')?.textContent).toBe('rebound');
+});
+
+it('reapplies repeated server values after a native template-local edit 
without changing sibling outputs', async () => {
+  TestBed.configureTestingModule({ imports: [OutputHost], providers: 
[provideZoneChangeDetection()] });
+  const service = TestBed.inject(NgZService);
+  service.registerParagraphBindings('note', 'A');
+  const binding = { noteId: 'note', paragraphId: null, interpreterGroupId: 
'spark' };
+  service.setContextValue('controlValue', 'server', 'A', false, binding);
+  const dynamic = await 
TestBed.inject(RuntimeCompilerService).createAndCompileTemplate(
+    'A',
+    '<output>{{controlValue}}</output><button (click)="controlValue = 
\'local\'">Local edit</button>'
+  );
+  const first = TestBed.createComponent(OutputHost);
+  const second = TestBed.createComponent(OutputHost);
+  first.componentInstance.dynamic = dynamic;
+  second.componentInstance.dynamic = dynamic;
+  first.detectChanges();
+  second.detectChanges();
+  const firstElement = first.nativeElement as HTMLElement;
+  const secondElement = second.nativeElement as HTMLElement;
+  expect(firstElement.querySelector('output')?.textContent).toBe('server');
+  expect(secondElement.querySelector('output')?.textContent).toBe('server');
+
+  firstElement.querySelector('button')!.click();
+  first.detectChanges();
+  second.detectChanges();
+  expect(firstElement.querySelector('output')?.textContent).toBe('local');
+  expect(secondElement.querySelector('output')?.textContent).toBe('server');
+
+  service.setContextValue('controlValue', 'server', 'A', false, binding);
+  first.detectChanges();
+  second.detectChanges();
+  expect(firstElement.querySelector('output')?.textContent).toBe('server');
+  expect(secondElement.querySelector('output')?.textContent).toBe('server');
+});
+
+it('keeps a releaseContext model separate from output cleanup', async () => {
+  TestBed.configureTestingModule({ imports: [OutputHost], providers: 
[provideZoneChangeDetection()] });
+  const service = TestBed.inject(NgZService);
+  service.registerParagraphBindings('note', 'A');
+  const binding = { noteId: 'note', paragraphId: null, interpreterGroupId: 
'spark' };
+  service.setContextValue('releaseContext', 'initial', 'A', false, binding);
+  const dynamic = await 
TestBed.inject(RuntimeCompilerService).createAndCompileTemplate(
+    'A',
+    '<output>{{releaseContext}}</output>'
+  );
+  const fixture = TestBed.createComponent(OutputHost);
+  fixture.componentInstance.dynamic = dynamic;
+  fixture.detectChanges();
+  const element = fixture.nativeElement as HTMLElement;
+  expect(element.querySelector('output')?.textContent).toBe('initial');
+
+  service.setContextValue('releaseContext', 'updated', 'A', false, binding);
+  fixture.detectChanges();
+  expect(element.querySelector('output')?.textContent).toBe('updated');
+  fixture.componentInstance.dynamic = null;
+  fixture.detectChanges();
+  service.setContextValue('releaseContext', 'after-destroy', 'A', false, 
binding);
+  fixture.componentInstance.dynamic = dynamic;
+  fixture.detectChanges();
+  expect(element.querySelector('output')?.textContent).toBe('after-destroy');
+});
+
+it('propagates a native shared edit without resetting a different paragraph 
binding draft', async () => {
+  TestBed.configureTestingModule({ imports: [OutputHost], providers: 
[provideZoneChangeDetection()] });
+  const service = TestBed.inject(NgZService);
+  service.registerParagraphBindings('note', 'A');
+  service.registerParagraphBindings('note', 'B');
+  service.registerParagraphBindings('note', 'C');
+  const shared = { noteId: 'note', paragraphId: null, interpreterGroupId: 
'spark' };
+  service.setContextValue('controlValue', 'shared', 'A', false, shared);
+  service.setContextValue('controlValue', 'private', 'B', false, { ...shared, 
paragraphId: 'B' });
+  const compiler = TestBed.inject(RuntimeCompilerService);
+  const fixtures = [];
+  for (const id of ['A', 'B', 'C']) {
+    const dynamic = await compiler.createAndCompileTemplate(
+      id,
+      id === 'A'
+        ? "<output>{{controlValue}}</output><button 
(click)=\"z.set('controlValue', 'edited', 'A')\">Edit shared</button>"
+        : '<output>{{controlValue}}</output><button (click)="controlValue = 
\'draft\'">Local edit</button>'
+    );
+    const fixture = TestBed.createComponent(OutputHost);
+    fixture.componentInstance.dynamic = dynamic;
+    fixture.detectChanges();
+    fixtures.push(fixture);
+  }
+  const [a, b, c] = fixtures;
+  const aElement = a.nativeElement as HTMLElement;
+  const bElement = b.nativeElement as HTMLElement;
+  const cElement = c.nativeElement as HTMLElement;
+  expect(cElement.querySelector('output')?.textContent).toBe('shared');
+  bElement.querySelector('button')!.click();
+  b.detectChanges();
+  expect(bElement.querySelector('output')?.textContent).toBe('draft');
+
+  aElement.querySelector('button')!.click();
+  a.detectChanges();
+  b.detectChanges();
+  c.detectChanges();
+  expect(aElement.querySelector('output')?.textContent).toBe('edited');
+  expect(bElement.querySelector('output')?.textContent).toBe('draft');
+  expect(cElement.querySelector('output')?.textContent).toBe('edited');
+});
diff --git a/zeppelin-web-angular/src/app/services/runtime-compiler.service.ts 
b/zeppelin-web-angular/src/app/services/runtime-compiler.service.ts
index 83e30aeda9..1ee76aa7e9 100644
--- a/zeppelin-web-angular/src/app/services/runtime-compiler.service.ts
+++ b/zeppelin-web-angular/src/app/services/runtime-compiler.service.ts
@@ -33,9 +33,14 @@ export class DynamicTemplateError {
   providedIn: 'root'
 })
 export class RuntimeCompilerService {
+  constructor(
+    private compiler: Compiler,
+    private ngZService: NgZService
+  ) {}
+
   public async createAndCompileTemplate(paragraphId: string, template: 
string): Promise<DynamicTemplate> {
     const ngZService = this.ngZService;
-    const dynamicComponent = Component({ template, selector: 
`dynamic-${paragraphId}` })(
+    const dynamicComponent = Component({ template, selector: 
`dynamic-${paragraphId}`, standalone: false })(
       class DynamicTemplateComponent {
         z = {
           set: (key: string, value: unknown, id: string) => 
ngZService.setContextValue(key, value, id),
@@ -43,10 +48,16 @@ export class RuntimeCompilerService {
           run: (id: string) => ngZService.runParagraph(id)
         };
 
+        readonly #releaseContext: () => void;
+
         constructor() {
-          ngZService.bindParagraph(paragraphId, this as Record<string, 
unknown>);
+          this.#releaseContext = ngZService.bindParagraphContext(paragraphId, 
this as Record<string, unknown>);
           Object.freeze(this.z);
         }
+
+        ngOnDestroy() {
+          this.#releaseContext();
+        }
       }
     );
     const dynamicModule = NgModule({
@@ -63,9 +74,4 @@ export class RuntimeCompilerService {
       throw new DynamicTemplateError(`${e}`);
     }
   }
-
-  constructor(
-    private compiler: Compiler,
-    private ngZService: NgZService
-  ) {}
 }

Reply via email to