{"id":"fission-tabs-1","tabs":[{"index":0,"label":"React","value":"react"},{"index":1,"label":"Fission","value":"fission"}]}
{"id":"fission-tabs-1","index":0,"label":"React","value":"react"}
type Todo = {
id: number;
title: string;
done: boolean;
};
const seedTodos: Todo[] = [
{ id: 1, title: "Write release notes", done: false },
{ id: 2, title: "Test Android package", done: true },
];
return (
<section>
<h1>Ship checklist</h1>
<ul>
{seedTodos.map((todo) => (
<li key={todo.id}>
<span>{todo.done ? "Done" : "Open"}</span>
<span>{todo.title}</span>
</li>
))}
</ul>
</section>
);
}
{"id":"fission-tabs-1","index":1,"label":"Fission","value":"fission"}
use fission::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Todo {
pub id: u64,
pub title: String,
pub done: bool,
}
pub struct TodoApp;
impl From<TodoApp> for Widget {
fn from(_app: TodoApp) -> Widget {
let todos = vec![
Todo { id: 1, title: "Write release notes".into(), done: false },
Todo { id: 2, title: "Test Android package".into(), done: true },
];
let mut children: Vec<Widget> = vec![
Text::new("Ship checklist").size(28.0).weight(800).into(),
];
for todo in todos {
let status = if todo.done { "Done" } else { "Open" };
children.push(
Row {
gap: Some(12.0),
children: widgets![
Text::new(status),
Text::new(todo.title),
],
..Default::default()
}
.into(),
);
}
Container::new(Column {
gap: Some(12.0),
children,
..Default::default()
})
.padding_all(24.0)
.into()
}
}
{"id":"fission-tabs-1"}
{"id":"fission-tabs-2","tabs":[{"index":0,"label":"React","value":"react"},{"index":1,"label":"Fission","value":"fission"}]}
{"id":"fission-tabs-2","index":0,"label":"React","value":"react"}
const [draft, setDraft] = useState("");
function submit() {
const title = draft.trim();
if (!title) return;
onAdd(title);
setDraft("");
}
return (
<form onSubmit={(event) => { event.preventDefault(); submit(); }}>
<label htmlFor="todo-title">New task</label>
<input
id="todo-title"
value={draft}
placeholder="Add a task"
onChange={(event) => setDraft(event.currentTarget.value)}
/>
<button type="submit">Add task</button>
</form>
);
}
{"id":"fission-tabs-2","index":1,"label":"Fission","value":"fission"}
use fission::prelude::*;
#[fission_component]
pub struct TodoComposer {
#[local_state(default_with = String::new)]
draft: String,
}
#[fission_reducer(SetLocalDraft)]
fn set_local_draft(draft: &mut String, value: String) {
*draft = value;
}
#[fission_reducer(ClearLocalDraft)]
fn clear_local_draft(draft: &mut String) {
draft.clear();
}
impl From<TodoComposer> for Widget {
fn from(composer: TodoComposer) -> Widget {
let (ctx, _) = fission::build::current::<()>();
let draft = composer.draft();
let update_draft = ctx.bind_local(
SetLocalDraft(String::new()),
draft.clone(),
reduce!(set_local_draft),
);
let clear_draft = ctx.bind_local(
ClearLocalDraft,
draft.clone(),
reduce!(clear_local_draft),
);
Row {
gap: Some(12.0),
children: widgets![
TextInput {
id: Some(WidgetId::explicit("todo-title")),
value: draft.get(),
label: Some("New task".into()),
placeholder: Some("Add a task".into()),
on_change: Some(update_draft),
width: Some(320.0),
..Default::default()
},
Button {
child: Some(Text::new("Clear").into()),
on_press: Some(clear_draft),
..Default::default()
},
],
..Default::default()
}
.into()
}
}
{"id":"fission-tabs-2"}
{"id":"fission-tabs-3","tabs":[{"index":0,"label":"React","value":"react"},{"index":1,"label":"Fission","value":"fission"}]}
{"id":"fission-tabs-3","index":0,"label":"React","value":"react"}
const [todos, setTodos] = useState<Todo[]>(seedTodos);
const [nextId, setNextId] = useState(3);
function addTodo(title: string) {
setTodos((current) => [
...current,
{ id: nextId, title, done: false },
]);
setNextId((id) => id + 1);
}
function toggleTodo(id: number) {
setTodos((current) =>
current.map((todo) =>
todo.id === id ? { ...todo, done: !todo.done } : todo,
),
);
}
return <TodoList todos={todos} onAdd={addTodo} onToggle={toggleTodo} />;
}
{"id":"fission-tabs-3","index":1,"label":"Fission","value":"fission"}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TodoState {
pub draft: String,
pub todos: Vec<Todo>,
pub next_id: u64,
}
impl Default for TodoState {
fn default() -> Self {
Self {
draft: String::new(),
todos: vec![
Todo { id: 1, title: "Write release notes".into(), done: false },
Todo { id: 2, title: "Test Android package".into(), done: true },
],
next_id: 3,
}
}
}
impl GlobalState for TodoState {}
#[fission_reducer(SetDraft)]
fn set_draft(state: &mut TodoState, draft: String) {
state.draft = draft;
}
#[fission_reducer(AddDraftTodo)]
fn add_draft_todo(state: &mut TodoState) {
let title = state.draft.trim();
if title.is_empty() {
return;
}
state.todos.push(Todo {
id: state.next_id,
title: title.to_string(),
done: false,
});
state.next_id += 1;
state.draft.clear();
}
#[fission_reducer(ToggleTodo)]
fn toggle_todo(state: &mut TodoState, id: u64) {
if let Some(todo) = state.todos.iter_mut().find(|todo| todo.id == id) {
todo.done = !todo.done;
}
}
pub struct TodoApp;
impl From<TodoApp> for Widget {
fn from(_app: TodoApp) -> Widget {
let (ctx, view) = fission::build::current::<TodoState>();
let update = with_reducer!(ctx, SetDraft(String::new()), set_draft);
let add = with_reducer!(ctx, AddDraftTodo, add_draft_todo);
let rows = view.state().todos.iter().map(|todo| {
let toggle = with_reducer!(ctx, ToggleTodo(todo.id), toggle_todo);
Row {
gap: Some(12.0),
children: widgets![
Checkbox {
checked: todo.done,
on_toggle: Some(toggle),
..Default::default()
},
Text::new(todo.title.clone()),
],
..Default::default()
}
.into()
});
Container::new(Column {
gap: Some(16.0),
children: widgets![
Text::new("Ship checklist").size(28.0).weight(800),
Row {
gap: Some(12.0),
children: widgets![
TextInput {
id: Some(WidgetId::explicit("todo-title")),
value: view.state().draft.clone(),
label: Some("New task".into()),
placeholder: Some("Add a task".into()),
on_change: Some(update),
width: Some(320.0),
..Default::default()
},
Button {
variant: ButtonVariant::Primary,
child: Some(Text::new("Add task").into()),
on_press: Some(add),
..Default::default()
},
],
..Default::default()
},
Column {
gap: Some(10.0),
children: rows.collect(),
..Default::default()
},
],
..Default::default()
})
.padding_all(24.0)
.into()
}
}
{"id":"fission-tabs-3"}
{"id":"fission-tabs-4","tabs":[{"index":0,"label":"React","value":"react"},{"index":1,"label":"Fission","value":"fission"}]}
{"id":"fission-tabs-4","index":0,"label":"React","value":"react"}
const [todos, setTodos] = useState<Todo[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetch("/api/todos")
.then((response) => response.json())
.then((data: Todo[]) => {
if (!cancelled) setTodos(data);
})
.catch((err) => {
if (!cancelled) setError(String(err));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
return <TodoList todos={todos} loading={loading} error={error} />;
}
{"id":"fission-tabs-4","index":1,"label":"Fission","value":"fission"}
use fission::core::{JobRef, JobSpec};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LoadTodosRequest;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ApiError {
pub message: String,
}
#[derive(Debug)]
pub struct LoadTodosJob;
impl JobSpec for LoadTodosJob {
type Request = LoadTodosRequest;
type Ok = Vec<Todo>;
type Err = ApiError;
const NAME: &'static str = "todo.load";
}
pub const LOAD_TODOS_JOB: JobRef<LoadTodosJob> = JobRef::new(LoadTodosJob::NAME);
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct TodoState {
pub draft: String,
pub todos: Vec<Todo>,
pub next_id: u64,
pub loading: bool,
pub error: Option<String>,
}
#[fission_reducer(LoadTodos)]
fn load_todos(state: &mut TodoState, ctx: &mut ReducerContext<TodoState>) {
if state.loading {
return;
}
state.loading = true;
state.error = None;
ctx.effects
.app(LOAD_TODOS_JOB, LoadTodosRequest)
.on_ok(ctx.effects.bind(TodosLoaded, reduce_with!(todos_loaded)))
.on_err(ctx.effects.bind(TodosFailed, reduce_with!(todos_failed)));
}
#[fission_reducer(TodosLoaded)]
fn todos_loaded(state: &mut TodoState, ctx: &mut ReducerContext<TodoState>) {
if let Some(todos) = ctx.input.job_ok(LOAD_TODOS_JOB) {
state.todos = todos;
state.next_id = state.todos.iter().map(|todo| todo.id).max().unwrap_or(0) + 1;
}
state.loading = false;
}
#[fission_reducer(TodosFailed)]
fn todos_failed(state: &mut TodoState, ctx: &mut ReducerContext<TodoState>) {
let message = ctx
.input
.job_err(LOAD_TODOS_JOB)
.map(|error| error.message)
.or_else(|| ctx.input.job_error_message(LOAD_TODOS_JOB).map(str::to_string))
.unwrap_or_else(|| "Unable to load todos".into());
state.loading = false;
state.error = Some(message);
}
{"id":"fission-tabs-4"}
{"id":"fission-tabs-5","tabs":[{"index":0,"label":"React","value":"react"},{"index":1,"label":"Fission","value":"fission"}]}
{"id":"fission-tabs-5","index":0,"label":"React","value":"react"}
return (
<button
className="flex items-center gap-3 rounded-xl border border-slate-200 bg-white px-4 py-3 shadow-sm"
onClick={() => onToggle(todo.id)}
>
<span className={todo.done ? "text-slate-400 line-through" : "text-slate-950"}>
{todo.title}
</span>
</button>
);
}
{"id":"fission-tabs-5","index":1,"label":"Fission","value":"fission"}
pub struct TodoRow {
pub todo: Todo,
}
impl From<TodoRow> for Widget {
fn from(row: TodoRow) -> Widget {
let (ctx, view) = fission::build::current::<TodoState>();
let tokens = &view.env().theme.tokens;
let colors = &tokens.colors;
let spacing = &tokens.spacing;
let radii = &tokens.radii;
let toggle = with_reducer!(ctx, ToggleTodo(row.todo.id), toggle_todo);
Container::new(Row {
gap: Some(spacing.s),
children: widgets![
Checkbox {
checked: row.todo.done,
on_toggle: Some(toggle),
..Default::default()
},
Text::new(row.todo.title)
.size(tokens.typography.font_size_base)
.color(if row.todo.done {
colors.text_secondary
} else {
colors.text_primary
}),
],
..Default::default()
})
.bg(colors.surface)
.border(colors.border, 1.0)
.border_radius(radii.large)
.padding([spacing.m, spacing.m, spacing.s, spacing.s])
.into()
}
}
{"id":"fission-tabs-5"}
{"id":"fission-tabs-6","tabs":[{"index":0,"label":"React","value":"react"},{"index":1,"label":"Fission","value":"fission"}]}
{"id":"fission-tabs-6","index":0,"label":"React","value":"react"}
<Routes>
<Route path="/" element={<TodoApp />} />
<Route path="/todos/:todoId" element={<TodoDetail />} />
</Routes>
{"id":"fission-tabs-6","index":1,"label":"Fission","value":"fission"}
use fission::widgets::{Route, Router};
use std::sync::Arc;
#[fission_reducer(NavigateTo)]
fn navigate_to(state: &mut TodoState, path: String) {
state.current_path = path;
}
pub struct TodoRoutes;
impl From<TodoRoutes> for Widget {
fn from(_routes: TodoRoutes) -> Widget {
let (_, view) = fission::build::current::<TodoState>();
Router::<TodoState> {
current_path: view.state().current_path.clone(),
routes: vec![
Route {
path: "/".into(),
builder: Arc::new(|_, _, _| TodoApp.into()),
},
Route {
path: "/todos/:id".into(),
builder: Arc::new(|_, _, params| {
TodoDetail {
id: params["id"].parse().unwrap_or(0),
}
.into()
}),
},
],
not_found: Some(Arc::new(|_, _, _| Text::new("Not found").into())),
}
.into()
}
}
{"id":"fission-tabs-6"}
{"id":"fission-tabs-7","tabs":[{"index":0,"label":"React","value":"react"},{"index":1,"label":"Fission","value":"fission"}]}
{"id":"fission-tabs-7","index":0,"label":"React","value":"react"}
it("adds a todo", async () => {
render(<TodoApp />);
await user.type(screen.getByLabelText("New task"), "Publish 0.6.0");
await user.click(screen.getByRole("button", { name: "Add task" }));
expect(screen.getByText("Publish 0.6.0")).toBeVisible();
});
{"id":"fission-tabs-7","index":1,"label":"Fission","value":"fission"}
#[test]
fn add_draft_todo_adds_item_and_clears_draft() {
let mut state = TodoState {
draft: "Publish 0.6.0".into(),
next_id: 10,
..Default::default()
};
add_draft_todo(&mut state);
assert_eq!(state.todos.len(), 1);
assert_eq!(state.todos[0].id, 10);
assert_eq!(state.todos[0].title, "Publish 0.6.0");
assert!(state.draft.is_empty());
assert_eq!(state.next_id, 11);
}
use fission::core::{ActionInput, ActionRegistry, Effects, ReducerContext};
#[test]
fn load_todos_marks_loading_and_requests_job() {
let mut state = TodoState::default();
let mut registry = ActionRegistry::new();
let mut effects = Effects::new(1, &mut registry);
let input = ActionInput::None;
let mut ctx = ReducerContext {
effects: &mut effects,
input: &input,
};
load_todos(&mut state, &mut ctx);
assert!(state.loading);
assert_eq!(effects.out.len(), 1);
}
use fission_test::prelude::DisplayOp;
use fission_test::TestHarness;
#[test]
fn todo_screen_renders_seed_items() {
let state = TodoState {
todos: vec![Todo { id: 1, title: "Write release notes".into(), done: false }],
next_id: 2,
..Default::default()
};
let mut harness = TestHarness::new(state).with_root_widget(TodoApp);
harness.pump().expect("pump todo app");
let display_list = harness.get_last_display_list().expect("display list");
assert!(display_list.ops.iter().any(|op| matches!(
op,
DisplayOp::DrawText { text, .. } if text == "Write release notes"
)));
}
use fission_test_driver::LiveTestClient;
use std::net::TcpListener;
use std::process::{Child, Command};
fn reserve_control_port() -> u16 {
TcpListener::bind(("127.0.0.1", 0))
.expect("bind test port")
.local_addr()
.expect("read test port")
.port()
}
fn launch_todo_app(control_port: u16) -> Child {
Command::new(env!("CARGO_BIN_EXE_todo"))
.env("FISSION_TEST_CONTROL_PORT", control_port.to_string())
.spawn()
.expect("launch todo app")
}
#[test]
#[ignore]
fn user_can_add_a_todo_visibly() {
let control_port = reserve_control_port();
let mut child = launch_todo_app(control_port);
let client = LiveTestClient::connect(control_port);
client.wait_for_ready(15_000).expect("app ready");
client.type_text("Publish 0.6.0").expect("type draft");
client.tap_text("Add task").expect("add todo");
client.assert_text_visible("Publish 0.6.0").expect("todo visible");
client
.screenshot(".artifacts/screenshots/todo/add-task.png")
.expect("screenshot");
client.quit().expect("quit app");
let _ = child.wait();
}
{"id":"fission-tabs-7"}
Requirement | Best first Fission test |
|---|---|
Business rule | Reducer test |
Effect request | Reducer plus Effects inspection |
Rendered text or display operation | TestHarness |
Focus, pointer, text input, screenshot, target behavior | LiveTestClient |
Platform-specific shell behavior | Target-specific live test |
React concept | Closest Fission concept | Important difference |
|---|---|---|
Function component | Rust struct plus impl From<MyComponent> for Widget | Conversion returns a widget tree. It is not a hook body. |
Props | Struct fields | Pass configuration with typed Rust fields. |
useState for local interaction state | #[fission_component] plus #[local_state] | Retained by component identity and updated through ctx.bind_local(...). |
useState for product data | GlobalState plus reducers | Product meaning is explicit app state, not hidden component memory. |
useReducer | #[fission_reducer] | Reducers are the standard state-update path. |
Callback prop | ActionEnvelope bound with with_reducer!, ctx.bind(...), or ctx.bind_local(...) | User intent has an action type and payload. |
Context | Env, providers, and explicit state | Env is for runtime environment and host context, not arbitrary product data. |
useEffect | Effects, jobs, services, resources, and capabilities | Outside work is requested from reducers or declared resources, not started during conversion. |
CSS/classes | Theme tokens and widget fields | Styling is structured and target-lowerable. Static site output can still emit CSS. |
React Router | Fission Router plus route state | Shell URLs and deep links normalize into app route actions. |
React Testing Library | Reducer tests, TestHarness, and LiveTestClient | Pick the smallest surface that proves the behavior. |