Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions examples/todo-list/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Todo List

A classic Todo list application demonstrating the Cloudflare Agents sync layer for real-time state synchronization across multiple browser windows.

## Features

- ✅ Add, edit, complete, and delete todos
- 🔄 Real-time state synchronization across multiple windows
- 🎯 Filter by all, active, or completed todos
- 🧹 Clear all completed todos
- ✨ Toggle all todos at once
- 💾 Persistent state using Durable Objects

## How It Works

This example showcases the **sync layer** of Cloudflare Agents, which enables automatic state synchronization between the server and all connected clients. When you make changes in one browser window, they instantly appear in all other open windows.

### Key Concepts

1. **Agent State Management**: The `TodoAgent` class extends the base `Agent` class and defines a typed state with todos and filter settings.

2. **State Synchronization**: Changes to state via `this.setState()` are automatically broadcast to all connected clients through WebSocket connections.

3. **React Integration**: The `useAgent` hook from `agents/react` connects your React component to the agent and receives state updates in real-time.

4. **Callable Methods**: Methods decorated with `@callable()` can be invoked remotely from the client using `agent.call()`.

## Quick Start

```bash
npm install && npm start
```

Visit http://localhost:5173 to see the app.

## Testing State Sync

1. Open the app in multiple browser windows side by side
2. Add a todo in one window - it appears in all windows instantly
3. Complete a todo in another window - the checkbox updates everywhere
4. Try filtering, editing, or clearing completed todos - all changes sync in real-time

## Code Structure

- **`src/server.ts`**: TodoAgent class with state management and callable methods
- **`src/client.tsx`**: React UI using the `useAgent` hook for state synchronization
- **`src/styles.css`**: Classic TodoMVC styling
- **`wrangler.jsonc`**: Cloudflare Workers configuration with Durable Objects bindings

## Learn More

- [Agents Documentation](https://developers.cloudflare.com/agents/)
- [State Management Guide](../../packages/agents/README.md)
- [useAgent Hook API](../../packages/agents/src/react.tsx)
12 changes: 12 additions & 0 deletions examples/todo-list/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Agents • TodoMVC</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/client.tsx"></script>
</body>
</html>
20 changes: 20 additions & 0 deletions examples/todo-list/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"author": "",
"description": "A Todo list app demonstrating real-time state synchronization using Cloudflare Agents sync layer",
"keywords": [
"agents",
"sync",
"state-management",
"todo",
"real-time"
],
"license": "ISC",
"name": "@cloudflare/agents-todo-list",
"private": true,
"scripts": {
"start": "vite dev",
"test": "vitest run"
},
"type": "module",
"version": "0.0.0"
}
Empty file.
306 changes: 306 additions & 0 deletions examples/todo-list/src/client.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,306 @@
import React, { StrictMode, act } from "react";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { render } from "vitest-browser-react";
import { useAgent } from "agents/react";
import type { TodoState } from "./server";

vi.mock("agents/react", () => ({
useAgent: vi.fn()
}));

