Repository: microsoft/FluidFramework
Pull request #24523: feat(devtools-core): Visualize TreeDataObject in Devtools
Author: login: jikim-msft
Labels: name: dependencies
description: Pull requests that update a dependency file
name: base: main
description: PRs targeted against main branch
Created: 2025-05-03T00:55:24Z | Merged: 2025-05-23T22:06:16Z

#### Description

[34572](https://dev.azure.com/fluidframework/internal/_sprints/taskboard/Team%20Rohan/internal/Team%20Rohan%20-%202025.Fairy?workitem=34572)

This PR adds the following:
- Visualization logic for the [TreeDataObject](https://github.com/microsoft/FluidFramework/blob/2a8b6abc099d98130e84384bffe3e9f0b60e0c03/packages/framework/aqueduct/src/data-objects/treeDataObject.ts#L46) class in `DefaultVisualizer.ts` and `DataVisualization.ts`
    - `isTreeDataObject` to determine whether the object is the indeed the `TreeDataObject` type
    - `visualizeTreeDataObject` extracts the root `sharedTree: ISharedObject` and passes it to the existing `visualizeSharedTree` visualizer (similar to the [DataObjectVisualization](https://github.com/microsoft/FluidFramework/pull/24154) work)
- Interactive UI component in the `devtools-test-app`
    - The UI components (`TodoListView.tsx` & `TodoItem.tsx`) are almost identical to the [data-object/todo](https://github.com/microsoft/FluidFramework/tree/main/examples/data-objects/todo/src) app.  

#### Note
- Exposing the `sharedTree` in `TreeDataObject` was required as the visualizer needed the access the schema and tree root [24593](https://github.com/microsoft/FluidFramework/pull/24593)
- Closed [24591](https://github.com/microsoft/FluidFramework/pull/24591) since the root-cause solution to the `devtools-view` failing to immeditaly apply the change in the underlying data was NOT fixing the dependency array but to map the correct pair of `fluidObjectId` & `visualizerNode` of `visualizerNodes: Map<FluidObjectId, VisualizerNode>` (documented at `DataVisualization.ts`'s `registerVisualizerForVisualizableObject()`. 


Diff:
diff --git a/packages/tools/devtools/devtools-core/src/data-visualization/DataVisualization.ts b/packages/tools/devtools/devtools-core/src/data-visualization/DataVisualization.ts
index eed1d92755d1..f782fe0bc7cc 100644
--- a/packages/tools/devtools/devtools-core/src/data-visualization/DataVisualization.ts
+++ b/packages/tools/devtools/devtools-core/src/data-visualization/DataVisualization.ts
@@ -7,7 +7,7 @@
 /* eslint-disable @typescript-eslint/consistent-indexed-object-style */
 
 import { TypedEventEmitter } from "@fluid-internal/client-utils";
-import { DataObject } from "@fluidframework/aqueduct/internal";
+import { DataObject, TreeDataObject } from "@fluidframework/aqueduct/internal";
 import type {
 	IDisposable,
 	IEvent,
@@ -21,7 +21,11 @@ import type { ISharedObject, SharedObject } from "@fluidframework/shared-object-
 
 import type { FluidObjectId } from "../CommonInterfaces.js";
 
-import { visualizeDataObject, visualizeUnknownSharedObject } from "./DefaultVisualizers.js";
+import {
+	createDataObjectVisualizer,
+	createTreeDataObjectVisualizer,
+	visualizeUnknownSharedObject,
+} from "./DefaultVisualizers.js";
 import {
 	type FluidObjectNode,
 	type Primitive,
@@ -66,19 +70,6 @@ export type VisualizeSharedObject = (
 	visualizeChildData: VisualizeChildData,
 ) => Promise<FluidObjectNode>;
 
-/**
- * Generates a visual description of the provided {@link DataObject}'s current state.
- *
- * @param dataObject - The object whose data will be rendered.
- * @param visualizeChildData - Callback to render child content of the shared object.
- *
- * @returns A visual tree representation of the provided `dataObject`.
- */
-export type VisualizeDataObject = (
-	dataObject: DataObject,
-	visualizeChildData: VisualizeChildData,
-) => Promise<FluidObjectNode>;
-
 /**
  * Recursively renders child contents of a {@link @fluidframework/shared-object-base#ISharedObject}.
  *
@@ -100,7 +91,7 @@ export type VisualizeChildData = (data: unknown) => Promise<VisualChildNode>;
 /**
  * Utility type for a union of things that can be visualized.
  */
-export type VisualizableFluidObject = ISharedObject | DataObject;
+export type VisualizableFluidObject = ISharedObject | DataObject | TreeDataObject<unknown>;
 
 /**
  * Specifies renderers for different {@link @fluidframework/shared-object-base#ISharedObject} types.
@@ -249,18 +240,34 @@ export class DataVisualizerGraph
 	private registerVisualizerForVisualizableObject(
 		visualizableObject: VisualizableFluidObject,
 	): FluidObjectId {
-		if (!this.visualizerNodes.has(visualizableObject.id)) {
-			// Create visualizer node for the shared object
-			const visualizationFunction = isDataObject(visualizableObject)
-				? visualizeDataObject
-				: (this.visualizers[visualizableObject.attributes.type] ??
-					visualizeUnknownSharedObject);
+		// Store type check results to avoid recomputing
+		const isDataObj = isDataObject(visualizableObject);
+		const isTreeDataObj = isTreeDataObject(visualizableObject);
+
+		let rootSharedObject: ISharedObject;
+		let fluidObjectId: string;
+		let visualizationFunction: VisualizeSharedObject;
+
+		if (isDataObj) {
+			rootSharedObject = (visualizableObject as unknown as { readonly root: ISharedDirectory })
+				.root;
+			fluidObjectId = `${visualizableObject.id}-${rootSharedObject.id}`;
+			visualizationFunction = createDataObjectVisualizer(visualizableObject.id);
+		} else if (isTreeDataObj) {
+			rootSharedObject = visualizableObject.sharedTree as unknown as ISharedObject;
+			fluidObjectId = `${visualizableObject.id}-${rootSharedObject.id}`;
+			visualizationFunction = createTreeDataObjectVisualizer(visualizableObject.id);
+		} else {
+			rootSharedObject = visualizableObject;
+			fluidObjectId = visualizableObject.id;
+			visualizationFunction =
+				(this.visualizers[visualizableObject.attributes.type] as VisualizeSharedObject) ??
+				visualizeUnknownSharedObject;
+		}
 
+		if (!this.visualizerNodes.has(fluidObjectId)) {
 			const visualizerNode = new VisualizerNode(
-				// Double-casting `sharedObject` is necessary for `DataObject` visualization, because the `root` property is inaccessible in `DataObject` (private).
-				isDataObject(visualizableObject)
-					? (visualizableObject as unknown as { readonly root: ISharedDirectory }).root
-					: visualizableObject,
+				rootSharedObject,
 				visualizationFunction,
 				async (handle) => this.registerVisualizerForHandle(handle),
 			);
@@ -269,9 +276,9 @@ export class DataVisualizerGraph
 			visualizerNode.on("update", this.onVisualUpdateHandler);
 
 			// Add the visualizer node to our collection
-			this.visualizerNodes.set(visualizableObject.id, visualizerNode);
+			this.visualizerNodes.set(fluidObjectId, visualizerNode);
 		}
-		return visualizableObject.id;
+		return fluidObjectId;
 	}
 
 	/**
@@ -290,7 +297,7 @@ export class DataVisualizerGraph
 	): Promise<FluidObjectId | undefined> {
 		const resolvedObject = await handle.get();
 
-		if (isDataObject(resolvedObject)) {
+		if (isDataObject(resolvedObject) || isTreeDataObject(resolvedObject)) {
 			return this.registerVisualizerForVisualizableObject(resolvedObject);
 		}
 
@@ -529,7 +536,7 @@ function isSharedObject(value: unknown): value is ISharedObject {
  * Determines whether or not the provided value is an {@link DataObject} using `instanceof`, for the purposes of this library.
  * @remarks
  * Uses `instanceof` over checking specific properties or methods, because we decided that a version mix-up with
- * {@link @fluidframework/aqueduct#}  is unlikely between devtools and end-user applications, and we don't support it anyway.
+ * {@link @fluidframework/aqueduct#} is unlikely between devtools and end-user applications, and we don't support it anyway.
  */
 function isDataObject(value: unknown): value is DataObject {
 	if (
@@ -549,3 +556,33 @@ function isDataObject(value: unknown): value is DataObject {
 
 	return false;
 }
+
+/**
+ * Determines whether or not the provided value is a {@link TreeDataObject} using `instanceof`, for the purposes of this library.
+ * @remarks
+ * Tries to use `instanceof` because we decided that a version mix-up with
+ * {@link @fluidframework/aqueduct#} is unlikely between devtools and end-user applications, and we don't support it anyway.
+ * In addition, we check for the presence of key properties that make a `TreeDataObject` unique:
+ * - {@link TreeDataObject#sharedTree | sharedTree} getter
+ * - {@link TreeDataObject#treeView | treeView} getter
+ * - {@link TreeDataObject#initializeInternal | initializeInternal} method
+ */
+function isTreeDataObject(value: unknown): value is TreeDataObject<unknown> {
+	if (
+		value instanceof TreeDataObject ||
+		(typeof (value as TreeDataObject<unknown>).initializeInternal === "function" &&
+			Object.getOwnPropertyDescriptor(Object.getPrototypeOf(value), "sharedTree")?.get !==
+				undefined)
+	) {
+		const tree = (value as TreeDataObject<unknown>).sharedTree;
+		if (tree === undefined) {
+			throw new Error(
+				"TreeDataObject must have a `sharedTree` property, but it was undefined.",
+			);
+		}
+
+		return true;
+	}
+
+	return false;
+}
diff --git a/packages/tools/devtools/devtools-core/src/data-visualization/DefaultVisualizers.ts b/packages/tools/devtools/devtools-core/src/data-visualization/DefaultVisualizers.ts
index 16508429ccce..a78815fe1007 100644
--- a/packages/tools/devtools/devtools-core/src/data-visualization/DefaultVisualizers.ts
+++ b/packages/tools/devtools/devtools-core/src/data-visualization/DefaultVisualizers.ts
@@ -104,26 +104,56 @@ export const visualizeSharedCell: VisualizeSharedObject = async (
 };
 
 /**
- * Default {@link VisualizeSharedObject} for {@link DataObject}.
- * @remarks This takes in a `root` of type {@link ISharedDirectory} from {@link DataObject} and visualizes its children.
+ * Creates a visualizer function that captures the parent object's ID for use in generating the fluidObjectId.
+ * @param parentId - The ID of the parent DataObject to use in generating unique fluidObjectIds
+ * @returns A VisualizeSharedObject function that will use the parentId in its fluidObjectId generation
  */
-export const visualizeDataObject: VisualizeSharedObject = async (
-	dataObjectRoot: ISharedObject,
-	visualizeChildData: VisualizeChildData,
-): Promise<FluidObjectTreeNode> => {
-	const renderedChildData = (await visualizeSharedDirectory(
-		dataObjectRoot,
-		visualizeChildData,
-	)) as FluidObjectTreeNode; // TODO: Refactor the visualizer to accept generic type to avoid type casting.
+export function createDataObjectVisualizer(parentId: string): VisualizeSharedObject {
+	return async (
+		dataObjectRoot: ISharedObject,
+		visualizeChildData: VisualizeChildData,
+	): Promise<FluidObjectTreeNode> => {
+		const fluidObjectId = `${parentId}-${dataObjectRoot.id}`;
+		const renderedChildData = (await visualizeSharedDirectory(
+			dataObjectRoot,
+			visualizeChildData,
+		)) as FluidObjectTreeNode;
 
-	return {
-		fluidObjectId: dataObjectRoot.id,
-		children: renderedChildData.children,
-		metadata: renderedChildData.metadata,
-		typeMetadata: "DataObject",
-		nodeKind: VisualNodeKind.FluidTreeNode,
+		return {
+			fluidObjectId,
+			children: renderedChildData.children,
+			metadata: renderedChildData.metadata,
+			typeMetadata: "DataObject",
+			nodeKind: VisualNodeKind.FluidTreeNode,
+		};
 	};
-};
+}
+
+/**
+ * Creates a visualizer function that captures the parent object's ID for use in generating the fluidObjectId.
+ * @param parentId - The ID of the parent TreeDataObject to use in generating unique fluidObjectIds
+ * @returns A VisualizeSharedObject function that will use the parentId in its fluidObjectId generation
+ */
+export function createTreeDataObjectVisualizer(parentId: string): VisualizeSharedObject {
+	return async (
+		rootTree: ISharedObject,
+		visualizeChildData: VisualizeChildData,
+	): Promise<FluidObjectTreeNode> => {
+		const fluidObjectId = `${parentId}-${rootTree.id}`;
+		const renderedChildData = (await visualizeSharedTree(
+			rootTree,
+			visualizeChildData,
+		)) as FluidObjectTreeNode;
+
+		return {
+			fluidObjectId,
+			children: renderedChildData.children,
+			metadata: renderedChildData.metadata,
+			typeMetadata: "TreeDataObject",
+			nodeKind: VisualNodeKind.FluidTreeNode,
+		};
+	};
+}
 
 /**
  * Default {@link VisualizeSharedObject} for {@link SharedCounter}.
@@ -358,7 +388,7 @@ export const visualizeUnknownSharedObject: VisualizeSharedObject = async (
 
 /**
  * List of default visualizers included in the library.
- * @remarks {@link @fluidframework/aqueduct#DataObject} does not have type information, thus not included in the list.
+ * @remarks {@link @fluidframework/aqueduct#DataObject} & {@link @fluidframework/aqueduct#TreeDataObject} do not have type information and thus are not included in the list.
  */
 export const defaultVisualizers: Record<string, VisualizeSharedObject> = {
 	[SharedCell.getFactory().type]: visualizeSharedCell,
diff --git a/packages/tools/devtools/devtools-test-app/package.json b/packages/tools/devtools/devtools-test-app/package.json
index 0802c51731f8..91e04f9a9a9b 100644
--- a/packages/tools/devtools/devtools-test-app/package.json
+++ b/packages/tools/devtools/devtools-test-app/package.json
@@ -45,6 +45,7 @@
 		"@fluentui/react-components": "^9.47.5",
 		"@fluentui/react-icons": "^2.0.233",
 		"@fluid-example/example-utils": "workspace:~",
+		"@fluid-experimental/tree-react-api": "workspace:~",
 		"@fluid-internal/client-utils": "workspace:~",
 		"@fluid-internal/devtools-view": "workspace:~",
 		"@fluidframework/aqueduct": "workspace:~",
@@ -68,7 +69,8 @@
 		"re-resizable": "^6.9.9",
 		"react": "^18.3.1",
 		"react-dom": "^18.3.1",
-		"scheduler": "^0.20.0"
+		"scheduler": "^0.20.0",
+		"uuid": "^9.0.0"
 	},
 	"devDependencies": {
 		"@biomejs/biome": "~1.9.3",
@@ -85,6 +87,7 @@
 		"@types/react": "^18.3.11",
 		"@types/react-dom": "^18.3.0",
 		"@types/testing-library__jest-dom": "^5.14.5",
+		"@types/uuid": "^9.0.2",
 		"concurrently": "^8.2.1",
 		"copyfiles": "^2.4.1",
 		"eslint": "~8.55.0",
diff --git a/packages/tools/devtools/devtools-test-app/src/App.tsx b/packages/tools/devtools/devtools-test-app/src/App.tsx
index 641665cb3ec6..42bcafbd417f 100644
--- a/packages/tools/devtools/devtools-test-app/src/App.tsx
+++ b/packages/tools/devtools/devtools-test-app/src/App.tsx
@@ -38,8 +38,8 @@ import {
 	loadExistingContainer,
 } from "./ClientUtilities.js";
 import type { IAppModel } from "./Container.js";
-import type { AppData } from "./FluidObject.js";
-import { CounterWidget, EmojiGrid } from "./widgets/index.js";
+import type { AppData, AppDataTree } from "./FluidObject.js";
+import { CounterWidget, EmojiGrid, TodoListView } from "./widgets/index.js";
 
 const sharedContainerKey: ContainerKey = "Shared Container";
 const privateContainerKey: ContainerKey = "Private Container";
@@ -271,6 +271,7 @@ function AppView(props: AppViewProps): React.ReactElement {
 			<EmojiMatrixView emojiMatrix={appData.emojiMatrix} />
 			<CounterView sharedCounter={appData.counter} />
 			<TextView sharedText={appData.text} />
+			<TodoAppView todoModel={appData.treeDataObject} />
 		</div>
 	);
 }
@@ -310,3 +311,13 @@ function EmojiMatrixView(props: EmojiMatrixViewProps): React.ReactElement {
 
 	return emojiMatrix === undefined ? <Spinner /> : <EmojiGrid emojiMatrix={emojiMatrix} />;
 }
+
+interface TodoAppViewProps {
+	todoModel: AppDataTree;
+}
+
+function TodoAppView(props: TodoAppViewProps): React.ReactElement {
+	const { todoModel } = props;
+
+	return todoModel === undefined ? <Spinner /> : <TodoListView todoModel={todoModel} />;
+}
diff --git a/packages/tools/devtools/devtools-test-app/src/FluidObject.ts b/packages/tools/devtools/devtools-test-app/src/FluidObject.ts
index 7d5c4af55579..c3831530bfd7 100644
--- a/packages/tools/devtools/devtools-test-app/src/FluidObject.ts
+++ b/packages/tools/devtools/devtools-test-app/src/FluidObject.ts
@@ -3,14 +3,99 @@
  * Licensed under the MIT License.
  */
 
-import { DataObject, DataObjectFactory } from "@fluidframework/aqueduct/internal";
+import {
+	DataObject,
+	DataObjectFactory,
+	PureDataObjectFactory,
+	TreeDataObject,
+} from "@fluidframework/aqueduct/internal";
 import { SharedCell } from "@fluidframework/cell/internal";
 import type { IFluidHandle, IFluidLoadable } from "@fluidframework/core-interfaces";
 import { SharedCounter } from "@fluidframework/counter/internal";
 import { SharedMatrix } from "@fluidframework/matrix/internal";
 import { SharedString } from "@fluidframework/sequence/internal";
 import { type ITree, SchemaFactory, TreeViewConfiguration } from "@fluidframework/tree";
-import { SharedTree } from "@fluidframework/tree/internal";
+import { SharedTree, type TreeView } from "@fluidframework/tree/internal";
+import { v4 as uuid } from "uuid";
+
+import { TodoList, TodoItem } from "./Schema.js";
+
+/**
+ * Props used when creating a new todo item.
+ */
+interface TodoItemProps {
+	/**
+	 * The initial text to populate the todo item's title with.
+	 * This value will be inserted into the shared string at index 0.
+	 */
+	readonly startingText: string;
+}
+
+/**
+ * Object so we can test {@link TreeDataObject} inside {@link AppData}.
+ */
+export class AppDataTree extends TreeDataObject<TreeView<typeof TodoList>> {
+	public static readonly Name = "@devtools-example/test-app-tree-data-object";
+
+	private readonly config = new TreeViewConfiguration({ schema: TodoList });
+	private static readonly factory = new PureDataObjectFactory<
+		TreeDataObject<TreeView<typeof TodoList>>
+	>(AppDataTree.Name, AppDataTree, [SharedString.getFactory(), SharedTree.getFactory()], {});
+
+	public static getFactory(): PureDataObjectFactory<
+		TreeDataObject<TreeView<typeof TodoList>>
+	> {
+		return AppDataTree.factory;
+	}
+
+	/**
+	 * Converts the underlying ITree into a typed TreeView using the provided schema configuration.
+	 *
+	 * @param tree - The ITree instance to view.
+	 * @returns A typed TreeView using the TodoList schema.
+	 */
+	public override generateView(tree: ITree): TreeView<typeof TodoList> {
+		return tree.viewWith(this.config) as unknown as TreeView<typeof TodoList>;
+	}
+
+	/**
+	 * Initializes the tree with a default title and empty todo item list.
+	 * @remarks Called during the initial creation of the data object.
+	 */
+	public override async initializingFirstTime(): Promise<void> {
+		const title = SharedString.create(this.runtime);
+		title.insertText(0, "Title");
+
+		this.treeView.initialize(new TodoList({ title: title.handle, items: [] }));
+	}
+
+	/**
+	 * Adds a new todo item to the list.
+	 *
+	 * @privateRemarks
+	 * This method was placed in the data object (instead of the TodoList schema class),
+	 * as we needed access to the runtime to create the `SharedString`.
+	 */
+	public async addTodoItem(props?: TodoItemProps): Promise<void> {
+		const title = SharedString.create(this.runtime);
+		const newItemText = props?.startingText ?? "New Item";
+		title.insertText(0, newItemText);
+		const description = SharedString.create(this.runtime);
+
+		const todoItem = new TodoItem({
+			title: title.handle,
+			description: description.handle,
+			completed: false,
+		});
+
+		// TODO: We should consider creating a separate field for date, so that we do not need to
+		// concatenate it to the id.
+		// Generate an ID that we can sort on later, and store the todo item.
+		const id = `${Date.now()}-${uuid()}`;
+
+		this.treeView.root.items.set(id, todoItem);
+	}
+}
 
 /**
  * Additional Data Object added to the {@link AppData}.
@@ -93,12 +178,18 @@ export class AppData extends DataObject {
 	 */
 	private readonly dataObjectKey = "shared-data-object";
 
+	/**
+	 * Key in the app's `rootMap` under which the {@link AppDataTree} object is stored.
+	 */
+	private readonly treeDataObjectKey = "tree-data-object";
+
 	// previous app's `rootMap`
 	private readonly _initialObjects: Record<string, IFluidLoadable> = {};
 	private _sharedTree: ITree | undefined;
 	private _text: SharedString | undefined;
 	private _counter: SharedCounter | undefined;
 	private _emojiMatrix: SharedMatrix | undefined;
+	private _treeDataObject: AppDataTree | undefined;
 
 	public get text(): SharedString {
 		if (this._text === undefined) {
@@ -128,6 +219,13 @@ export class AppData extends DataObject {
 		return this._sharedTree;
 	}
 
+	public get treeDataObject(): AppDataTree {
+		if (this._treeDataObject === undefined) {
+			throw new Error("The TreeDataObject was not initialized correctly");
+		}
+		return this._treeDataObject;
+	}
+
 	public getRootObject(): Record<string, IFluidLoadable> {
 		return this._initialObjects;
 	}
@@ -144,7 +242,10 @@ export class AppData extends DataObject {
 			SharedCell.getFactory(),
 			SharedTree.getFactory(),
 		],
-		registryEntries: new Map([AppDataTwo.getFactory().registryEntry]),
+		registryEntries: new Map([
+			AppDataTwo.getFactory().registryEntry,
+			AppDataTree.getFactory().registryEntry,
+		]),
 	});
 
 	public static getFactory(): DataObjectFactory<AppData> {
@@ -155,7 +256,7 @@ export class AppData extends DataObject {
 		// Create the shared objects and store their handles in the root SharedDirectory
 		const text = SharedString.create(this.runtime, this.sharedTextKey);
 		const counter = SharedCounter.create(this.runtime, this.sharedCounterKey);
-		const sharedTree = SharedTree.create(this.runtime);
+		const sharedTree = SharedTree.create(this.runtime, this.sharedTreeKey);
 
 		const emojiMatrix = SharedMatrix.create(this.runtime, this.emojiMatrixKey);
 		const matrixDimension = 2; // Height and Width
@@ -170,12 +271,14 @@ export class AppData extends DataObject {
 		this.populateSharedTree(sharedTree);
 
 		const appDataTwo = await AppDataTwo.getFactory().createChildInstance(this.context);
+		const appDataTree = await AppDataTree.getFactory().createChildInstance(this.context);
 
 		this.root.set(this.sharedTextKey, text.handle);
 		this.root.set(this.sharedCounterKey, counter.handle);
 		this.root.set(this.emojiMatrixKey, emojiMatrix.handle);
 		this.root.set(this.sharedTreeKey, sharedTree.handle);
 		this.root.set(this.dataObjectKey, appDataTwo.handle);
+		this.root.set(this.treeDataObjectKey, appDataTree.handle);
 
 		// Also set a couple of primitives for testing the debug view
 		this.root.set("numeric-value", 42);
@@ -201,6 +304,9 @@ export class AppData extends DataObject {
 		this._emojiMatrix = await this.root
 			.get<IFluidHandle<SharedMatrix>>(this.emojiMatrixKey)
 			?.get();
+		this._treeDataObject = await this.root
+			.get<IFluidHandle<AppDataTree>>(this.treeDataObjectKey)
+			?.get();
 		const sharedTree = await this.root.get<IFluidHandle<ITree>>(this.sharedTreeKey)?.get();
 		if (sharedTree === undefined) {
 			throw new Error("SharedTree was not initialized");
diff --git a/packages/tools/devtools/devtools-test-app/src/Schema.ts b/packages/tools/devtools/devtools-test-app/src/Schema.ts
new file mode 100644
index 000000000000..d13dd2eec937
--- /dev/null
+++ b/packages/tools/devtools/devtools-test-app/src/Schema.ts
@@ -0,0 +1,32 @@
+/*!
+ * Copyright (c) Microsoft Corporation and contributors. All rights reserved.
+ * Licensed under the MIT License.
+ */
+
+import { SchemaFactory } from "@fluidframework/tree";
+
+/**
+ * Schema definition of {@link TodoItem} & {@link TodoList} used in {@link AppDataTree}.
+ */
+
+const builder = new SchemaFactory("AppDataTree-Schema");
+
+/**
+ * Represents a single todo item in the list.
+ */
+export class TodoItem extends builder.object("TodoItem", {
+	title: builder.handle,
+
+	description: builder.handle,
+
+	completed: builder.boolean,
+}) {}
+
+/**
+ * Represents the todo list schema, which holds multiple todo items.
+ */
+export class TodoList extends builder.object("TodoList", {
+	title: builder.handle,
+
+	items: builder.map([TodoItem]),
+}) {}
diff --git a/packages/tools/devtools/devtools-test-app/src/widgets/TodoItemView.tsx b/packages/tools/devtools/devtools-test-app/src/widgets/TodoItemView.tsx
new file mode 100644
index 000000000000..3b76bf6e4b43
--- /dev/null
+++ b/packages/tools/devtools/devtools-test-app/src/widgets/TodoItemView.tsx
@@ -0,0 +1,136 @@
+/*!
+ * Copyright (c) Microsoft Corporation and contributors. All rights reserved.
+ * Licensed under the MIT License.
+ */
+
+import { makeStyles } from "@fluentui/react-components";
+import {
+	CollaborativeInput,
+	CollaborativeTextArea,
+	SharedStringHelper,
+} from "@fluid-example/example-utils";
+import { useTree } from "@fluid-experimental/tree-react-api";
+import type { SharedString } from "@fluidframework/sequence/internal";
+import React from "react";
+
+import type { TodoItem } from "../Schema.js";
+
+const useStyles = makeStyles({
+	todoItemHeader: {
+		margin: 0,
+		display: "flex",
+	},
+	todoItemExpandButton: {
+		boxSizing: "border-box",
+		width: "30px",
+		height: "50px",
+		margin: "10px 0 10px 10px",
+		border: "none",
+		padding: 0,
+		fontSize: "30px",
+		background: "none",
+	},
+	todoItemCheckbox: {
+		boxSizing: "border-box",
+		width: "50px",
+		height: "50px",
+		margin: "10px 0 10px 10px",
+	},
+	todoItemInput: {
+		boxSizing: "border-box",
+		border: "1px solid #666",
+		width: "100%",
+		height: "50px",
+		margin: "10px 0 10px 10px",
+		padding: 0,
+		fontSize: "20px",
+		outline: "none",
+	},
+	todoItemDetails: {
+		width: "100%",
+	},
+});
+
+/**
+ * {@link TodoItemView} input props.
+ */
+interface TodoItemViewProps {
+	readonly todoItemModel: TodoItem;
+	readonly className?: string;
+}
+
+/**
+ * Todo Item that will be stored in the {@link TodoListView} tree.
+ * Contains a title, description and a checkbox to mark it as completed.
+ */
+export const TodoItemView: React.FC<TodoItemViewProps> = (props: TodoItemViewProps) => {
+	const { todoItemModel, className } = props;
+	const styles = useStyles();
+
+	const [itemTitle, setItemTitle] = React.useState<SharedString | undefined>(undefined);
+	const [itemDescription, setItemDescription] = React.useState<SharedString | undefined>(
+		undefined,
+	);
+	const [detailsVisible, setDetailsVisible] = React.useState<boolean>(false);
+
+	useTree(todoItemModel);
+
+	React.useEffect(() => {
+		todoItemModel.title
+			.get()
+			.then((text) => {
+				setItemTitle(text as SharedString);
+			})
+			.catch((error) => {
+				console.error("Failed to load title:", error);
+			});
+	}, [todoItemModel.title]);
+
+	React.useEffect(() => {
+		todoItemModel.description
+			.get()
+			.then((text) => {
+				setItemDescription(text as SharedString);
+			})
+			.catch((error) => {
+				console.error("Failed to load description:", error);
+			});
+	}, [todoItemModel.description]);
+
+	const checkChangedHandler = (e: React.ChangeEvent<HTMLInputElement>): void => {
+		todoItemModel.completed = e.target.checked;
+	};
+
+	if (itemTitle === undefined || itemDescription === undefined) {
+		return <div>Loading item...</div>;
+	}
+
+	return (
+		<div className={`todo-item${className === undefined ? "" : ` ${className}`}`}>
+			<h2 className={styles.todoItemHeader}>
+				<input
+					type="checkbox"
+					className={styles.todoItemCheckbox}
+					checked={todoItemModel.completed}
+					onChange={checkChangedHandler}
+				/>
+				<button
+					className={styles.todoItemExpandButton}
+					name="toggleDetailsVisible"
+					onClick={() => {
+						setDetailsVisible(!detailsVisible);
+					}}
+				>
+					{detailsVisible ? "▲" : "▼"}
+				</button>
+				<CollaborativeInput sharedString={itemTitle} className={styles.todoItemInput} />
+			</h2>
+			{detailsVisible && (
+				<CollaborativeTextArea
+					className={styles.todoItemDetails}
+					sharedStringHelper={new SharedStringHelper(itemDescription)}
+				/>
+			)}
+		</div>
+	);
+};
diff --git a/packages/tools/devtools/devtools-test-app/src/widgets/TodoListView.tsx b/packages/tools/devtools/devtools-test-app/src/widgets/TodoListView.tsx
new file mode 100644
index 000000000000..8cc96c84e7b1
--- /dev/null
+++ b/packages/tools/devtools/devtools-test-app/src/widgets/TodoListView.tsx
@@ -0,0 +1,156 @@
+/*!
+ * Copyright (c) Microsoft Corporation and contributors. All rights reserved.
+ * Licensed under the MIT License.
+ */
+
+import { makeStyles } from "@fluentui/react-components";
+import { CollaborativeInput } from "@fluid-example/example-utils";
+import { useTree } from "@fluid-experimental/tree-react-api";
+import type { SharedString, ISharedString } from "@fluidframework/sequence/internal";
+import { Tree } from "@fluidframework/tree/internal";
+import React from "react";
+
+import type { AppDataTree } from "../FluidObject.js";
+
+import { TodoItemView } from "./TodoItemView.js";
+
+const useStyles = makeStyles({
+	todoView: {
+		maxWidth: "400px",
+		margin: "0 auto",
+		padding: "0 15px",
+	},
+	todoTitle: {
+		width: "100%",
+		border: "none",
+		fontSize: "30px",
+		textAlign: "center",
+		marginBottom: "5px",
+		marginTop: "5px",
+		outline: "none",
+	},
+	newItemForm: {
+		margin: "20px 0",
+	},
+	newItemText: {
+		boxSizing: "border-box",
+		width: "calc(100% - 50px)",
+		height: "50px",
+		border: "1px solid #666",
+		verticalAlign: "middle",
+		fontSize: "30px",
+		outline: "0",
+	},
+	newItemButton: {
+		width: "50px",
+		height: "50px",
+		border: "1px solid #666",
+		verticalAlign: "middle",
+		fontSize: "30px",
+	},
+	itemWrap: {
+		display: "flex",
+	},
+	todoItemView: {
+		display: "inline-block",
+		width: "calc(100% - 100px)",
+	},
+	actionButton: {
+		boxSizing: "border-box",
+		width: "50px",
+		height: "50px",
+		margin: "10px 0 10px 10px",
+		border: "1px solid #666",
+		padding: "0",
+		fontSize: "30px",
+		"&:last-child": {
+			margin: "10px",
+		},
+	},
+});
+
+/**
+ * {@link TodoListView} input props.
+ */
+export interface TodoListProps {
+	readonly todoModel: AppDataTree;
+}
+
+/**
+ * Contains the list of {@link TodoItemView}, an editable title input and an input/button for submitting new items.
+ */
+export const TodoListView: React.FC<TodoListProps> = (props: TodoListProps) => {
+	const { todoModel } = props;
+	const styles = useStyles();
+	const [titleString, setTitleString] = React.useState<SharedString | undefined>();
+
+	const newItemTextInputRef = React.useRef<HTMLInputElement>(null);
+
+	useTree(todoModel.treeView.root);
+
+	React.useEffect(() => {
+		Promise.resolve(todoModel.treeView.root.title.get())
+			.then((title) => {
+				setTitleString(title as ISharedString);
+			})
+			.catch((error) => {
+				console.error(error);
+			});
+		return () => {};
+	}, [todoModel]);
+
+	if (titleString === undefined) {
+		return <div>Loading...</div>;
+	}
+	const handleCreateClick = (ev: React.FormEvent<HTMLFormElement>): void => {
+		ev.preventDefault();
+
+		const input = newItemTextInputRef.current;
+		if (!input) {
+			throw new Error("New item text field missing");
+		}
+
+		todoModel
+			.addTodoItem({ startingText: input.value })
+			.then(() => {
+				input.value = "";
+			})
+			.catch((error) => {
+				console.error("Failed to create todo item:", error);
+			});
+	};
+
+	const todoItemViews = [...todoModel.treeView.root.items.entries()].map(([id, todoItem]) => (
+		<div className={styles.itemWrap} key={id}>
+			<TodoItemView todoItemModel={todoItem} className={styles.todoItemView} />
+			<button
+				className={styles.actionButton}
+				onClick={() => {
+					todoModel.treeView.root.items.delete(id);
+					Tree.on(todoModel.treeView.root.items, "treeChanged", () => {});
+				}}
+			>
+				X
+			</button>
+		</div>
+	));
+
+	return (
+		<div className={styles.todoView}>
+			<CollaborativeInput className={styles.todoTitle} sharedString={titleString} />
+			<form className={styles.newItemForm} onSubmit={handleCreateClick}>
+				<input
+					className={styles.newItemText}
+					type="text"
+					ref={newItemTextInputRef}
+					name="itemName"
+					autoFocus
+				/>
+				<button className={styles.newItemButton} type="submit" name="createItem">
+					+
+				</button>
+			</form>
+			<div>{todoItemViews}</div>
+		</div>
+	);
+};
diff --git a/packages/tools/devtools/devtools-test-app/src/widgets/index.ts b/packages/tools/devtools/devtools-test-app/src/widgets/index.ts
index 8fa359f9e62a..89dcb20cbbdf 100644
--- a/packages/tools/devtools/devtools-test-app/src/widgets/index.ts
+++ b/packages/tools/devtools/devtools-test-app/src/widgets/index.ts
@@ -9,3 +9,5 @@ export type { EmojiButtonProps } from "./EmojiButton.js";
 export { EmojiButton } from "./EmojiButton.js";
 export type { EmojiGridProps } from "./EmojiGrid.js";
 export { EmojiGrid } from "./EmojiGrid.js";
+export type { TodoListProps } from "./TodoListView.js";
+export { TodoListView } from "./TodoListView.js";
diff --git a/packages/tools/devtools/devtools-view/src/components/data-visualization/FluidHandleView.tsx b/packages/tools/devtools/devtools-view/src/components/data-visualization/FluidHandleView.tsx
index d96ae007013e..68ddd44adf87 100644
--- a/packages/tools/devtools/devtools-view/src/components/data-visualization/FluidHandleView.tsx
+++ b/packages/tools/devtools/devtools-view/src/components/data-visualization/FluidHandleView.tsx
@@ -81,7 +81,7 @@ export function FluidHandleView(props: FluidHandleViewProps): React.ReactElement
 		return (): void => {
 			messageRelay.off("message", messageHandler);
 		};
-	}, [containerKey, setVisualTree, fluidObjectId, messageRelay]);
+	}, [containerKey, fluidObjectId, messageRelay]);
 
 	if (visualTree === undefined) {
 		const header = <TreeHeader label={label} inlineValue={<Spinner size="tiny" />} />;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f11b880bf1ec..a030207d450d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -15432,6 +15432,9 @@ importers:
       '@fluid-example/example-utils':
         specifier: workspace:~
         version: link:../../../../examples/utils/example-utils
+      '@fluid-experimental/tree-react-api':
+        specifier: workspace:~
+        version: link:../../../../experimental/framework/tree-react-api
       '@fluid-internal/client-utils':
         specifier: workspace:~
         version: link:../../../common/client-utils
@@ -15504,6 +15507,9 @@ importers:
       scheduler:
         specifier: ^0.20.0
         version: 0.20.2
+      uuid:
+        specifier: ^9.0.0
+        version: 9.0.1
     devDependencies:
       '@biomejs/biome':
         specifier: ~1.9.3
@@ -15547,6 +15553,9 @@ importers:
       '@types/testing-library__jest-dom':
         specifier: ^5.14.5
         version: 5.14.9
+      '@types/uuid':
+        specifier: ^9.0.2
+        version: 9.0.8
       concurrently:
         specifier: ^8.2.1
         version: 8.2.2


Reviews:
user: login: jikim-msft
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T21:05:54Z
user: login: Josmithr
body: I'm sorry. I started reviewing this yesterday and meant to submit my comments, but I forgot 😓
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T21:42:45Z
user: login: jikim-msft
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-17T03:30:20Z
user: login: jikim-msft
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-17T04:15:03Z
user: login: alexvy86
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T15:04:42Z
user: login: jikim-msft
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T22:05:07Z
user: login: jikim-msft
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T22:15:49Z
user: login: alexvy86
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T22:57:05Z
user: login: jikim-msft
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T00:11:34Z
user: login: Josmithr
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T00:15:16Z
user: login: jikim-msft
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T00:17:38Z
user: login: Josmithr
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T00:18:46Z
user: login: Josmithr
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T00:20:01Z
user: login: Josmithr
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T00:20:52Z
user: login: Josmithr
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T00:23:22Z
user: login: Josmithr
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T00:30:58Z
user: login: Josmithr
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T00:35:12Z
user: login: Josmithr
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T00:39:09Z
user: login: alexvy86
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T02:04:47Z
user: login: jikim-msft
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T23:28:41Z
user: login: Josmithr
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T00:02:44Z
user: login: jikim-msft
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T00:39:26Z
user: login: alexvy86
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T15:04:08Z
user: login: Josmithr
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T18:57:47Z
user: login: Josmithr
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T20:27:03Z
user: login: Josmithr
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T20:30:14Z
user: login: Josmithr
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T20:31:41Z
user: login: Josmithr
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T20:33:03Z
user: login: Josmithr
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T21:25:49Z

Review comments:
diff_hunk: @@ -26,6 +26,8 @@ module.exports = {
 		// TODO: AB#18875 - Re-enable react/no-deprecated once we replace uses of the deprecated ReactDOM.render()
 		// with the new React 18 createRoot().
 		"react/no-deprecated": "off",
+
+		"import/no-named-as-default-member": "off",
path: packages/tools/devtools/devtools-test-app/.eslintrc.cjs
user: login: jikim-msft
body: TODO: Remove this. 
created_at: 2025-05-06T21:05:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -249,18 +253,30 @@ export class DataVisualizerGraph
 	private registerVisualizerForVisualizableObject(
 		visualizableObject: VisualizableFluidObject,
 	): FluidObjectId {
-		if (!this.visualizerNodes.has(visualizableObject.id)) {
+		// In case the visualizable object is a `DataObject` or a `TreeDataObjct`, we extract the id of its root `ISharedObject` so that the view can correctly render the newly updated `VisualizerNode`.
+		// This is necessary because it is the `ISharedObject` (e.g., root or sharedTree of `DataObject` and `TreeDataObject`) which emits the event NOT the `DataObject` and `TreeDataObject`.
+		const fluidObjectId = isDataObject(visualizableObject)
path: packages/tools/devtools/devtools-core/src/data-visualization/DataVisualization.ts
user: login: Josmithr
body: Nit: we use this same nested ternary structure 3 times now in this function. Probably a good time to refactor a bit to avoid repeating the pattern so much.

We should also probably store off the result of `isDataObject` and `isTreeDataObject` to avoid recomputing in each ternary.
created_at: 2025-05-15T22:17:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -249,18 +253,30 @@ export class DataVisualizerGraph
 	private registerVisualizerForVisualizableObject(
 		visualizableObject: VisualizableFluidObject,
 	): FluidObjectId {
-		if (!this.visualizerNodes.has(visualizableObject.id)) {
+		// In case the visualizable object is a `DataObject` or a `TreeDataObjct`, we extract the id of its root `ISharedObject` so that the view can correctly render the newly updated `VisualizerNode`.
path: packages/tools/devtools/devtools-core/src/data-visualization/DataVisualization.ts
user: login: Josmithr
body: Nit
```suggestion
		// In case the visualizable object is a `DataObject` or a `TreeDataObjct`, we extract the ID of its root DDS so that the view can correctly render the newly updated `VisualizerNode`.
```
created_at: 2025-05-15T22:18:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -249,18 +253,30 @@ export class DataVisualizerGraph
 	private registerVisualizerForVisualizableObject(
 		visualizableObject: VisualizableFluidObject,
 	): FluidObjectId {
-		if (!this.visualizerNodes.has(visualizableObject.id)) {
+		// In case the visualizable object is a `DataObject` or a `TreeDataObjct`, we extract the id of its root `ISharedObject` so that the view can correctly render the newly updated `VisualizerNode`.
+		// This is necessary because it is the `ISharedObject` (e.g., root or sharedTree of `DataObject` and `TreeDataObject`) which emits the event NOT the `DataObject` and `TreeDataObject`.
path: packages/tools/devtools/devtools-core/src/data-visualization/DataVisualization.ts
user: login: Josmithr
body: Nit
```suggestion
		// This is necessary because it is the DDS for which data updates are automatically emitted on change, rather than the containing `DataObject` and `TreeDataObject`.
```
created_at: 2025-05-15T22:19:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -549,3 +569,35 @@ function isDataObject(value: unknown): value is DataObject {
 
 	return false;
 }
+
+/**
+ * Determines whether or not the provided value is an {@link TreeDataObject} using `instanceof`, for the purposes of this library.
+ * @remarks
+ * Uses `instanceof` over checking specific properties or methods, because we decided that a version mix-up with
+ * {@link @fluidframework/aqueduct#} is unlikely between devtools and end-user applications, and we don't support it anyway.
+ * In addition, we check for the presence of key properties that make a `TreeDataObject` unique:
+ * - {@link TreeDataObject#sharedTree | sharedTree} getter
+ * - {@link TreeDataObject#treeView | treeView} getter
+ * - {@link TreeDataObject#initializeInternal | initializeInternal} method
+ */
+function isTreeDataObject(value: unknown): value is TreeDataObject<unknown> {
+	if (
+		value instanceof TreeDataObject ||
+		(typeof (value as TreeDataObject<unknown>).initializeInternal === "function" &&
+			Object.getOwnPropertyDescriptor(Object.getPrototypeOf(value), "sharedTree")?.get !==
+				undefined &&
+			Object.getOwnPropertyDescriptor(Object.getPrototypeOf(value), "treeView")?.get !==
path: packages/tools/devtools/devtools-core/src/data-visualization/DataVisualization.ts
user: login: Josmithr
body: Do we use the `treeView` property at all in the visualizer code? If not, we probably don't need to check for this property. So long as we have the `sharedTree` prop, I think we're in business.
created_at: 2025-05-15T22:20:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -249,18 +253,30 @@ export class DataVisualizerGraph
 	private registerVisualizerForVisualizableObject(
 		visualizableObject: VisualizableFluidObject,
 	): FluidObjectId {
-		if (!this.visualizerNodes.has(visualizableObject.id)) {
+		// In case the visualizable object is a `DataObject` or a `TreeDataObjct`, we extract the id of its root `ISharedObject` so that the view can correctly render the newly updated `VisualizerNode`.
+		// This is necessary because it is the `ISharedObject` (e.g., root or sharedTree of `DataObject` and `TreeDataObject`) which emits the event NOT the `DataObject` and `TreeDataObject`.
+		const fluidObjectId = isDataObject(visualizableObject)
+			? (visualizableObject as unknown as { readonly root: ISharedDirectory }).root.id
+			: isTreeDataObject(visualizableObject)
+				? (visualizableObject.sharedTree as unknown as ISharedObject).id
path: packages/tools/devtools/devtools-core/src/data-visualization/DataVisualization.ts
user: login: alexvy86
body: Why do we need this cast? We recently exposed this `.sharedTree` property on the `TreeDataObject`, so I'm curious if we missed something with the typing there.
created_at: 2025-05-16T15:33:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -549,3 +569,35 @@ function isDataObject(value: unknown): value is DataObject {
 
 	return false;
 }
+
+/**
+ * Determines whether or not the provided value is an {@link TreeDataObject} using `instanceof`, for the purposes of this library.
path: packages/tools/devtools/devtools-core/src/data-visualization/DataVisualization.ts
user: login: alexvy86
body: ```suggestion
 * Determines whether or not the provided value is a {@link TreeDataObject} using `instanceof`, for the purposes of this library.
```
created_at: 2025-05-16T15:42:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -549,3 +569,35 @@ function isDataObject(value: unknown): value is DataObject {
 
 	return false;
 }
+
+/**
+ * Determines whether or not the provided value is an {@link TreeDataObject} using `instanceof`, for the purposes of this library.
+ * @remarks
+ * Uses `instanceof` over checking specific properties or methods, because we decided that a version mix-up with
path: packages/tools/devtools/devtools-core/src/data-visualization/DataVisualization.ts
user: login: alexvy86
body: The typeguard for DataObject wants a similar update I think, since it also tries `instanceof` and falls back to a property check.
```suggestion
 * Tries to use `instanceof` because we decided that a version mix-up with
```
created_at: 2025-05-16T15:43:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -549,3 +569,35 @@ function isDataObject(value: unknown): value is DataObject {
 
 	return false;
 }
+
+/**
+ * Determines whether or not the provided value is an {@link TreeDataObject} using `instanceof`, for the purposes of this library.
+ * @remarks
+ * Uses `instanceof` over checking specific properties or methods, because we decided that a version mix-up with
+ * {@link @fluidframework/aqueduct#} is unlikely between devtools and end-user applications, and we don't support it anyway.
+ * In addition, we check for the presence of key properties that make a `TreeDataObject` unique:
+ * - {@link TreeDataObject#sharedTree | sharedTree} getter
+ * - {@link TreeDataObject#treeView | treeView} getter
+ * - {@link TreeDataObject#initializeInternal | initializeInternal} method
+ */
+function isTreeDataObject(value: unknown): value is TreeDataObject<unknown> {
+	if (
+		value instanceof TreeDataObject ||
+		(typeof (value as TreeDataObject<unknown>).initializeInternal === "function" &&
+			Object.getOwnPropertyDescriptor(Object.getPrototypeOf(value), "sharedTree")?.get !==
+				undefined &&
+			Object.getOwnPropertyDescriptor(Object.getPrototypeOf(value), "treeView")?.get !==
path: packages/tools/devtools/devtools-core/src/data-visualization/DataVisualization.ts
user: login: jikim-msft
body: Strictly speaking *no*, we don't use it. The ST [visualizer](https://github.com/microsoft/FluidFramework/blob/fb1d10bee5d649a2e7bf19efdfc6b55f765edcb1/packages/tools/devtools/devtools-core/src/data-visualization/DefaultVisualizers.ts#L300) does use the `treeView` to construct the visual tree, but it's the `TreeDataObject.sharedTree.view` that it's using NOT the `TreeDataObject.view`.
created_at: 2025-05-17T03:30:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -249,18 +240,34 @@ export class DataVisualizerGraph
 	private registerVisualizerForVisualizableObject(
 		visualizableObject: VisualizableFluidObject,
 	): FluidObjectId {
-		if (!this.visualizerNodes.has(visualizableObject.id)) {
+		// Store type check results to avoid recomputing
+		const isDataObj = isDataObject(visualizableObject);
+		const isTreeDataObj = isTreeDataObject(visualizableObject);
+
+		// Get the root shared object and its ID based on the object type
+		const rootSharedObject = isDataObj
+			? (visualizableObject as unknown as { readonly root: ISharedDirectory }).root
+			: isTreeDataObj
+				? (visualizableObject.sharedTree as unknown as ISharedObject)
+				: visualizableObject;
+
+		// Generate the fluidObjectId based on the object type
+		const fluidObjectId =
+			isDataObj || isTreeDataObj
+				? `${visualizableObject.id}-${rootSharedObject.id}`
path: packages/tools/devtools/devtools-core/src/data-visualization/DataVisualization.ts
user: login: jikim-msft
body: I've added wrapper functions `createDataObjectVisualizer()` & `createTreeDataObjectVisualizer()` to disntinguish the `fluidObjectId` of root `ISharedObject`. 

Since the `TreeDataObject.sharedTree` will always have a unique id, this maybe unnecessary for the `TreeDataObject`, but I've added this for consistency in pattern for non-`ISharedObject` data.
created_at: 2025-05-17T04:15:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -249,18 +240,34 @@ export class DataVisualizerGraph
 	private registerVisualizerForVisualizableObject(
 		visualizableObject: VisualizableFluidObject,
 	): FluidObjectId {
-		if (!this.visualizerNodes.has(visualizableObject.id)) {
+		// Store type check results to avoid recomputing
+		const isDataObj = isDataObject(visualizableObject);
+		const isTreeDataObj = isTreeDataObject(visualizableObject);
+
+		// Get the root shared object and its ID based on the object type
+		const rootSharedObject = isDataObj
path: packages/tools/devtools/devtools-core/src/data-visualization/DataVisualization.ts
user: login: alexvy86
body: Nit: I feel like a more traditional if structure where we assign all the necessary things (`rootSharedObject`, `fluidObjectId`, `visualizationFunction`) together on each branch of the if would be more readable than the series of nested ternaries.
created_at: 2025-05-19T14:35:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,43 @@
+/*!
+ * Copyright (c) Microsoft Corporation and contributors. All rights reserved.
+ * Licensed under the MIT License.
+ */
+
+import { SchemaFactory } from "@fluidframework/tree";
+
+/**
+ * Schema definition of {@link TodoItem} & {@link TodoList} used in {@link AppDataTree}.
+ */
+
+const builder = new SchemaFactory("AppDataTree-Schema");
+
+/**
+ * Represents a single todo item in the list.
+ */
+export class TodoItem extends builder.object("TodoItem", {
+	title: builder.handle,
+
+	description: builder.handle,
+
+	completed: builder.boolean,
+}) {}
+
+/**
+ * Represents the todo list schema, which holds multiple todo items.
+ */
+export class TodoList extends builder.object("TodoList", {
+	title: builder.handle,
+
+	items: builder.map([TodoItem]),
+}) {}
+
+/**
+ * Props used when creating a new todo item.
+ */
+export interface TodoItemProps {
+	/**
+	 * The initial text to populate the todo item's title with.
+	 * This value will be inserted into the shared string at index 0.
+	 */
+	readonly startingText: string;
+}
path: packages/tools/devtools/devtools-test-app/src/Schema.ts
user: login: alexvy86
body: I'd suggest we move this interface to the file that uses it, it's not really related to the SharedTree schema.
created_at: 2025-05-19T14:42:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -3,14 +3,92 @@
  * Licensed under the MIT License.
  */
 
-import { DataObject, DataObjectFactory } from "@fluidframework/aqueduct/internal";
+import {
+	DataObject,
+	DataObjectFactory,
+	PureDataObjectFactory,
+	TreeDataObject,
+} from "@fluidframework/aqueduct/internal";
 import { SharedCell } from "@fluidframework/cell/internal";
 import type { IFluidHandle, IFluidLoadable } from "@fluidframework/core-interfaces";
 import { SharedCounter } from "@fluidframework/counter/internal";
 import { SharedMatrix } from "@fluidframework/matrix/internal";
 import { SharedString } from "@fluidframework/sequence/internal";
 import { type ITree, SchemaFactory, TreeViewConfiguration } from "@fluidframework/tree";
-import { SharedTree } from "@fluidframework/tree/internal";
+import { SharedTree, type TreeView } from "@fluidframework/tree/internal";
+import { v4 as uuid } from "uuid";
+
+import { TodoList, TodoItem, type TodoItemProps } from "./Schema.js";
+
+/**
+ * Additional {@link TreeDataObject} added to the {@link AppData}.
path: packages/tools/devtools/devtools-test-app/src/FluidObject.ts
user: login: alexvy86
body: It's the only one so not really "additional", right? And since this is not a public API, I think documenting the purpose (for future us) is useful.
```suggestion
 * Object so we can test {@link TreeDataObject} inside {@link AppData}.
```
created_at: 2025-05-19T14:47:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -3,14 +3,92 @@
  * Licensed under the MIT License.
  */
 
-import { DataObject, DataObjectFactory } from "@fluidframework/aqueduct/internal";
+import {
+	DataObject,
+	DataObjectFactory,
+	PureDataObjectFactory,
+	TreeDataObject,
+} from "@fluidframework/aqueduct/internal";
 import { SharedCell } from "@fluidframework/cell/internal";
 import type { IFluidHandle, IFluidLoadable } from "@fluidframework/core-interfaces";
 import { SharedCounter } from "@fluidframework/counter/internal";
 import { SharedMatrix } from "@fluidframework/matrix/internal";
 import { SharedString } from "@fluidframework/sequence/internal";
 import { type ITree, SchemaFactory, TreeViewConfiguration } from "@fluidframework/tree";
-import { SharedTree } from "@fluidframework/tree/internal";
+import { SharedTree, type TreeView } from "@fluidframework/tree/internal";
+import { v4 as uuid } from "uuid";
+
+import { TodoList, TodoItem, type TodoItemProps } from "./Schema.js";
+
+/**
+ * Additional {@link TreeDataObject} added to the {@link AppData}.
+ */
+export class AppDataTree extends TreeDataObject<TreeView<typeof TodoList>> {
+	public static readonly Name = "@devtools-example/test-app-tree-data-object";
+
+	public readonly config = new TreeViewConfiguration({ schema: TodoList });
+	public static readonly factory = new PureDataObjectFactory<
path: packages/tools/devtools/devtools-test-app/src/FluidObject.ts
user: login: alexvy86
body: `config` doesn't seem to be used the class, and if we're exposing `getFactory` which just returns `factory`, I think no need to expose `factory` itself?
```suggestion
	private readonly config = new TreeViewConfiguration({ schema: TodoList });
	private static readonly factory = new PureDataObjectFactory<
```
created_at: 2025-05-19T14:49:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -3,14 +3,92 @@
  * Licensed under the MIT License.
  */
 
-import { DataObject, DataObjectFactory } from "@fluidframework/aqueduct/internal";
+import {
+	DataObject,
+	DataObjectFactory,
+	PureDataObjectFactory,
+	TreeDataObject,
+} from "@fluidframework/aqueduct/internal";
 import { SharedCell } from "@fluidframework/cell/internal";
 import type { IFluidHandle, IFluidLoadable } from "@fluidframework/core-interfaces";
 import { SharedCounter } from "@fluidframework/counter/internal";
 import { SharedMatrix } from "@fluidframework/matrix/internal";
 import { SharedString } from "@fluidframework/sequence/internal";
 import { type ITree, SchemaFactory, TreeViewConfiguration } from "@fluidframework/tree";
-import { SharedTree } from "@fluidframework/tree/internal";
+import { SharedTree, type TreeView } from "@fluidframework/tree/internal";
+import { v4 as uuid } from "uuid";
+
+import { TodoList, TodoItem, type TodoItemProps } from "./Schema.js";
+
+/**
+ * Additional {@link TreeDataObject} added to the {@link AppData}.
+ */
+export class AppDataTree extends TreeDataObject<TreeView<typeof TodoList>> {
+	public static readonly Name = "@devtools-example/test-app-tree-data-object";
+
+	public readonly config = new TreeViewConfiguration({ schema: TodoList });
+	public static readonly factory = new PureDataObjectFactory<
+		TreeDataObject<TreeView<typeof TodoList>>
+	>(AppDataTree.Name, AppDataTree, [SharedString.getFactory(), SharedTree.getFactory()], {});
+
+	public static getFactory(): PureDataObjectFactory<
+		TreeDataObject<TreeView<typeof TodoList>>
+	> {
+		return AppDataTree.factory;
+	}
+
+	/**
+	 * Converts the underlying ITree into a typed TreeView using the provided schema configuration.
+	 *
+	 * @param tree - The ITree instance to view.
+	 * @returns A typed TreeView using the TodoList schema.
+	 */
+	public override generateView(tree: ITree): TreeView<typeof TodoList> {
+		return tree.viewWith(this.config) as unknown as TreeView<typeof TodoList>;
+	}
+
+	/**
+	 * Initializes the tree with a default title and empty todo item list.
+	 * @remarks Called during the initial creation of the data object.
+	 */
+	public override async initializingFirstTime(): Promise<void> {
+		const title = SharedString.create(this.runtime);
+		title.insertText(0, "Title");
+
+		this.treeView.initialize(new TodoList({ title: title.handle, items: [] }));
+	}
+
+	/**
+	 * Adds a new todo item to the list.
+	 *
+	 * @param props
+	 * -`startingText`: The text to prefill into the item's title.
+	 *
+	 * @privateRemarks
+	 * This method was placed in the data object (instead of the TodoList schema class),
+	 * as we needed access to the runtime to create the `SharedString`.
+	 */
+	public async addTodoItem(props?: TodoItemProps): Promise<void> {
+		console.log("Adding a new todo item");
+		const title = SharedString.create(this.runtime);
+		const newItemText = props?.startingText ?? "New Item";
+		title.insertText(0, newItemText);
+		const description = SharedString.create(this.runtime);
+
+		const todoItem = new TodoItem({
+			title: title.handle,
+			description: description.handle,
+			completed: false,
+		});
+
+		// TODO: We should consider creating a separate field for date, so that we do not need to
+		// concatenate it to the id.
+		// Generate an ID that we can sort on later, and store the handle.
path: packages/tools/devtools/devtools-test-app/src/FluidObject.ts
user: login: alexvy86
body: ?
```suggestion
		// Generate an ID that we can sort on later, and store the todo item.
```
created_at: 2025-05-19T14:51:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,156 @@
+/*!
+ * Copyright (c) Microsoft Corporation and contributors. All rights reserved.
+ * Licensed under the MIT License.
+ */
+
+import { makeStyles } from "@fluentui/react-components";
+import { CollaborativeInput } from "@fluid-example/example-utils";
+import type { SharedString, ISharedString } from "@fluidframework/sequence/internal";
+import { Tree } from "@fluidframework/tree/internal";
+import React from "react";
+
+import type { AppDataTree } from "../FluidObject.js";
+
+import { TodoItemView, useTree } from "./TodoItemView.js";
+
+const useStyles = makeStyles({
+	todoView: {
+		maxWidth: "400px",
+		margin: "0 auto",
+		padding: "0 15px",
+	},
+	todoTitle: {
+		width: "100%",
+		border: "none",
+		fontSize: "30px",
+		textAlign: "center",
+		marginBottom: "5px",
+		marginTop: "5px",
+		outline: "none",
+	},
+	newItemForm: {
+		margin: "20px 0",
+	},
+	newItemText: {
+		boxSizing: "border-box",
+		width: "calc(100% - 50px)",
+		height: "50px",
+		border: "1px solid #666",
+		verticalAlign: "middle",
+		fontSize: "30px",
+		outline: "0",
+	},
+	newItemButton: {
+		width: "50px",
+		height: "50px",
+		border: "1px solid #666",
+		verticalAlign: "middle",
+		fontSize: "30px",
+	},
+	itemWrap: {
+		display: "flex",
+	},
+	todoItemView: {
+		display: "inline-block",
+		width: "calc(100% - 100px)",
+	},
+	actionButton: {
+		boxSizing: "border-box",
+		width: "50px",
+		height: "50px",
+		margin: "10px 0 10px 10px",
+		border: "1px solid #666",
+		padding: "0",
+		fontSize: "30px",
+		"&:last-child": {
+			margin: "10px",
+		},
+	},
+});
+
+/**
+ * {@link TodoListView} input props.
+ * @internal
path: packages/tools/devtools/devtools-test-app/src/widgets/TodoListView.tsx
user: login: alexvy86
body: Not package exported so no need for tags.
```suggestion
```
created_at: 2025-05-19T14:53:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,156 @@
+/*!
+ * Copyright (c) Microsoft Corporation and contributors. All rights reserved.
+ * Licensed under the MIT License.
+ */
+
+import { makeStyles } from "@fluentui/react-components";
+import { CollaborativeInput } from "@fluid-example/example-utils";
+import type { SharedString, ISharedString } from "@fluidframework/sequence/internal";
+import { Tree } from "@fluidframework/tree/internal";
+import React from "react";
+
+import type { AppDataTree } from "../FluidObject.js";
+
+import { TodoItemView, useTree } from "./TodoItemView.js";
+
+const useStyles = makeStyles({
+	todoView: {
+		maxWidth: "400px",
+		margin: "0 auto",
+		padding: "0 15px",
+	},
+	todoTitle: {
+		width: "100%",
+		border: "none",
+		fontSize: "30px",
+		textAlign: "center",
+		marginBottom: "5px",
+		marginTop: "5px",
+		outline: "none",
+	},
+	newItemForm: {
+		margin: "20px 0",
+	},
+	newItemText: {
+		boxSizing: "border-box",
+		width: "calc(100% - 50px)",
+		height: "50px",
+		border: "1px solid #666",
+		verticalAlign: "middle",
+		fontSize: "30px",
+		outline: "0",
+	},
+	newItemButton: {
+		width: "50px",
+		height: "50px",
+		border: "1px solid #666",
+		verticalAlign: "middle",
+		fontSize: "30px",
+	},
+	itemWrap: {
+		display: "flex",
+	},
+	todoItemView: {
+		display: "inline-block",
+		width: "calc(100% - 100px)",
+	},
+	actionButton: {
+		boxSizing: "border-box",
+		width: "50px",
+		height: "50px",
+		margin: "10px 0 10px 10px",
+		border: "1px solid #666",
+		padding: "0",
+		fontSize: "30px",
+		"&:last-child": {
+			margin: "10px",
+		},
+	},
+});
+
+/**
+ * {@link TodoListView} input props.
+ * @internal
+ */
+export interface TodoListProps {
+	readonly todoModel: AppDataTree;
+}
+
+/**
+ * Contains the list of {@link TodoItemView}, an editable title input and an input/button for submitting new items.
+ */
+export const TodoListView: React.FC<TodoListProps> = (props: TodoListProps) => {
+	const { todoModel } = props;
+	const styles = useStyles();
+	const [titleString, setTitleString] = React.useState<SharedString | undefined>();
+
+	const newItemTextInputRef = React.useRef<HTMLInputElement>(null);
+
+	useTree(todoModel.treeView.root);
+
+	React.useEffect(() => {
+		Promise.resolve(todoModel.treeView.root.title.get())
+			.then((title) => {
+				setTitleString(title as ISharedString);
+			})
+			.catch((error) => {
+				console.error(error);
+			});
+		return () => {};
+	}, [todoModel]);
+
+	if (titleString === undefined) {
+		return <div>Loading...</div>;
+	}
+	const handleCreateClick = (ev: React.FormEvent<HTMLFormElement>): void => {
+		ev.preventDefault();
+
+		const input = newItemTextInputRef.current;
+		if (!input) {
+			throw new Error("New item text field missing");
+		}
+
+		todoModel
+			.addTodoItem({ startingText: input.value })
+			.then(() => {
+				input.value = "";
+			})
+			.catch((error) => {
+				console.error("Failed to create todo item:", error);
+			});
+	};
+
+	const todoItemViews = [...todoModel.treeView.root.items.entries()].map(([id, todoItem]) => (
+		<div className={styles.itemWrap} key={id}>
+			<TodoItemView todoItemModel={todoItem} className={styles.todoItemView} />
+			<button
+				className={styles.actionButton}
+				onClick={() => {
+					todoModel.treeView.root.items.delete(id);
+					Tree.on(todoModel.treeView.root.items, "treeChanged", () => {});
path: packages/tools/devtools/devtools-test-app/src/widgets/TodoListView.tsx
user: login: alexvy86
body: Each time someone deletes an item, we're setting up a new empty listener for the `treeChanged` event?
created_at: 2025-05-19T14:55:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,162 @@
+/*!
+ * Copyright (c) Microsoft Corporation and contributors. All rights reserved.
+ * Licensed under the MIT License.
+ */
+
+import { makeStyles } from "@fluentui/react-components";
+import {
+	CollaborativeInput,
+	CollaborativeTextArea,
+	SharedStringHelper,
+} from "@fluid-example/example-utils";
+import type { SharedString } from "@fluidframework/sequence/internal";
+import { Tree, type TreeNode } from "@fluidframework/tree/internal";
+import React from "react";
+
+import type { TodoItem } from "../Schema.js";
+
+const useStyles = makeStyles({
+	todoItemHeader: {
+		margin: 0,
+		display: "flex",
+	},
+	todoItemExpandButton: {
+		boxSizing: "border-box",
+		width: "30px",
+		height: "50px",
+		margin: "10px 0 10px 10px",
+		border: "none",
+		padding: 0,
+		fontSize: "30px",
+		background: "none",
+	},
+	todoItemCheckbox: {
+		boxSizing: "border-box",
+		width: "50px",
+		height: "50px",
+		margin: "10px 0 10px 10px",
+	},
+	todoItemInput: {
+		boxSizing: "border-box",
+		border: "1px solid #666",
+		width: "100%",
+		height: "50px",
+		margin: "10px 0 10px 10px",
+		padding: 0,
+		fontSize: "20px",
+		outline: "none",
+	},
+	todoItemDetails: {
+		width: "100%",
+	},
+});
+
+/**
+ * {@link TodoItemView} input props.
+ * @internal
path: packages/tools/devtools/devtools-test-app/src/widgets/TodoItemView.tsx
user: login: alexvy86
body: Same as above, no need for the tags if things are not package-exported.
```suggestion
```
created_at: 2025-05-19T14:56:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,162 @@
+/*!
+ * Copyright (c) Microsoft Corporation and contributors. All rights reserved.
+ * Licensed under the MIT License.
+ */
+
+import { makeStyles } from "@fluentui/react-components";
+import {
+	CollaborativeInput,
+	CollaborativeTextArea,
+	SharedStringHelper,
+} from "@fluid-example/example-utils";
+import type { SharedString } from "@fluidframework/sequence/internal";
+import { Tree, type TreeNode } from "@fluidframework/tree/internal";
+import React from "react";
+
+import type { TodoItem } from "../Schema.js";
+
+const useStyles = makeStyles({
+	todoItemHeader: {
+		margin: 0,
+		display: "flex",
+	},
+	todoItemExpandButton: {
+		boxSizing: "border-box",
+		width: "30px",
+		height: "50px",
+		margin: "10px 0 10px 10px",
+		border: "none",
+		padding: 0,
+		fontSize: "30px",
+		background: "none",
+	},
+	todoItemCheckbox: {
+		boxSizing: "border-box",
+		width: "50px",
+		height: "50px",
+		margin: "10px 0 10px 10px",
+	},
+	todoItemInput: {
+		boxSizing: "border-box",
+		border: "1px solid #666",
+		width: "100%",
+		height: "50px",
+		margin: "10px 0 10px 10px",
+		padding: 0,
+		fontSize: "20px",
+		outline: "none",
+	},
+	todoItemDetails: {
+		width: "100%",
+	},
+});
+
+/**
+ * {@link TodoItemView} input props.
+ * @internal
+ */
+interface TodoItemViewProps {
+	readonly todoItemModel: TodoItem;
+	readonly className?: string;
+}
+
+/**
+ * Todo Item that will be stored in the {@link TodoListView} tree.
+ * Contains a title, description and a checkbox to mark it as completed.
+ */
+export const TodoItemView: React.FC<TodoItemViewProps> = (props: TodoItemViewProps) => {
+	const { todoItemModel, className } = props;
+	const styles = useStyles();
+
+	const [itemTitle, setItemTitle] = React.useState<SharedString | undefined>(undefined);
+	const [itemDescription, setItemDescription] = React.useState<SharedString | undefined>(
+		undefined,
+	);
+	const [detailsVisible, setDetailsVisible] = React.useState<boolean>(false);
+
+	useTree(todoItemModel);
+
+	React.useEffect(() => {
+		todoItemModel.title
+			.get()
+			.then((text) => {
+				setItemTitle(text as SharedString);
+			})
+			.catch((error) => {
+				console.error("Failed to load title:", error);
+			});
+	}, [todoItemModel.title]);
+
+	React.useEffect(() => {
+		todoItemModel.description
+			.get()
+			.then((text) => {
+				setItemDescription(text as SharedString);
+			})
+			.catch((error) => {
+				console.error("Failed to load description:", error);
+			});
+	}, [todoItemModel.description]);
path: packages/tools/devtools/devtools-test-app/src/widgets/TodoItemView.tsx
user: login: alexvy86
body: This feels funky, and it causes the component to render twice each time it loads (once after each of the two fields is "resolved"). Maybe try something like this inside a single effect?

```typescript
const titleP = todoItemModel.title.get();
const descriptionP = todoItemModel.description.get();
Promise.all([titleP, descriptionP]).then([title, description] => {
  setItemTitle(title as SharedString);
  setItemDescription(description as SharedString);
})
.catch((error) => {
  console.error("Failed to load title or description:", error);
});
```
created_at: 2025-05-19T15:02:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,162 @@
+/*!
+ * Copyright (c) Microsoft Corporation and contributors. All rights reserved.
+ * Licensed under the MIT License.
+ */
+
+import { makeStyles } from "@fluentui/react-components";
+import {
+	CollaborativeInput,
+	CollaborativeTextArea,
+	SharedStringHelper,
+} from "@fluid-example/example-utils";
+import type { SharedString } from "@fluidframework/sequence/internal";
+import { Tree, type TreeNode } from "@fluidframework/tree/internal";
+import React from "react";
+
+import type { TodoItem } from "../Schema.js";
+
+const useStyles = makeStyles({
+	todoItemHeader: {
+		margin: 0,
+		display: "flex",
+	},
+	todoItemExpandButton: {
+		boxSizing: "border-box",
+		width: "30px",
+		height: "50px",
+		margin: "10px 0 10px 10px",
+		border: "none",
+		padding: 0,
+		fontSize: "30px",
+		background: "none",
+	},
+	todoItemCheckbox: {
+		boxSizing: "border-box",
+		width: "50px",
+		height: "50px",
+		margin: "10px 0 10px 10px",
+	},
+	todoItemInput: {
+		boxSizing: "border-box",
+		border: "1px solid #666",
+		width: "100%",
+		height: "50px",
+		margin: "10px 0 10px 10px",
+		padding: 0,
+		fontSize: "20px",
+		outline: "none",
+	},
+	todoItemDetails: {
+		width: "100%",
+	},
+});
+
+/**
+ * {@link TodoItemView} input props.
+ * @internal
+ */
+interface TodoItemViewProps {
+	readonly todoItemModel: TodoItem;
+	readonly className?: string;
+}
+
+/**
+ * Todo Item that will be stored in the {@link TodoListView} tree.
+ * Contains a title, description and a checkbox to mark it as completed.
+ */
+export const TodoItemView: React.FC<TodoItemViewProps> = (props: TodoItemViewProps) => {
+	const { todoItemModel, className } = props;
+	const styles = useStyles();
+
+	const [itemTitle, setItemTitle] = React.useState<SharedString | undefined>(undefined);
+	const [itemDescription, setItemDescription] = React.useState<SharedString | undefined>(
+		undefined,
+	);
+	const [detailsVisible, setDetailsVisible] = React.useState<boolean>(false);
+
+	useTree(todoItemModel);
+
+	React.useEffect(() => {
+		todoItemModel.title
+			.get()
+			.then((text) => {
+				setItemTitle(text as SharedString);
+			})
+			.catch((error) => {
+				console.error("Failed to load title:", error);
+			});
+	}, [todoItemModel.title]);
+
+	React.useEffect(() => {
+		todoItemModel.description
+			.get()
+			.then((text) => {
+				setItemDescription(text as SharedString);
+			})
+			.catch((error) => {
+				console.error("Failed to load description:", error);
+			});
+	}, [todoItemModel.description]);
+
+	const checkChangedHandler = (e: React.ChangeEvent<HTMLInputElement>): void => {
+		todoItemModel.completed = e.target.checked;
+	};
+
+	if (itemTitle === undefined || itemDescription === undefined) {
+		return <div>Loading item...</div>;
+	}
+
+	return (
+		<div className={`todo-item${className === undefined ? "" : ` ${className}`}`}>
+			<h2 className={styles.todoItemHeader}>
+				<input
+					type="checkbox"
+					className={styles.todoItemCheckbox}
+					checked={todoItemModel.completed}
+					onChange={checkChangedHandler}
+				/>
+				<button
+					className={styles.todoItemExpandButton}
+					name="toggleDetailsVisible"
+					onClick={() => {
+						setDetailsVisible(!detailsVisible);
+					}}
+				>
+					{detailsVisible ? "▲" : "▼"}
+				</button>
+				<CollaborativeInput sharedString={itemTitle} className={styles.todoItemInput} />
+			</h2>
+			{detailsVisible && (
+				<CollaborativeTextArea
+					className={styles.todoItemDetails}
+					sharedStringHelper={new SharedStringHelper(itemDescription)}
+				/>
+			)}
+		</div>
+	);
+};
+
+/**
+ * Custom hook which invalidates a React Component when there is a change in the subtree defined by `subtreeRoot`.
+ * This includes changes to the tree's content, but not changes to its parentage.
+ * See {@link @fluidframework/tree#TreeChangeEvents.treeChanged} for details.
+ * @privateRemarks
+ * Without a way to get invalidation callbacks for specific fields,
+ * it's impractical to implement an ergonomic and efficient more fine-grained invalidation hook.
+ * TODO: This was copied over from "fluid-experimental/tree-react-api".
+ * Once the API has stabilized, this local copy should be removed and import from that package.
+ * @public
path: packages/tools/devtools/devtools-test-app/src/widgets/TodoItemView.tsx
user: login: alexvy86
body: Once more :)
```suggestion
```
created_at: 2025-05-19T15:04:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -3,14 +3,92 @@
  * Licensed under the MIT License.
  */
 
-import { DataObject, DataObjectFactory } from "@fluidframework/aqueduct/internal";
+import {
+	DataObject,
+	DataObjectFactory,
+	PureDataObjectFactory,
+	TreeDataObject,
+} from "@fluidframework/aqueduct/internal";
 import { SharedCell } from "@fluidframework/cell/internal";
 import type { IFluidHandle, IFluidLoadable } from "@fluidframework/core-interfaces";
 import { SharedCounter } from "@fluidframework/counter/internal";
 import { SharedMatrix } from "@fluidframework/matrix/internal";
 import { SharedString } from "@fluidframework/sequence/internal";
 import { type ITree, SchemaFactory, TreeViewConfiguration } from "@fluidframework/tree";
-import { SharedTree } from "@fluidframework/tree/internal";
+import { SharedTree, type TreeView } from "@fluidframework/tree/internal";
+import { v4 as uuid } from "uuid";
+
+import { TodoList, TodoItem, type TodoItemProps } from "./Schema.js";
+
+/**
+ * Additional {@link TreeDataObject} added to the {@link AppData}.
+ */
+export class AppDataTree extends TreeDataObject<TreeView<typeof TodoList>> {
+	public static readonly Name = "@devtools-example/test-app-tree-data-object";
+
+	public readonly config = new TreeViewConfiguration({ schema: TodoList });
+	public static readonly factory = new PureDataObjectFactory<
path: packages/tools/devtools/devtools-test-app/src/FluidObject.ts
user: login: jikim-msft
body: Thanks for the catch! 
created_at: 2025-05-19T22:05:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -249,18 +253,30 @@ export class DataVisualizerGraph
 	private registerVisualizerForVisualizableObject(
 		visualizableObject: VisualizableFluidObject,
 	): FluidObjectId {
-		if (!this.visualizerNodes.has(visualizableObject.id)) {
+		// In case the visualizable object is a `DataObject` or a `TreeDataObjct`, we extract the id of its root `ISharedObject` so that the view can correctly render the newly updated `VisualizerNode`.
+		// This is necessary because it is the `ISharedObject` (e.g., root or sharedTree of `DataObject` and `TreeDataObject`) which emits the event NOT the `DataObject` and `TreeDataObject`.
+		const fluidObjectId = isDataObject(visualizableObject)
+			? (visualizableObject as unknown as { readonly root: ISharedDirectory }).root.id
+			: isTreeDataObject(visualizableObject)
+				? (visualizableObject.sharedTree as unknown as ISharedObject).id
path: packages/tools/devtools/devtools-core/src/data-visualization/DataVisualization.ts
user: login: jikim-msft
body: So I did some digging on how SharedTree is created and stored in the `FluidObject.ts` and this is what I understand so far:

At runtime, `visualizableObject.sharedTree` is actually an `ISharedObject` because:
- [SharedTree.create()](https://github.com/microsoft/FluidFramework/blob/00dd82cfbd08f84a872de885aa946c28c1fb992c/packages/tools/devtools/devtools-test-app/src/FluidObject.ts#L158) [returns](https://github.com/microsoft/FluidFramework/blob/00dd82cfbd08f84a872de885aa946c28c1fb992c/packages/dds/tree/src/treeFactory.ts#L115) an object that is both an `ITree` $ `ISharedObject` 
- Key point is the use of [`makeSharedObjectKind<ITree>`](https://github.com/microsoft/FluidFramework/blob/00dd82cfbd08f84a872de885aa946c28c1fb992c/packages/dds/shared-object-base/src/sharedObjectKernel.ts#L463) which wraps the kernel in a shared object
- The resulting object implements both interfaces through composition not inheritance

However, in the type system:
- The [sharedTree](https://github.com/microsoft/FluidFramework/blob/00dd82cfbd08f84a872de885aa946c28c1fb992c/packages/framework/aqueduct/src/data-objects/treeDataObject.ts#L57) property of `TreeDataObject` is just `ITree`
- And `ITree` just extends `ViewableTree` & `IFluidLoadable`
- Thus I believe the type system doesn't know that every `ITree` is actually wrapped in an `ISharedObjet` at runtime

So we need the `unknown` cast as we are sure that `ITree` is indeed an `ISharedObject`. 
created_at: 2025-05-20T22:15:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -249,18 +253,30 @@ export class DataVisualizerGraph
 	private registerVisualizerForVisualizableObject(
 		visualizableObject: VisualizableFluidObject,
 	): FluidObjectId {
-		if (!this.visualizerNodes.has(visualizableObject.id)) {
+		// In case the visualizable object is a `DataObject` or a `TreeDataObjct`, we extract the id of its root `ISharedObject` so that the view can correctly render the newly updated `VisualizerNode`.
+		// This is necessary because it is the `ISharedObject` (e.g., root or sharedTree of `DataObject` and `TreeDataObject`) which emits the event NOT the `DataObject` and `TreeDataObject`.
+		const fluidObjectId = isDataObject(visualizableObject)
+			? (visualizableObject as unknown as { readonly root: ISharedDirectory }).root.id
+			: isTreeDataObject(visualizableObject)
+				? (visualizableObject.sharedTree as unknown as ISharedObject).id
path: packages/tools/devtools/devtools-core/src/data-visualization/DataVisualization.ts
user: login: alexvy86
body: I think your points above make sense, and I don't have enough context to say if it should work differently (e.g. that `ITree` was an `ISharedObject` or not). But I still wonder if `TreeDataObject` should expose its `sharedTree` property as an `ITree & ISharedObject` and do the necessary checks or casts itself. I imagine we could have it guarantee that what it exposes is indeed always an `ISharedObject`.

I think I'd also bring in @ChumpChief ; I believe in previous conversations with Ji, you suggested that a non-Tree DataObject shouldn't expose its `root`; is there a similar argument that `TreeDataObject` shouldn't expose the `sharedTree` either? I don't know if there's any purpose to that other than make it available here without the need for a cast like we do for DataObject.

cc @Josmithr for thoughts.
created_at: 2025-05-20T22:57:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,162 @@
+/*!
+ * Copyright (c) Microsoft Corporation and contributors. All rights reserved.
+ * Licensed under the MIT License.
+ */
+
+import { makeStyles } from "@fluentui/react-components";
+import {
+	CollaborativeInput,
+	CollaborativeTextArea,
+	SharedStringHelper,
+} from "@fluid-example/example-utils";
+import type { SharedString } from "@fluidframework/sequence/internal";
+import { Tree, type TreeNode } from "@fluidframework/tree/internal";
+import React from "react";
+
+import type { TodoItem } from "../Schema.js";
+
+const useStyles = makeStyles({
+	todoItemHeader: {
+		margin: 0,
+		display: "flex",
+	},
+	todoItemExpandButton: {
+		boxSizing: "border-box",
+		width: "30px",
+		height: "50px",
+		margin: "10px 0 10px 10px",
+		border: "none",
+		padding: 0,
+		fontSize: "30px",
+		background: "none",
+	},
+	todoItemCheckbox: {
+		boxSizing: "border-box",
+		width: "50px",
+		height: "50px",
+		margin: "10px 0 10px 10px",
+	},
+	todoItemInput: {
+		boxSizing: "border-box",
+		border: "1px solid #666",
+		width: "100%",
+		height: "50px",
+		margin: "10px 0 10px 10px",
+		padding: 0,
+		fontSize: "20px",
+		outline: "none",
+	},
+	todoItemDetails: {
+		width: "100%",
+	},
+});
+
+/**
+ * {@link TodoItemView} input props.
+ * @internal
+ */
+interface TodoItemViewProps {
+	readonly todoItemModel: TodoItem;
+	readonly className?: string;
+}
+
+/**
+ * Todo Item that will be stored in the {@link TodoListView} tree.
+ * Contains a title, description and a checkbox to mark it as completed.
+ */
+export const TodoItemView: React.FC<TodoItemViewProps> = (props: TodoItemViewProps) => {
+	const { todoItemModel, className } = props;
+	const styles = useStyles();
+
+	const [itemTitle, setItemTitle] = React.useState<SharedString | undefined>(undefined);
+	const [itemDescription, setItemDescription] = React.useState<SharedString | undefined>(
+		undefined,
+	);
+	const [detailsVisible, setDetailsVisible] = React.useState<boolean>(false);
+
+	useTree(todoItemModel);
+
+	React.useEffect(() => {
+		todoItemModel.title
+			.get()
+			.then((text) => {
+				setItemTitle(text as SharedString);
+			})
+			.catch((error) => {
+				console.error("Failed to load title:", error);
+			});
+	}, [todoItemModel.title]);
+
+	React.useEffect(() => {
+		todoItemModel.description
+			.get()
+			.then((text) => {
+				setItemDescription(text as SharedString);
+			})
+			.catch((error) => {
+				console.error("Failed to load description:", error);
+			});
+	}, [todoItemModel.description]);
path: packages/tools/devtools/devtools-test-app/src/widgets/TodoItemView.tsx
user: login: jikim-msft
body: I believe keeping the two seprate `useEffect` hooks would rather avoid the double render problem? Integrating everything into a single hook also looks cleaner, but the [original todo‑app](https://github.com/microsoft/FluidFramework/blob/52ba8aa3d0539c4eaf18fbf9c7e30c65cae64536/examples/data-objects/todo/src/TodoItem/TodoItemView.tsx#L24) seems to have used two separate hooks so the component doesn’t rerender when only title or description changes.

If we collapse everything into one hook that resolves a single promise, we’d still have to fetch both title and description even when just one of them updates.
created_at: 2025-05-21T00:11:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -249,18 +253,30 @@ export class DataVisualizerGraph
 	private registerVisualizerForVisualizableObject(
 		visualizableObject: VisualizableFluidObject,
 	): FluidObjectId {
-		if (!this.visualizerNodes.has(visualizableObject.id)) {
+		// In case the visualizable object is a `DataObject` or a `TreeDataObjct`, we extract the id of its root `ISharedObject` so that the view can correctly render the newly updated `VisualizerNode`.
+		// This is necessary because it is the `ISharedObject` (e.g., root or sharedTree of `DataObject` and `TreeDataObject`) which emits the event NOT the `DataObject` and `TreeDataObject`.
+		const fluidObjectId = isDataObject(visualizableObject)
+			? (visualizableObject as unknown as { readonly root: ISharedDirectory }).root.id
+			: isTreeDataObject(visualizableObject)
+				? (visualizableObject.sharedTree as unknown as ISharedObject).id
path: packages/tools/devtools/devtools-core/src/data-visualization/DataVisualization.ts
user: login: Josmithr
body: I think it would be fine to update `TreeDataObject` to use `ITree & ISharedObject`. But I think this will be moot if we stop using `IChannel.id` as the keys for our shared objects, which Ji and I had been discussing. That's probably a better solution. 
created_at: 2025-05-21T00:15:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,156 @@
+/*!
+ * Copyright (c) Microsoft Corporation and contributors. All rights reserved.
+ * Licensed under the MIT License.
+ */
+
+import { makeStyles } from "@fluentui/react-components";
+import { CollaborativeInput } from "@fluid-example/example-utils";
+import type { SharedString, ISharedString } from "@fluidframework/sequence/internal";
+import { Tree } from "@fluidframework/tree/internal";
+import React from "react";
+
+import type { AppDataTree } from "../FluidObject.js";
+
+import { TodoItemView, useTree } from "./TodoItemView.js";
+
+const useStyles = makeStyles({
+	todoView: {
+		maxWidth: "400px",
+		margin: "0 auto",
+		padding: "0 15px",
+	},
+	todoTitle: {
+		width: "100%",
+		border: "none",
+		fontSize: "30px",
+		textAlign: "center",
+		marginBottom: "5px",
+		marginTop: "5px",
+		outline: "none",
+	},
+	newItemForm: {
+		margin: "20px 0",
+	},
+	newItemText: {
+		boxSizing: "border-box",
+		width: "calc(100% - 50px)",
+		height: "50px",
+		border: "1px solid #666",
+		verticalAlign: "middle",
+		fontSize: "30px",
+		outline: "0",
+	},
+	newItemButton: {
+		width: "50px",
+		height: "50px",
+		border: "1px solid #666",
+		verticalAlign: "middle",
+		fontSize: "30px",
+	},
+	itemWrap: {
+		display: "flex",
+	},
+	todoItemView: {
+		display: "inline-block",
+		width: "calc(100% - 100px)",
+	},
+	actionButton: {
+		boxSizing: "border-box",
+		width: "50px",
+		height: "50px",
+		margin: "10px 0 10px 10px",
+		border: "1px solid #666",
+		padding: "0",
+		fontSize: "30px",
+		"&:last-child": {
+			margin: "10px",
+		},
+	},
+});
+
+/**
+ * {@link TodoListView} input props.
+ * @internal
+ */
+export interface TodoListProps {
+	readonly todoModel: AppDataTree;
+}
+
+/**
+ * Contains the list of {@link TodoItemView}, an editable title input and an input/button for submitting new items.
+ */
+export const TodoListView: React.FC<TodoListProps> = (props: TodoListProps) => {
+	const { todoModel } = props;
+	const styles = useStyles();
+	const [titleString, setTitleString] = React.useState<SharedString | undefined>();
+
+	const newItemTextInputRef = React.useRef<HTMLInputElement>(null);
+
+	useTree(todoModel.treeView.root);
+
+	React.useEffect(() => {
+		Promise.resolve(todoModel.treeView.root.title.get())
+			.then((title) => {
+				setTitleString(title as ISharedString);
+			})
+			.catch((error) => {
+				console.error(error);
+			});
+		return () => {};
+	}, [todoModel]);
+
+	if (titleString === undefined) {
+		return <div>Loading...</div>;
+	}
+	const handleCreateClick = (ev: React.FormEvent<HTMLFormElement>): void => {
+		ev.preventDefault();
+
+		const input = newItemTextInputRef.current;
+		if (!input) {
+			throw new Error("New item text field missing");
+		}
+
+		todoModel
+			.addTodoItem({ startingText: input.value })
+			.then(() => {
+				input.value = "";
+			})
+			.catch((error) => {
+				console.error("Failed to create todo item:", error);
+			});
+	};
+
+	const todoItemViews = [...todoModel.treeView.root.items.entries()].map(([id, todoItem]) => (
+		<div className={styles.itemWrap} key={id}>
+			<TodoItemView todoItemModel={todoItem} className={styles.todoItemView} />
+			<button
+				className={styles.actionButton}
+				onClick={() => {
+					todoModel.treeView.root.items.delete(id);
+					Tree.on(todoModel.treeView.root.items, "treeChanged", () => {});
path: packages/tools/devtools/devtools-test-app/src/widgets/TodoListView.tsx
user: login: jikim-msft
body: Being honest here, `TodoItemView.tsx` and `TodoListView.tsx` are pretty much the same code from the `examples/data-object. 

However, giving a second thought about this `onClick()` event, I agree that we don't need to set up a new empty listneer here. Since the component is rendered based on the `todoModel.root.items`, the visualizer correctly renders the `TreeDataObject.sharedTree` state without this line. 

Instead, I did something like: 
```ts
<button
	className={styles.actionButton}
	onClick={() => {
		try {
			todoModel.treeView.root.items.delete(id);
		} catch (error) {
			console.error("Failed to delete todo item:", error);
		}
	}}
	title="Delete item"
>
	X
</button>
```

Looping @Josmithr @daesun-park for more context here. 
created_at: 2025-05-21T00:17:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,162 @@
+/*!
+ * Copyright (c) Microsoft Corporation and contributors. All rights reserved.
+ * Licensed under the MIT License.
+ */
+
+import { makeStyles } from "@fluentui/react-components";
+import {
+	CollaborativeInput,
+	CollaborativeTextArea,
+	SharedStringHelper,
+} from "@fluid-example/example-utils";
+import type { SharedString } from "@fluidframework/sequence/internal";
+import { Tree, type TreeNode } from "@fluidframework/tree/internal";
+import React from "react";
+
+import type { TodoItem } from "../Schema.js";
+
+const useStyles = makeStyles({
+	todoItemHeader: {
+		margin: 0,
+		display: "flex",
+	},
+	todoItemExpandButton: {
+		boxSizing: "border-box",
+		width: "30px",
+		height: "50px",
+		margin: "10px 0 10px 10px",
+		border: "none",
+		padding: 0,
+		fontSize: "30px",
+		background: "none",
+	},
+	todoItemCheckbox: {
+		boxSizing: "border-box",
+		width: "50px",
+		height: "50px",
+		margin: "10px 0 10px 10px",
+	},
+	todoItemInput: {
+		boxSizing: "border-box",
+		border: "1px solid #666",
+		width: "100%",
+		height: "50px",
+		margin: "10px 0 10px 10px",
+		padding: 0,
+		fontSize: "20px",
+		outline: "none",
+	},
+	todoItemDetails: {
+		width: "100%",
+	},
+});
+
+/**
+ * {@link TodoItemView} input props.
+ * @internal
+ */
+interface TodoItemViewProps {
+	readonly todoItemModel: TodoItem;
+	readonly className?: string;
+}
+
+/**
+ * Todo Item that will be stored in the {@link TodoListView} tree.
+ * Contains a title, description and a checkbox to mark it as completed.
+ */
+export const TodoItemView: React.FC<TodoItemViewProps> = (props: TodoItemViewProps) => {
+	const { todoItemModel, className } = props;
+	const styles = useStyles();
+
+	const [itemTitle, setItemTitle] = React.useState<SharedString | undefined>(undefined);
+	const [itemDescription, setItemDescription] = React.useState<SharedString | undefined>(
+		undefined,
+	);
+	const [detailsVisible, setDetailsVisible] = React.useState<boolean>(false);
+
+	useTree(todoItemModel);
+
+	React.useEffect(() => {
+		todoItemModel.title
+			.get()
+			.then((text) => {
+				setItemTitle(text as SharedString);
+			})
+			.catch((error) => {
+				console.error("Failed to load title:", error);
+			});
+	}, [todoItemModel.title]);
+
+	React.useEffect(() => {
+		todoItemModel.description
+			.get()
+			.then((text) => {
+				setItemDescription(text as SharedString);
+			})
+			.catch((error) => {
+				console.error("Failed to load description:", error);
+			});
+	}, [todoItemModel.description]);
path: packages/tools/devtools/devtools-test-app/src/widgets/TodoItemView.tsx
user: login: Josmithr
body: I agree with Ji here - I think the separate `useEffect`s is better and more idiomatic to React. Consider the case where the `description` field is updated with a new `SharedString` handle. In that case, we want to re-run the necessary update logic for the new `description`, but there is no need to re-run the logic for `title`.
created_at: 2025-05-21T00:18:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -249,18 +240,34 @@ export class DataVisualizerGraph
 	private registerVisualizerForVisualizableObject(
 		visualizableObject: VisualizableFluidObject,
 	): FluidObjectId {
-		if (!this.visualizerNodes.has(visualizableObject.id)) {
-			// Create visualizer node for the shared object
-			const visualizationFunction = isDataObject(visualizableObject)
-				? visualizeDataObject
-				: (this.visualizers[visualizableObject.attributes.type] ??
-					visualizeUnknownSharedObject);
+		// Store type check results to avoid recomputing
+		const isDataObj = isDataObject(visualizableObject);
+		const isTreeDataObj = isTreeDataObject(visualizableObject);
+
+		let rootSharedObject: ISharedObject;
path: packages/tools/devtools/devtools-core/src/data-visualization/DataVisualization.ts
user: login: Josmithr
body: This looks great! Thanks for refactoring!
created_at: 2025-05-21T00:20:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -549,3 +569,35 @@ function isDataObject(value: unknown): value is DataObject {
 
 	return false;
 }
+
+/**
+ * Determines whether or not the provided value is an {@link TreeDataObject} using `instanceof`, for the purposes of this library.
+ * @remarks
+ * Uses `instanceof` over checking specific properties or methods, because we decided that a version mix-up with
+ * {@link @fluidframework/aqueduct#} is unlikely between devtools and end-user applications, and we don't support it anyway.
+ * In addition, we check for the presence of key properties that make a `TreeDataObject` unique:
+ * - {@link TreeDataObject#sharedTree | sharedTree} getter
+ * - {@link TreeDataObject#treeView | treeView} getter
+ * - {@link TreeDataObject#initializeInternal | initializeInternal} method
+ */
+function isTreeDataObject(value: unknown): value is TreeDataObject<unknown> {
+	if (
+		value instanceof TreeDataObject ||
+		(typeof (value as TreeDataObject<unknown>).initializeInternal === "function" &&
+			Object.getOwnPropertyDescriptor(Object.getPrototypeOf(value), "sharedTree")?.get !==
+				undefined &&
+			Object.getOwnPropertyDescriptor(Object.getPrototypeOf(value), "treeView")?.get !==
path: packages/tools/devtools/devtools-core/src/data-visualization/DataVisualization.ts
user: login: Josmithr
body: I would vote to remove this check then. I don't think it's necessary.
created_at: 2025-05-21T00:20:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -358,7 +387,7 @@ export const visualizeUnknownSharedObject: VisualizeSharedObject = async (
 
 /**
  * List of default visualizers included in the library.
- * @remarks {@link @fluidframework/aqueduct#DataObject} does not have type information, thus not included in the list.
+ * @remarks {@link @fluidframework/aqueduct#DataObject} & {@link @fluidframework/aqueduct#TreeDataObject} does not have type information, thus not included in the list.
path: packages/tools/devtools/devtools-core/src/data-visualization/DefaultVisualizers.ts
user: login: Josmithr
body: ```suggestion
 * @remarks {@link @fluidframework/aqueduct#DataObject} & {@link @fluidframework/aqueduct#TreeDataObject} do not have type information and thus are not included in the list.
```
created_at: 2025-05-21T00:23:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,160 @@
+/*!
+ * Copyright (c) Microsoft Corporation and contributors. All rights reserved.
+ * Licensed under the MIT License.
+ */
+
+import { makeStyles } from "@fluentui/react-components";
+import {
+	CollaborativeInput,
+	CollaborativeTextArea,
+	SharedStringHelper,
+} from "@fluid-example/example-utils";
+import type { SharedString } from "@fluidframework/sequence/internal";
+import { Tree, type TreeNode } from "@fluidframework/tree/internal";
+import React from "react";
+
+import type { TodoItem } from "../Schema.js";
+
+const useStyles = makeStyles({
+	todoItemHeader: {
+		margin: 0,
+		display: "flex",
+	},
+	todoItemExpandButton: {
+		boxSizing: "border-box",
+		width: "30px",
+		height: "50px",
+		margin: "10px 0 10px 10px",
+		border: "none",
+		padding: 0,
+		fontSize: "30px",
+		background: "none",
+	},
+	todoItemCheckbox: {
+		boxSizing: "border-box",
+		width: "50px",
+		height: "50px",
+		margin: "10px 0 10px 10px",
+	},
+	todoItemInput: {
+		boxSizing: "border-box",
+		border: "1px solid #666",
+		width: "100%",
+		height: "50px",
+		margin: "10px 0 10px 10px",
+		padding: 0,
+		fontSize: "20px",
+		outline: "none",
+	},
+	todoItemDetails: {
+		width: "100%",
+	},
+});
+
+/**
+ * {@link TodoItemView} input props.
+ */
+interface TodoItemViewProps {
+	readonly todoItemModel: TodoItem;
+	readonly className?: string;
+}
+
+/**
+ * Todo Item that will be stored in the {@link TodoListView} tree.
+ * Contains a title, description and a checkbox to mark it as completed.
+ */
+export const TodoItemView: React.FC<TodoItemViewProps> = (props: TodoItemViewProps) => {
+	const { todoItemModel, className } = props;
+	const styles = useStyles();
+
+	const [itemTitle, setItemTitle] = React.useState<SharedString | undefined>(undefined);
+	const [itemDescription, setItemDescription] = React.useState<SharedString | undefined>(
+		undefined,
+	);
+	const [detailsVisible, setDetailsVisible] = React.useState<boolean>(false);
+
+	useTree(todoItemModel);
+
+	React.useEffect(() => {
+		todoItemModel.title
+			.get()
+			.then((text) => {
+				setItemTitle(text as SharedString);
+			})
+			.catch((error) => {
+				console.error("Failed to load title:", error);
+			});
+	}, [todoItemModel.title]);
+
+	React.useEffect(() => {
+		todoItemModel.description
+			.get()
+			.then((text) => {
+				setItemDescription(text as SharedString);
+			})
+			.catch((error) => {
+				console.error("Failed to load description:", error);
+			});
+	}, [todoItemModel.description]);
+
+	const checkChangedHandler = (e: React.ChangeEvent<HTMLInputElement>): void => {
+		todoItemModel.completed = e.target.checked;
+	};
+
+	if (itemTitle === undefined || itemDescription === undefined) {
+		return <div>Loading item...</div>;
+	}
+
+	return (
+		<div className={`todo-item${className === undefined ? "" : ` ${className}`}`}>
+			<h2 className={styles.todoItemHeader}>
+				<input
+					type="checkbox"
+					className={styles.todoItemCheckbox}
+					checked={todoItemModel.completed}
+					onChange={checkChangedHandler}
+				/>
+				<button
+					className={styles.todoItemExpandButton}
+					name="toggleDetailsVisible"
+					onClick={() => {
+						setDetailsVisible(!detailsVisible);
+					}}
+				>
+					{detailsVisible ? "▲" : "▼"}
+				</button>
+				<CollaborativeInput sharedString={itemTitle} className={styles.todoItemInput} />
+			</h2>
+			{detailsVisible && (
+				<CollaborativeTextArea
+					className={styles.todoItemDetails}
+					sharedStringHelper={new SharedStringHelper(itemDescription)}
+				/>
+			)}
+		</div>
+	);
+};
+
+/**
+ * Custom hook which invalidates a React Component when there is a change in the subtree defined by `subtreeRoot`.
+ * This includes changes to the tree's content, but not changes to its parentage.
+ * See {@link @fluidframework/tree#TreeChangeEvents.treeChanged} for details.
+ * @privateRemarks
+ * Without a way to get invalidation callbacks for specific fields,
+ * it's impractical to implement an ergonomic and efficient more fine-grained invalidation hook.
+ * TODO: This was copied over from "fluid-experimental/tree-react-api".
+ * Once the API has stabilized, this local copy should be removed and import from that package.
+ */
+export function useTree(subtreeRoot: TreeNode): void {
path: packages/tools/devtools/devtools-test-app/src/widgets/TodoItemView.tsx
user: login: Josmithr
body: We can import this directly from `@fluid-experimental/tree-react-api`. We intentionally didn't do that in the `todo` example because I wanted the example to be self-contained. But we can use it here.
created_at: 2025-05-21T00:30:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,156 @@
+/*!
+ * Copyright (c) Microsoft Corporation and contributors. All rights reserved.
+ * Licensed under the MIT License.
+ */
+
+import { makeStyles } from "@fluentui/react-components";
+import { CollaborativeInput } from "@fluid-example/example-utils";
+import type { SharedString, ISharedString } from "@fluidframework/sequence/internal";
+import { Tree } from "@fluidframework/tree/internal";
+import React from "react";
+
+import type { AppDataTree } from "../FluidObject.js";
+
+import { TodoItemView, useTree } from "./TodoItemView.js";
+
+const useStyles = makeStyles({
+	todoView: {
+		maxWidth: "400px",
+		margin: "0 auto",
+		padding: "0 15px",
+	},
+	todoTitle: {
+		width: "100%",
+		border: "none",
+		fontSize: "30px",
+		textAlign: "center",
+		marginBottom: "5px",
+		marginTop: "5px",
+		outline: "none",
+	},
+	newItemForm: {
+		margin: "20px 0",
+	},
+	newItemText: {
+		boxSizing: "border-box",
+		width: "calc(100% - 50px)",
+		height: "50px",
+		border: "1px solid #666",
+		verticalAlign: "middle",
+		fontSize: "30px",
+		outline: "0",
+	},
+	newItemButton: {
+		width: "50px",
+		height: "50px",
+		border: "1px solid #666",
+		verticalAlign: "middle",
+		fontSize: "30px",
+	},
+	itemWrap: {
+		display: "flex",
+	},
+	todoItemView: {
+		display: "inline-block",
+		width: "calc(100% - 100px)",
+	},
+	actionButton: {
+		boxSizing: "border-box",
+		width: "50px",
+		height: "50px",
+		margin: "10px 0 10px 10px",
+		border: "1px solid #666",
+		padding: "0",
+		fontSize: "30px",
+		"&:last-child": {
+			margin: "10px",
+		},
+	},
+});
+
+/**
+ * {@link TodoListView} input props.
+ * @internal
+ */
+export interface TodoListProps {
+	readonly todoModel: AppDataTree;
+}
+
+/**
+ * Contains the list of {@link TodoItemView}, an editable title input and an input/button for submitting new items.
+ */
+export const TodoListView: React.FC<TodoListProps> = (props: TodoListProps) => {
+	const { todoModel } = props;
+	const styles = useStyles();
+	const [titleString, setTitleString] = React.useState<SharedString | undefined>();
+
+	const newItemTextInputRef = React.useRef<HTMLInputElement>(null);
+
+	useTree(todoModel.treeView.root);
+
+	React.useEffect(() => {
+		Promise.resolve(todoModel.treeView.root.title.get())
+			.then((title) => {
+				setTitleString(title as ISharedString);
+			})
+			.catch((error) => {
+				console.error(error);
+			});
+		return () => {};
+	}, [todoModel]);
+
+	if (titleString === undefined) {
+		return <div>Loading...</div>;
+	}
+	const handleCreateClick = (ev: React.FormEvent<HTMLFormElement>): void => {
+		ev.preventDefault();
+
+		const input = newItemTextInputRef.current;
+		if (!input) {
+			throw new Error("New item text field missing");
+		}
+
+		todoModel
+			.addTodoItem({ startingText: input.value })
+			.then(() => {
+				input.value = "";
+			})
+			.catch((error) => {
+				console.error("Failed to create todo item:", error);
+			});
+	};
+
+	const todoItemViews = [...todoModel.treeView.root.items.entries()].map(([id, todoItem]) => (
+		<div className={styles.itemWrap} key={id}>
+			<TodoItemView todoItemModel={todoItem} className={styles.todoItemView} />
+			<button
+				className={styles.actionButton}
+				onClick={() => {
+					todoModel.treeView.root.items.delete(id);
+					Tree.on(todoModel.treeView.root.items, "treeChanged", () => {});
path: packages/tools/devtools/devtools-test-app/src/widgets/TodoListView.tsx
user: login: Josmithr
body: Oof, yeah, I must have missed this in review. It should be safe to just delete the line adding the event listener. I don't think we need the try-catch though. 
created_at: 2025-05-21T00:35:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,156 @@
+/*!
+ * Copyright (c) Microsoft Corporation and contributors. All rights reserved.
+ * Licensed under the MIT License.
+ */
+
+import { makeStyles } from "@fluentui/react-components";
+import { CollaborativeInput } from "@fluid-example/example-utils";
+import type { SharedString, ISharedString } from "@fluidframework/sequence/internal";
+import { Tree } from "@fluidframework/tree/internal";
+import React from "react";
+
+import type { AppDataTree } from "../FluidObject.js";
+
+import { TodoItemView, useTree } from "./TodoItemView.js";
+
+const useStyles = makeStyles({
+	todoView: {
+		maxWidth: "400px",
+		margin: "0 auto",
+		padding: "0 15px",
+	},
+	todoTitle: {
+		width: "100%",
+		border: "none",
+		fontSize: "30px",
+		textAlign: "center",
+		marginBottom: "5px",
+		marginTop: "5px",
+		outline: "none",
+	},
+	newItemForm: {
+		margin: "20px 0",
+	},
+	newItemText: {
+		boxSizing: "border-box",
+		width: "calc(100% - 50px)",
+		height: "50px",
+		border: "1px solid #666",
+		verticalAlign: "middle",
+		fontSize: "30px",
+		outline: "0",
+	},
+	newItemButton: {
+		width: "50px",
+		height: "50px",
+		border: "1px solid #666",
+		verticalAlign: "middle",
+		fontSize: "30px",
+	},
+	itemWrap: {
+		display: "flex",
+	},
+	todoItemView: {
+		display: "inline-block",
+		width: "calc(100% - 100px)",
+	},
+	actionButton: {
+		boxSizing: "border-box",
+		width: "50px",
+		height: "50px",
+		margin: "10px 0 10px 10px",
+		border: "1px solid #666",
+		padding: "0",
+		fontSize: "30px",
+		"&:last-child": {
+			margin: "10px",
+		},
+	},
+});
+
+/**
+ * {@link TodoListView} input props.
+ * @internal
+ */
+export interface TodoListProps {
+	readonly todoModel: AppDataTree;
+}
+
+/**
+ * Contains the list of {@link TodoItemView}, an editable title input and an input/button for submitting new items.
+ */
+export const TodoListView: React.FC<TodoListProps> = (props: TodoListProps) => {
+	const { todoModel } = props;
+	const styles = useStyles();
+	const [titleString, setTitleString] = React.useState<SharedString | undefined>();
+
+	const newItemTextInputRef = React.useRef<HTMLInputElement>(null);
+
+	useTree(todoModel.treeView.root);
+
+	React.useEffect(() => {
+		Promise.resolve(todoModel.treeView.root.title.get())
+			.then((title) => {
+				setTitleString(title as ISharedString);
+			})
+			.catch((error) => {
+				console.error(error);
+			});
+		return () => {};
+	}, [todoModel]);
+
+	if (titleString === undefined) {
+		return <div>Loading...</div>;
+	}
+	const handleCreateClick = (ev: React.FormEvent<HTMLFormElement>): void => {
+		ev.preventDefault();
+
+		const input = newItemTextInputRef.current;
+		if (!input) {
+			throw new Error("New item text field missing");
+		}
+
+		todoModel
+			.addTodoItem({ startingText: input.value })
+			.then(() => {
+				input.value = "";
+			})
+			.catch((error) => {
+				console.error("Failed to create todo item:", error);
+			});
+	};
+
+	const todoItemViews = [...todoModel.treeView.root.items.entries()].map(([id, todoItem]) => (
+		<div className={styles.itemWrap} key={id}>
+			<TodoItemView todoItemModel={todoItem} className={styles.todoItemView} />
+			<button
+				className={styles.actionButton}
+				onClick={() => {
+					todoModel.treeView.root.items.delete(id);
+					Tree.on(todoModel.treeView.root.items, "treeChanged", () => {});
path: packages/tools/devtools/devtools-test-app/src/widgets/TodoListView.tsx
user: login: Josmithr
body: PR removing it in `todo`: https://github.com/microsoft/FluidFramework/pull/24674
created_at: 2025-05-21T00:39:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,162 @@
+/*!
+ * Copyright (c) Microsoft Corporation and contributors. All rights reserved.
+ * Licensed under the MIT License.
+ */
+
+import { makeStyles } from "@fluentui/react-components";
+import {
+	CollaborativeInput,
+	CollaborativeTextArea,
+	SharedStringHelper,
+} from "@fluid-example/example-utils";
+import type { SharedString } from "@fluidframework/sequence/internal";
+import { Tree, type TreeNode } from "@fluidframework/tree/internal";
+import React from "react";
+
+import type { TodoItem } from "../Schema.js";
+
+const useStyles = makeStyles({
+	todoItemHeader: {
+		margin: 0,
+		display: "flex",
+	},
+	todoItemExpandButton: {
+		boxSizing: "border-box",
+		width: "30px",
+		height: "50px",
+		margin: "10px 0 10px 10px",
+		border: "none",
+		padding: 0,
+		fontSize: "30px",
+		background: "none",
+	},
+	todoItemCheckbox: {
+		boxSizing: "border-box",
+		width: "50px",
+		height: "50px",
+		margin: "10px 0 10px 10px",
+	},
+	todoItemInput: {
+		boxSizing: "border-box",
+		border: "1px solid #666",
+		width: "100%",
+		height: "50px",
+		margin: "10px 0 10px 10px",
+		padding: 0,
+		fontSize: "20px",
+		outline: "none",
+	},
+	todoItemDetails: {
+		width: "100%",
+	},
+});
+
+/**
+ * {@link TodoItemView} input props.
+ * @internal
+ */
+interface TodoItemViewProps {
+	readonly todoItemModel: TodoItem;
+	readonly className?: string;
+}
+
+/**
+ * Todo Item that will be stored in the {@link TodoListView} tree.
+ * Contains a title, description and a checkbox to mark it as completed.
+ */
+export const TodoItemView: React.FC<TodoItemViewProps> = (props: TodoItemViewProps) => {
+	const { todoItemModel, className } = props;
+	const styles = useStyles();
+
+	const [itemTitle, setItemTitle] = React.useState<SharedString | undefined>(undefined);
+	const [itemDescription, setItemDescription] = React.useState<SharedString | undefined>(
+		undefined,
+	);
+	const [detailsVisible, setDetailsVisible] = React.useState<boolean>(false);
+
+	useTree(todoItemModel);
+
+	React.useEffect(() => {
+		todoItemModel.title
+			.get()
+			.then((text) => {
+				setItemTitle(text as SharedString);
+			})
+			.catch((error) => {
+				console.error("Failed to load title:", error);
+			});
+	}, [todoItemModel.title]);
+
+	React.useEffect(() => {
+		todoItemModel.description
+			.get()
+			.then((text) => {
+				setItemDescription(text as SharedString);
+			})
+			.catch((error) => {
+				console.error("Failed to load description:", error);
+			});
+	}, [todoItemModel.description]);
path: packages/tools/devtools/devtools-test-app/src/widgets/TodoItemView.tsx
user: login: alexvy86
body: 👍 no objections
created_at: 2025-05-21T02:04:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -249,18 +253,30 @@ export class DataVisualizerGraph
 	private registerVisualizerForVisualizableObject(
 		visualizableObject: VisualizableFluidObject,
 	): FluidObjectId {
-		if (!this.visualizerNodes.has(visualizableObject.id)) {
+		// In case the visualizable object is a `DataObject` or a `TreeDataObjct`, we extract the id of its root `ISharedObject` so that the view can correctly render the newly updated `VisualizerNode`.
+		// This is necessary because it is the `ISharedObject` (e.g., root or sharedTree of `DataObject` and `TreeDataObject`) which emits the event NOT the `DataObject` and `TreeDataObject`.
+		const fluidObjectId = isDataObject(visualizableObject)
+			? (visualizableObject as unknown as { readonly root: ISharedDirectory }).root.id
+			: isTreeDataObject(visualizableObject)
+				? (visualizableObject.sharedTree as unknown as ISharedObject).id
path: packages/tools/devtools/devtools-core/src/data-visualization/DataVisualization.ts
user: login: jikim-msft
body: Even if I change to `TreeDataObject.sharedTree: ITree & ISharedObject`, direct access of `visualizableObject.sharedTree` without explicit typecasting throws an error 

`Type 'ITree' is missing the following properties from type 'ISharedObject<ISharedObjectEvents>': bindToContext, id, attributes, getAttachSummary, and 7 more.ts(2740)`


Updating the `isTreeDataObject()` function stricter as below allows the direct access of `SharedTree` in the `registerVisualizerForVisualizableObject()` :
```
// Verify that sharedTree has ISharedObject properties
const sharedObject = tree as unknown as ISharedObject;
if (
	sharedObject.id === undefined ||
	sharedObject.attributes?.type === undefined ||
	sharedObject.on === undefined
) {
	throw new TypeError(
		"TreeDataObject's sharedTree must implement ISharedObject interface.",
	);
}
```

Thoughts? 
created_at: 2025-05-21T23:28:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -249,18 +253,30 @@ export class DataVisualizerGraph
 	private registerVisualizerForVisualizableObject(
 		visualizableObject: VisualizableFluidObject,
 	): FluidObjectId {
-		if (!this.visualizerNodes.has(visualizableObject.id)) {
+		// In case the visualizable object is a `DataObject` or a `TreeDataObjct`, we extract the id of its root `ISharedObject` so that the view can correctly render the newly updated `VisualizerNode`.
+		// This is necessary because it is the `ISharedObject` (e.g., root or sharedTree of `DataObject` and `TreeDataObject`) which emits the event NOT the `DataObject` and `TreeDataObject`.
+		const fluidObjectId = isDataObject(visualizableObject)
+			? (visualizableObject as unknown as { readonly root: ISharedDirectory }).root.id
+			: isTreeDataObject(visualizableObject)
+				? (visualizableObject.sharedTree as unknown as ISharedObject).id
path: packages/tools/devtools/devtools-core/src/data-visualization/DataVisualization.ts
user: login: Josmithr
body: Did you rebuild the aqueduct package? I don't understand how changing the property type wouldn't work. That looks like the devtools package just isn't aware of the type change.
created_at: 2025-05-22T00:02:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -249,18 +253,30 @@ export class DataVisualizerGraph
 	private registerVisualizerForVisualizableObject(
 		visualizableObject: VisualizableFluidObject,
 	): FluidObjectId {
-		if (!this.visualizerNodes.has(visualizableObject.id)) {
+		// In case the visualizable object is a `DataObject` or a `TreeDataObjct`, we extract the id of its root `ISharedObject` so that the view can correctly render the newly updated `VisualizerNode`.
+		// This is necessary because it is the `ISharedObject` (e.g., root or sharedTree of `DataObject` and `TreeDataObject`) which emits the event NOT the `DataObject` and `TreeDataObject`.
+		const fluidObjectId = isDataObject(visualizableObject)
+			? (visualizableObject as unknown as { readonly root: ISharedDirectory }).root.id
+			: isTreeDataObject(visualizableObject)
+				? (visualizableObject.sharedTree as unknown as ISharedObject).id
path: packages/tools/devtools/devtools-core/src/data-visualization/DataVisualization.ts
user: login: jikim-msft
body: My bad 😓, completely forgot about rebuilding the aqueduct package! Now working as expected. Thanks!
created_at: 2025-05-22T00:39:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -249,18 +253,30 @@ export class DataVisualizerGraph
 	private registerVisualizerForVisualizableObject(
 		visualizableObject: VisualizableFluidObject,
 	): FluidObjectId {
-		if (!this.visualizerNodes.has(visualizableObject.id)) {
+		// In case the visualizable object is a `DataObject` or a `TreeDataObjct`, we extract the id of its root `ISharedObject` so that the view can correctly render the newly updated `VisualizerNode`.
+		// This is necessary because it is the `ISharedObject` (e.g., root or sharedTree of `DataObject` and `TreeDataObject`) which emits the event NOT the `DataObject` and `TreeDataObject`.
+		const fluidObjectId = isDataObject(visualizableObject)
+			? (visualizableObject as unknown as { readonly root: ISharedDirectory }).root.id
+			: isTreeDataObject(visualizableObject)
+				? (visualizableObject.sharedTree as unknown as ISharedObject).id
path: packages/tools/devtools/devtools-core/src/data-visualization/DataVisualization.ts
user: login: alexvy86
body: Shouldn't it have rebuilt automatically when building the test app if it's a transitive dependency? 🤔 
created_at: 2025-05-22T15:04:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -249,18 +253,30 @@ export class DataVisualizerGraph
 	private registerVisualizerForVisualizableObject(
 		visualizableObject: VisualizableFluidObject,
 	): FluidObjectId {
-		if (!this.visualizerNodes.has(visualizableObject.id)) {
+		// In case the visualizable object is a `DataObject` or a `TreeDataObjct`, we extract the id of its root `ISharedObject` so that the view can correctly render the newly updated `VisualizerNode`.
+		// This is necessary because it is the `ISharedObject` (e.g., root or sharedTree of `DataObject` and `TreeDataObject`) which emits the event NOT the `DataObject` and `TreeDataObject`.
+		const fluidObjectId = isDataObject(visualizableObject)
+			? (visualizableObject as unknown as { readonly root: ISharedDirectory }).root.id
+			: isTreeDataObject(visualizableObject)
+				? (visualizableObject.sharedTree as unknown as ISharedObject).id
path: packages/tools/devtools/devtools-core/src/data-visualization/DataVisualization.ts
user: login: Josmithr
body: If you explicitly re_build_, yes. But I think the common flow while working in devtools is to just use our multi-package watch mode (which only watches for changes in the devtools packages).
created_at: 2025-05-22T18:57:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -294,6 +301,10 @@ export class DataVisualizerGraph
 			return this.registerVisualizerForVisualizableObject(resolvedObject);
 		}
 
+		if (isTreeDataObject(resolvedObject)) {
path: packages/tools/devtools/devtools-core/src/data-visualization/DataVisualization.ts
user: login: Josmithr
body: Nit: you could combine this conditional block with the one above.
created_at: 2025-05-23T20:27:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -104,26 +104,55 @@ export const visualizeSharedCell: VisualizeSharedObject = async (
 };
 
 /**
- * Default {@link VisualizeSharedObject} for {@link DataObject}.
- * @remarks This takes in a `root` of type {@link ISharedDirectory} from {@link DataObject} and visualizes its children.
+ * Creates a visualizer function that captures the parent object's ID for use in generating the fluidObjectId.
+ * @param parentId - The ID of the parent DataObject to use in generating unique fluidObjectIds
+ * @returns A VisualizeSharedObject function that will use the parentId in its fluidObjectId generation
  */
-export const visualizeDataObject: VisualizeSharedObject = async (
-	dataObjectRoot: ISharedObject,
-	visualizeChildData: VisualizeChildData,
-): Promise<FluidObjectTreeNode> => {
-	const renderedChildData = (await visualizeSharedDirectory(
-		dataObjectRoot,
-		visualizeChildData,
-	)) as FluidObjectTreeNode; // TODO: Refactor the visualizer to accept generic type to avoid type casting.
+export function createDataObjectVisualizer(parentId: string): VisualizeSharedObject {
+	return async (
+		dataObjectRoot: ISharedObject,
+		visualizeChildData: VisualizeChildData,
+	): Promise<FluidObjectTreeNode> => {
+		const renderedChildData = (await visualizeSharedDirectory(
+			dataObjectRoot,
+			visualizeChildData,
+		)) as FluidObjectTreeNode;
 
-	return {
-		fluidObjectId: dataObjectRoot.id,
-		children: renderedChildData.children,
-		metadata: renderedChildData.metadata,
-		typeMetadata: "DataObject",
-		nodeKind: VisualNodeKind.FluidTreeNode,
+		return {
+			fluidObjectId: `${parentId}-${dataObjectRoot.id}`,
+			children: renderedChildData.children,
+			metadata: renderedChildData.metadata,
+			typeMetadata: "DataObject",
+			nodeKind: VisualNodeKind.FluidTreeNode,
+		};
 	};
-};
+}
+
+/**
+ * Creates a visualizer function that captures the parent object's ID for use in generating the fluidObjectId.
+ * @param parentId - The ID of the parent TreeDataObject to use in generating unique fluidObjectIds
+ * @returns A VisualizeSharedObject function that will use the parentId in its fluidObjectId generation
+ */
+export function createTreeDataObjectVisualizer(parentId: string): VisualizeSharedObject {
+	return async (
+		rootTree: ISharedObject,
+		visualizeChildData: VisualizeChildData,
+	): Promise<FluidObjectTreeNode> => {
+		const fluidObjectId = `${parentId}-${rootTree.id}`;
path: packages/tools/devtools/devtools-core/src/data-visualization/DefaultVisualizers.ts
user: login: Josmithr
body: Nit: in `createDataObjectVisualizer`, this is inlined into the return statement. Either way (inlined or variable) is fine, but I would prefer we be consistent :)
created_at: 2025-05-23T20:30:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -3,14 +3,102 @@
  * Licensed under the MIT License.
  */
 
-import { DataObject, DataObjectFactory } from "@fluidframework/aqueduct/internal";
+import {
+	DataObject,
+	DataObjectFactory,
+	PureDataObjectFactory,
+	TreeDataObject,
+} from "@fluidframework/aqueduct/internal";
 import { SharedCell } from "@fluidframework/cell/internal";
 import type { IFluidHandle, IFluidLoadable } from "@fluidframework/core-interfaces";
 import { SharedCounter } from "@fluidframework/counter/internal";
 import { SharedMatrix } from "@fluidframework/matrix/internal";
 import { SharedString } from "@fluidframework/sequence/internal";
 import { type ITree, SchemaFactory, TreeViewConfiguration } from "@fluidframework/tree";
-import { SharedTree } from "@fluidframework/tree/internal";
+import { SharedTree, type TreeView } from "@fluidframework/tree/internal";
+import { v4 as uuid } from "uuid";
+
+import { TodoList, TodoItem } from "./Schema.js";
+
+/**
+ * Props used when creating a new todo item.
+ */
+interface TodoItemProps {
+	/**
+	 * The initial text to populate the todo item's title with.
+	 * This value will be inserted into the shared string at index 0.
+	 */
+	readonly startingText: string;
+}
+
+/**
+ * Object so we can test {@link TreeDataObject} inside {@link AppData}.
+ */
+export class AppDataTree extends TreeDataObject<TreeView<typeof TodoList>> {
+	public static readonly Name = "@devtools-example/test-app-tree-data-object";
+
+	private readonly config = new TreeViewConfiguration({ schema: TodoList });
+	private static readonly factory = new PureDataObjectFactory<
+		TreeDataObject<TreeView<typeof TodoList>>
+	>(AppDataTree.Name, AppDataTree, [SharedString.getFactory(), SharedTree.getFactory()], {});
+
+	public static getFactory(): PureDataObjectFactory<
+		TreeDataObject<TreeView<typeof TodoList>>
+	> {
+		return AppDataTree.factory;
+	}
+
+	/**
+	 * Converts the underlying ITree into a typed TreeView using the provided schema configuration.
+	 *
+	 * @param tree - The ITree instance to view.
+	 * @returns A typed TreeView using the TodoList schema.
+	 */
+	public override generateView(tree: ITree): TreeView<typeof TodoList> {
+		return tree.viewWith(this.config) as unknown as TreeView<typeof TodoList>;
+	}
+
+	/**
+	 * Initializes the tree with a default title and empty todo item list.
+	 * @remarks Called during the initial creation of the data object.
+	 */
+	public override async initializingFirstTime(): Promise<void> {
+		const title = SharedString.create(this.runtime);
+		title.insertText(0, "Title");
+
+		this.treeView.initialize(new TodoList({ title: title.handle, items: [] }));
+	}
+
+	/**
+	 * Adds a new todo item to the list.
+	 *
+	 * @param props
+	 * -`startingText`: The text to prefill into the item's title.
path: packages/tools/devtools/devtools-test-app/src/FluidObject.ts
user: login: Josmithr
body: Nit: the `startingText` property is already documented in the `TodoItemProps` interface. I think you can remove this block.
```suggestion
```
created_at: 2025-05-23T20:31:41Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-05-23T21:31:46Z
body: 🔗 No broken links found! ✅

Your attention to detail is admirable.

#### linkcheck output

```

> fluid-framework-docs-site@0.0.0 ci:check-links /home/runner/work/FluidFramework/FluidFramework/docs
> start-server-and-test "npm run serve -- --no-open" 3000 check-links

1: starting server using command "npm run serve -- --no-open"
and when url "[ 'http://127.0.0.1:3000' ]" is responding with HTTP status code 200
running tests using command "npm run check-links"


> fluid-framework-docs-site@0.0.0 serve
> docusaurus serve --no-open

[SUCCESS] Serving "build" directory at: http://localhost:3000/

> fluid-framework-docs-site@0.0.0 check-links
> linkcheck http://localhost:3000 --skip-file skipped-urls.txt

Crawling...

Stats:
  209835 links
    1663 destination URLs
    1895 URLs ignored
       0 warnings
       0 errors


```

<!-- Sticky Pull Request Commentlinkreport -->
author_association: CONTRIBUTOR