diff --git a/frontend/lib/src/AppNode.ts b/frontend/lib/src/AppNode.ts index ab6ca8f9183..1d47a88743a 100644 --- a/frontend/lib/src/AppNode.ts +++ b/frontend/lib/src/AppNode.ts @@ -14,7 +14,11 @@ * limitations under the License. */ -export type { AppNode } from "./render-tree/AppNode.interface" +export { + NO_SCRIPT_RUN_ID, + type AppNode, +} from "./render-tree/AppNode.interface" export { AppRoot } from "./render-tree/AppRoot" export { BlockNode } from "./render-tree/BlockNode" export { ElementNode } from "./render-tree/ElementNode" +export { TransientNode } from "./render-tree/TransientNode" diff --git a/frontend/lib/src/components/core/Block/RenderNodeVisitor.tsx b/frontend/lib/src/components/core/Block/RenderNodeVisitor.tsx index 4a2e185277a..56aa3be3972 100644 --- a/frontend/lib/src/components/core/Block/RenderNodeVisitor.tsx +++ b/frontend/lib/src/components/core/Block/RenderNodeVisitor.tsx @@ -16,7 +16,7 @@ import { ReactElement } from "react" -import { BlockNode, ElementNode } from "~lib/AppNode" +import { BlockNode, ElementNode, TransientNode } from "~lib/AppNode" import { AppNodeVisitor } from "~lib/render-tree/visitors/AppNodeVisitor.interface" import { getElementId } from "~lib/util/utils" @@ -82,6 +82,11 @@ export class RenderNodeVisitor return renderer } + visitTransientNode(_node: TransientNode): OptionalReactElement { + // Transient nodes are rendered outside of the context this visitor is used in + return null + } + visitElementNode(node: ElementNode): OptionalReactElement { // Put node in childProps instead of passing as a node={node} prop in React to // guarantee it doesn't get overwritten by {...childProps}. diff --git a/frontend/lib/src/index.ts b/frontend/lib/src/index.ts index edfbd3c2091..7935511d2bc 100644 --- a/frontend/lib/src/index.ts +++ b/frontend/lib/src/index.ts @@ -18,7 +18,7 @@ import "@streamlit/utils" // These imports are each exported specifically in order to minimize public apis. export type { LibConfig } from "@streamlit/connection" -export { AppRoot, BlockNode, ElementNode } from "./AppNode" +export { AppRoot, BlockNode, ElementNode, TransientNode } from "./AppNode" export { ContainerContentsWrapper, VerticalBlock, diff --git a/frontend/lib/src/render-tree/BlockNode.test.ts b/frontend/lib/src/render-tree/BlockNode.test.ts index a5751e3730e..f4f67b9cf62 100644 --- a/frontend/lib/src/render-tree/BlockNode.test.ts +++ b/frontend/lib/src/render-tree/BlockNode.test.ts @@ -82,6 +82,7 @@ describe("BlockNode", () => { const mockVisitor = { visitElementNode: vi.fn().mockReturnValue("element-result"), visitBlockNode: vi.fn().mockReturnValue("block-result"), + visitTransientNode: vi.fn().mockReturnValue("transient-result"), } const result = node.accept(mockVisitor) @@ -96,6 +97,7 @@ describe("BlockNode", () => { const identityVisitor = { visitElementNode: vi.fn(), visitBlockNode: vi.fn().mockReturnValue(node), + visitTransientNode: vi.fn(), } const result = node.accept(identityVisitor) @@ -108,6 +110,7 @@ describe("BlockNode", () => { const transformVisitor = { visitElementNode: vi.fn(), visitBlockNode: vi.fn().mockReturnValue(block([text("transformed")])), + visitTransientNode: vi.fn(), } const result = originalNode.accept(transformVisitor) diff --git a/frontend/lib/src/render-tree/ElementNode.test.ts b/frontend/lib/src/render-tree/ElementNode.test.ts index 5a0688903b6..0944d41c575 100644 --- a/frontend/lib/src/render-tree/ElementNode.test.ts +++ b/frontend/lib/src/render-tree/ElementNode.test.ts @@ -426,6 +426,7 @@ describe("ElementNode.accept", () => { const mockVisitor = { visitElementNode: vi.fn().mockReturnValue("element-result"), visitBlockNode: vi.fn().mockReturnValue("block-result"), + visitTransientNode: vi.fn().mockReturnValue("transient-result"), } const result = node.accept(mockVisitor) @@ -440,6 +441,7 @@ describe("ElementNode.accept", () => { const identityVisitor = { visitElementNode: vi.fn().mockReturnValue(node), visitBlockNode: vi.fn(), + visitTransientNode: vi.fn(), } const result = node.accept(identityVisitor) @@ -452,6 +454,7 @@ describe("ElementNode.accept", () => { const nullVisitor = { visitElementNode: vi.fn().mockReturnValue(undefined), visitBlockNode: vi.fn(), + visitTransientNode: vi.fn(), } const result = node.accept(nullVisitor) diff --git a/frontend/lib/src/render-tree/TransientNode.test.ts b/frontend/lib/src/render-tree/TransientNode.test.ts new file mode 100644 index 00000000000..5f14da8672c --- /dev/null +++ b/frontend/lib/src/render-tree/TransientNode.test.ts @@ -0,0 +1,87 @@ +/** + * Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022-2025) + * + * 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 { text } from "~lib/render-tree/test-utils" +import { TransientNode } from "~lib/render-tree/TransientNode" +import { AppNodeVisitor } from "~lib/render-tree/visitors/AppNodeVisitor.interface" +import { + DebugVisitor, + MAX_HASH_LENGTH, +} from "~lib/render-tree/visitors/DebugVisitor" + +describe("TransientNode", () => { + describe("constructor defaults", () => { + it("sets defaults for transientNodes and deltaMsgReceivedAt", () => { + const now = 123456789 + const spy = vi.spyOn(Date, "now").mockReturnValue(now) + + const anchor = text("anchor") + const node = new TransientNode("run-1", anchor) + + expect(node.scriptRunId).toBe("run-1") + expect(node.anchor).toBe(anchor) + expect(Array.isArray(node.transientNodes)).toBe(true) + expect(node.transientNodes.length).toBe(0) + expect(node.deltaMsgReceivedAt).toBe(now) + + spy.mockRestore() + }) + + it("uses provided transient nodes and timestamp", () => { + const t1 = text("t1") + const t2 = text("t2") + const node = new TransientNode("run-2", undefined, [t1, t2], 42) + + expect(node.transientNodes).toEqual([t1, t2]) + expect(node.deltaMsgReceivedAt).toBe(42) + }) + }) + + describe("accept + debug", () => { + it("accepts a visitor and returns its value", () => { + const node = new TransientNode("run-v", text("a"), [], 1) + const visitor: AppNodeVisitor = { + visitBlockNode: vi.fn(), + visitElementNode: vi.fn(), + visitTransientNode: vi.fn().mockReturnValue("ok"), + } + + const out = node.accept(visitor) + expect(out).toBe("ok") + expect(visitor.visitTransientNode).toHaveBeenCalledWith(node) + }) + + it("produces a human-readable debug string including anchor and transients", () => { + const anchor = text("anchor-text") + const t1 = text("t1") + const t2 = text("t2") + const node = new TransientNode("run-xyz", anchor, [t1, t2], 5) + + const debug = node.debug() + + expect(debug.split("\n")[0]).toBe( + `└── TransientNode [2 transient] (run: ${"run-xyz".substring(0, MAX_HASH_LENGTH)})` + ) + expect(debug).toContain("anchor:") + expect(debug).toContain("ElementNode [text]") + expect(debug).toContain("transient nodes:") + + // Also validate DebugVisitor can be used directly + const viaVisitor = node.accept(new DebugVisitor()) + expect(viaVisitor).toBe(debug) + }) + }) +}) diff --git a/frontend/lib/src/render-tree/TransientNode.ts b/frontend/lib/src/render-tree/TransientNode.ts new file mode 100644 index 00000000000..8992fc64b38 --- /dev/null +++ b/frontend/lib/src/render-tree/TransientNode.ts @@ -0,0 +1,61 @@ +/** + * Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022-2025) + * + * 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 { AppNode } from "./AppNode.interface" +import { ElementNode } from "./ElementNode" +import { AppNodeVisitor } from "./visitors/AppNodeVisitor.interface" +import { DebugVisitor } from "./visitors/DebugVisitor" + +/** + * A TransientNode represents a transient Node in the tree that can hold + * multiple transient Elements. It maintains an anchor node, which is the node + * that would persist after the transient nodes are cleared. + */ + +export class TransientNode implements AppNode { + readonly anchor?: AppNode + readonly transientNodes: ElementNode[] + readonly scriptRunId: string + readonly deltaMsgReceivedAt?: number + readonly fragmentId?: string + readonly activeScriptHash?: string + + constructor( + scriptRunId: string, + anchor?: AppNode, + transientNodes?: ElementNode[], + deltaMsgReceivedAt?: number + ) { + this.scriptRunId = scriptRunId + this.anchor = anchor + this.transientNodes = transientNodes ?? [] + this.deltaMsgReceivedAt = deltaMsgReceivedAt ?? Date.now() + + // We explicitly set these to undefined because transient nodes + // are not associated with a fragment or a script hash directly. + // The anchor node will have the fragmentId and activeScriptHash. + this.fragmentId = undefined + this.activeScriptHash = undefined + } + + accept(visitor: AppNodeVisitor): T { + return visitor.visitTransientNode(this) + } + + public debug(): string { + return this.accept(new DebugVisitor()) + } +} diff --git a/frontend/lib/src/render-tree/visitors/AppNodeVisitor.interface.ts b/frontend/lib/src/render-tree/visitors/AppNodeVisitor.interface.ts index 76c5b12c6e0..593e2250fb7 100644 --- a/frontend/lib/src/render-tree/visitors/AppNodeVisitor.interface.ts +++ b/frontend/lib/src/render-tree/visitors/AppNodeVisitor.interface.ts @@ -16,8 +16,10 @@ import { BlockNode } from "~lib/render-tree/BlockNode" import { ElementNode } from "~lib/render-tree/ElementNode" +import { TransientNode } from "~lib/render-tree/TransientNode" export interface AppNodeVisitor { visitBlockNode(node: BlockNode): T visitElementNode(node: ElementNode): T + visitTransientNode(node: TransientNode): T } diff --git a/frontend/lib/src/render-tree/visitors/ClearStaleNodeVisitor.ts b/frontend/lib/src/render-tree/visitors/ClearStaleNodeVisitor.ts index ae2a287660b..fc91ac5f87c 100644 --- a/frontend/lib/src/render-tree/visitors/ClearStaleNodeVisitor.ts +++ b/frontend/lib/src/render-tree/visitors/ClearStaleNodeVisitor.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { AppNode, BlockNode, ElementNode } from "~lib/AppNode" +import { AppNode, BlockNode, ElementNode, TransientNode } from "~lib/AppNode" import { AppNodeVisitor } from "~lib/render-tree/visitors/AppNodeVisitor.interface" /** @@ -138,4 +138,8 @@ export class ClearStaleNodeVisitor } return node.scriptRunId === this.currentScriptRunId ? node : undefined } + + visitTransientNode(_node: TransientNode): AppNode | undefined { + throw new Error("Method not implemented.") + } } diff --git a/frontend/lib/src/render-tree/visitors/DebugVisitor.test.ts b/frontend/lib/src/render-tree/visitors/DebugVisitor.test.ts index 806560d6590..321a1a137ca 100644 --- a/frontend/lib/src/render-tree/visitors/DebugVisitor.test.ts +++ b/frontend/lib/src/render-tree/visitors/DebugVisitor.test.ts @@ -16,8 +16,7 @@ import { Element, ForwardMsgMetadata } from "@streamlit/protobuf" -import { NO_SCRIPT_RUN_ID } from "~lib/render-tree/AppNode.interface" -import { ElementNode } from "~lib/render-tree/ElementNode" +import { ElementNode, NO_SCRIPT_RUN_ID, TransientNode } from "~lib/AppNode" import { block, FAKE_SCRIPT_HASH, text } from "~lib/render-tree/test-utils" import { DebugVisitor, MAX_HASH_LENGTH } from "./DebugVisitor" @@ -130,3 +129,30 @@ describe("DebugVisitor.generateDebugString", () => { expect(out.startsWith("X ├── BlockNode")).toBe(true) }) }) + +describe("DebugVisitor.visitTransientNode", () => { + it("prints anchor and transient nodes with proper structure and truncated run id", () => { + const anchor = text("anchor") + const t1 = text("one") + const t2 = text("two") + const runId = "abcdef012345" + + const transient = new TransientNode(runId, anchor, [t1, t2], 1) + + const out = transient.accept(new DebugVisitor()) + + // Root line with truncated run id + expect(out.split("\n")[0]).toBe( + `└── TransientNode [2 transient] (run: ${runId.substring(0, MAX_HASH_LENGTH)})` + ) + + // Contains anchor section and its rendered element + expect(out).toContain("anchor:") + expect(out).toContain('ElementNode [text] "anchor"') + + // Contains transient nodes section and both elements + expect(out).toContain("transient nodes:") + expect(out).toContain('ElementNode [text] "one"') + expect(out).toContain('ElementNode [text] "two"') + }) +}) diff --git a/frontend/lib/src/render-tree/visitors/DebugVisitor.ts b/frontend/lib/src/render-tree/visitors/DebugVisitor.ts index bf09c99b42f..9035dfd7f20 100644 --- a/frontend/lib/src/render-tree/visitors/DebugVisitor.ts +++ b/frontend/lib/src/render-tree/visitors/DebugVisitor.ts @@ -14,9 +14,13 @@ * limitations under the License. */ -import { AppNode, NO_SCRIPT_RUN_ID } from "~lib/render-tree/AppNode.interface" -import { BlockNode } from "~lib/render-tree/BlockNode" -import { ElementNode } from "~lib/render-tree/ElementNode" +import { + AppNode, + BlockNode, + ElementNode, + NO_SCRIPT_RUN_ID, + TransientNode, +} from "~lib/AppNode" import { AppNodeVisitor } from "./AppNodeVisitor.interface" @@ -78,6 +82,40 @@ export class DebugVisitor implements AppNodeVisitor { return result } + visitTransientNode(node: TransientNode): string { + const connector = this.isLast ? "└── " : "├── " + const childPrefix = this.prefix + (this.isLast ? " " : "│ ") + + let result = `${this.prefix}${connector}TransientNode [${node.transientNodes.length} transient]` + if (node.scriptRunId !== NO_SCRIPT_RUN_ID) { + result += ` (run: ${node.scriptRunId.substring(0, MAX_HASH_LENGTH)})` + } + result += "\n" + + if (node.anchor) { + result += `${childPrefix}├── anchor:\n` + const anchorVisitor = new DebugVisitor(childPrefix + "│ ", true) + result += node.anchor.accept(anchorVisitor) + } + + if (node.transientNodes.length > 0) { + result += `${childPrefix}└── transient nodes:\n` + + node.transientNodes.forEach((transientNode, index) => { + const isLastTransient = index === node.transientNodes.length - 1 + const transientConnector = isLastTransient ? "└── " : "├── " + const transientChildPrefix = + childPrefix + " " + (isLastTransient ? " " : "│ ") + + result += `${childPrefix} ${transientConnector}:\n` + const transientVisitor = new DebugVisitor(transientChildPrefix, true) + result += transientNode.accept(transientVisitor) + }) + } + + return result + } + /** * Static helper method to generate debug output for any AppNode. */ diff --git a/frontend/lib/src/render-tree/visitors/ElementsSetVisitor.ts b/frontend/lib/src/render-tree/visitors/ElementsSetVisitor.ts index ddf0d690e6d..e7888bba945 100644 --- a/frontend/lib/src/render-tree/visitors/ElementsSetVisitor.ts +++ b/frontend/lib/src/render-tree/visitors/ElementsSetVisitor.ts @@ -19,6 +19,7 @@ import { Element } from "@streamlit/protobuf" import { AppNode } from "~lib/render-tree/AppNode.interface" import { BlockNode } from "~lib/render-tree/BlockNode" import { ElementNode } from "~lib/render-tree/ElementNode" +import { TransientNode } from "~lib/render-tree/TransientNode" import type { AppNodeVisitor } from "~lib/render-tree/visitors/AppNodeVisitor.interface" /** @@ -56,6 +57,10 @@ export class ElementsSetVisitor implements AppNodeVisitor> { return this.elements } + visitTransientNode(_node: TransientNode): Set { + throw new Error("Method not implemented.") + } + /** * Static convenience method to collect all elements from a node tree. */ diff --git a/frontend/lib/src/render-tree/visitors/FilterMainScriptElementsVisitor.ts b/frontend/lib/src/render-tree/visitors/FilterMainScriptElementsVisitor.ts index 25a10f03055..b460f2bcdc6 100644 --- a/frontend/lib/src/render-tree/visitors/FilterMainScriptElementsVisitor.ts +++ b/frontend/lib/src/render-tree/visitors/FilterMainScriptElementsVisitor.ts @@ -17,6 +17,7 @@ import { AppNode } from "~lib/render-tree/AppNode.interface" import { BlockNode } from "~lib/render-tree/BlockNode" import { ElementNode } from "~lib/render-tree/ElementNode" +import { TransientNode } from "~lib/render-tree/TransientNode" import { AppNodeVisitor } from "./AppNodeVisitor.interface" @@ -80,6 +81,10 @@ export class FilterMainScriptElementsVisitor ) } + visitTransientNode(_node: TransientNode): AppNode | undefined { + throw new Error("Method not implemented.") + } + /** * Static convenience method to filter a node tree based on mainScriptHash. */ diff --git a/frontend/lib/src/render-tree/visitors/GetNodeByDeltaPathVisitor.ts b/frontend/lib/src/render-tree/visitors/GetNodeByDeltaPathVisitor.ts index 7f4191cee55..6c8fed6cf3d 100644 --- a/frontend/lib/src/render-tree/visitors/GetNodeByDeltaPathVisitor.ts +++ b/frontend/lib/src/render-tree/visitors/GetNodeByDeltaPathVisitor.ts @@ -17,6 +17,7 @@ import { AppNode } from "~lib/render-tree/AppNode.interface" import { BlockNode } from "~lib/render-tree/BlockNode" import { ElementNode } from "~lib/render-tree/ElementNode" +import { TransientNode } from "~lib/render-tree/TransientNode" import { AppNodeVisitor } from "./AppNodeVisitor.interface" @@ -65,6 +66,10 @@ export class GetNodeByDeltaPathVisitor return node.children[currentIndex].accept(childVisitor) } + visitTransientNode(_node: TransientNode): AppNode | undefined { + throw new Error("Method not implemented.") + } + /** * Static convenience method to get a node at a delta path. */ diff --git a/frontend/lib/src/render-tree/visitors/SetNodeByDeltaPathVisitor.ts b/frontend/lib/src/render-tree/visitors/SetNodeByDeltaPathVisitor.ts index 0b10a6b0c6c..abced05189c 100644 --- a/frontend/lib/src/render-tree/visitors/SetNodeByDeltaPathVisitor.ts +++ b/frontend/lib/src/render-tree/visitors/SetNodeByDeltaPathVisitor.ts @@ -17,6 +17,7 @@ import { AppNode } from "~lib/render-tree/AppNode.interface" import { BlockNode } from "~lib/render-tree/BlockNode" import { ElementNode } from "~lib/render-tree/ElementNode" +import { TransientNode } from "~lib/render-tree/TransientNode" import { AppNodeVisitor } from "./AppNodeVisitor.interface" @@ -87,6 +88,10 @@ export class SetNodeByDeltaPathVisitor implements AppNodeVisitor { ) } + visitTransientNode(_node: TransientNode): AppNode { + throw new Error("Method not implemented.") + } + /** * Static convenience method to set a node at a delta path. */