describe("TodoApp React Integration", () => {
let mockAgent: { call: typeof vi.fn };
let onStateUpdateCallback: ((state: TodoState) => void) | undefined;

beforeEach(() => {
onStateUpdateCallback = undefined;

mockAgent = {
call: vi.fn()
};

vi.mocked(useAgent).mockImplementation((options) => {

Check failure on line 22 in examples/todo-list/src/client.test.tsx

View workflow job for this annotation

GitHub Actions / check (ubuntu-24.04)

Argument of type '(options: UseAgentOptions<unknown>) => { call: <T extends Procedure = Procedure>(implementation?: T | undefined) => Mock<T>; }' is not assignable to parameter of type 'NormalizedProcedure<{ <State = unknown>(options: UseAgentOptions<State>): PartySocket & { agent: string; name: string; setState: (state: State) => void; call: UntypedAgentMethodCall; stub: UntypedAgentStub; }; <AgentT extends { get state(): State; }, State>(options: UseAgentOptions<...>): PartySocket & { ...; }; }>'.
onStateUpdateCallback = options.onStateUpdate;

Check failure on line 23 in examples/todo-list/src/client.test.tsx

View workflow job for this annotation

GitHub Actions / check (ubuntu-24.04)

Type '((state: unknown, source: "client" | "server") => void) | undefined' is not assignable to type '((state: TodoState) => void) | undefined'.
return mockAgent;
});
});

it("initializes with empty state and renders correctly", async () => {
const TestComponent = () => {
const [state, setState] = React.useState<TodoState>({
todos: [],
filter: "all"
});

useAgent<TodoState>({
agent: "todo-agent",
onStateUpdate: (newState) => {
setState(newState);
}
});

return (
<div>
<h1 data-testid="title">todos</h1>
<div data-testid="todo-count">{state.todos.length}</div>
</div>
);
};

const screen = render(
<StrictMode>
<TestComponent />
</StrictMode>
);

await expect
.element(screen.getByTestId("title"))
.toHaveTextContent("todos");
await expect
.element(screen.getByTestId("todo-count"))
.toHaveTextContent("0");
});

it("updates UI when state changes via onStateUpdate", async () => {
const TestComponent = () => {
const [state, setState] = React.useState<TodoState>({
todos: [],
filter: "all"
});

useAgent<TodoState>({
agent: "todo-agent",
onStateUpdate: (newState) => {
setState(newState);
}
});

return (
<div>
<div data-testid="todo-count">{state.todos.length}</div>
<div data-testid="filter">{state.filter}</div>
</div>
);
};

const screen = render(
<StrictMode>
<TestComponent />
</StrictMode>
);

expect(onStateUpdateCallback).toBeDefined();

const newState: TodoState = {
todos: [
{
id: "1",
text: "Test todo",
completed: false,
createdAt: Date.now()
}
],
filter: "active"
};

await act(async () => {
onStateUpdateCallback?.(newState);
});

await expect
.element(screen.getByTestId("todo-count"))
.toHaveTextContent("1");
await expect
.element(screen.getByTestId("filter"))
.toHaveTextContent("active");
});

it("calls agent.call when adding a todo", async () => {
const TestComponent = () => {
const [inputValue, setInputValue] = React.useState("");

const agent = useAgent<TodoState>({
agent: "todo-agent",
onStateUpdate: () => {}
});

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (inputValue.trim()) {
await agent.call("addTodo", [inputValue.trim()]);
setInputValue("");
}
};

return (
<form onSubmit={handleSubmit}>
<input
data-testid="todo-input"
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
/>
<button data-testid="submit-btn" type="submit">
Add
</button>
</form>
);
};

const screen = render(
<StrictMode>
<TestComponent />
</StrictMode>
);

const input = screen.getByTestId("todo-input");
const submitBtn = screen.getByTestId("submit-btn");

await act(async () => {
await input.fill("Learn Cloudflare Agents");
await submitBtn.click();
});

expect(mockAgent.call).toHaveBeenCalledWith("addTodo", [
"Learn Cloudflare Agents"
]);
});

it("calls agent.call for toggle, delete, and filter operations", async () => {
const TestComponent = () => {
const agent = useAgent<TodoState>({
agent: "todo-agent",
onStateUpdate: () => {}
});

return (
<div>
<button
type="button"
data-testid="toggle-btn"
onClick={() => agent.call("toggleTodo", ["todo-1"])}
>
Toggle
</button>
<button
type="button"
data-testid="delete-btn"
onClick={() => agent.call("deleteTodo", ["todo-1"])}
>
Delete
</button>
<button
type="button"
data-testid="filter-btn"
onClick={() => agent.call("setFilter", ["completed"])}
>
Filter
</button>
</div>
);
};

const screen = render(
<StrictMode>
<TestComponent />
</StrictMode>
);

await act(async () => {
await screen.getByTestId("toggle-btn").click();
});
expect(mockAgent.call).toHaveBeenCalledWith("toggleTodo", ["todo-1"]);

await act(async () => {
await screen.getByTestId("delete-btn").click();
});
expect(mockAgent.call).toHaveBeenCalledWith("deleteTodo", ["todo-1"]);

await act(async () => {
await screen.getByTestId("filter-btn").click();
});
expect(mockAgent.call).toHaveBeenCalledWith("setFilter", ["completed"]);
});

it("filters todos correctly based on filter state", async () => {
const TestComponent = () => {
const [state, setState] = React.useState<TodoState>({
todos: [
{
id: "1",
text: "Active todo",
completed: false,
createdAt: Date.now()
},
{
id: "2",
text: "Completed todo",
completed: true,
createdAt: Date.now()
}
],
filter: "all"
});

useAgent<TodoState>({
agent: "todo-agent",
onStateUpdate: (newState) => {
setState(newState);
}
});

const filteredTodos = state.todos.filter((todo) => {
if (state.filter === "active") return !todo.completed;
if (state.filter === "completed") return todo.completed;
return true;
});

return (
<div>
<div data-testid="todo-count">{filteredTodos.length}</div>
<div data-testid="current-filter">{state.filter}</div>
</div>
);
};

const screen = render(
<StrictMode>
<TestComponent />
</StrictMode>
);

await expect
.element(screen.getByTestId("todo-count"))
.toHaveTextContent("2");
await expect
.element(screen.getByTestId("current-filter"))
.toHaveTextContent("all");

await act(async () => {
onStateUpdateCallback?.({
todos: [
{
id: "1",
text: "Active todo",
completed: false,
createdAt: Date.now()
},
{
id: "2",
text: "Completed todo",
completed: true,
createdAt: Date.now()
}
],
filter: "active"
});
});

await expect
.element(screen.getByTestId("todo-count"))
.toHaveTextContent("1");
await expect
.element(screen.getByTestId("current-filter"))
.toHaveTextContent("active");
});
});
Loading
Loading