Skip to content
Merged
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
10 changes: 3 additions & 7 deletions apps/reference/src/islands/TaskList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ import { useState } from "preact/hooks";
import { settleUiMutation } from "@nzip/lofi";
import {
type BootProgress,
Notices,
useBootProgress,
usePendingWrites,
useSyncStatus,
} from "@nzip/lofi/preact";
import { type Task, useTaskNotice, useTasks } from "./use-tasks.ts";
import { type Task, useTasks } from "./use-tasks.ts";

// A cold first visit waits on the engine download, not on storage; name the
// wait it is actually in, with byte progress while the download runs.
Expand All @@ -26,7 +27,6 @@ function loadingLabel(boot: BootProgress): string {
*/
export default function TaskList() {
const { status, error, durability, tasks, failureKind, create, setCompleted } = useTasks();
const notice = useTaskNotice();
const pending = usePendingWrites();
const boot = useBootProgress();
const [text, setText] = useState("");
Expand Down Expand Up @@ -78,11 +78,7 @@ export default function TaskList() {
{pending.count} change{pending.count === 1 ? "" : "s"} waiting to sync
</p>
)}
{notice && (
<p class="state" role="status" data-notice={notice.kind}>
{notice.text}
</p>
)}
<Notices />
<ul aria-label="Tasks">
{tasks.map((task) => <TaskItem key={task.id} task={task} setCompleted={setCompleted} />)}
</ul>
Expand Down
49 changes: 11 additions & 38 deletions apps/reference/src/islands/use-tasks.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "preact/hooks";
import { useCallback, useState } from "preact/hooks";
import type { RowOf, WriteHandle } from "@nzip/lofi";
import { useLiveQuery, useWrite } from "@nzip/lofi/preact";
import { s } from "@nzip/lofi/schema";
Expand All @@ -17,54 +17,27 @@ const tasksTable = app.schema.tasks;
/** The row type comes straight from the declared schema. */
export type Task = RowOf<typeof tasksTable>;

/** A one-line consequence or compensation surfaced to the UI. */
export type TaskNotice = { kind: "synced" | "rejected"; text: string };

// A tiny author-owned notice channel: effect handlers run outside any
// component, so they publish through module state and hooks subscribe.
let notice: TaskNotice | null = null;
const noticeListeners = new Set<() => void>();

function publishNotice(next: TaskNotice | null): void {
notice = next;
for (const listener of [...noticeListeners]) listener();
}

/**
* The verb call sites use. Its effect units are declared once, here: the
* consequence runs when the store confirms the task, the compensation runs if
* a stale-policy write is denied — even if the app restarted in between.
*/
export const addTask = s.mutation("addTask", s.insert(tasksTable), {
effects: [s.log("task-added"), s.trace("task-added")],
onSynced: (task) => {
publishNotice({ kind: "synced", text: `"${task.text ?? "Task"}" synced to your account` });
},
onRejected: (task) => {
// The engine already rolled the denied row back out of local reads; this
// compensates what the user was told.
publishNotice({
kind: "rejected",
text: `"${task.text ?? "Task"}" was declined by the store and has been removed`,
});
},
effects: [
s.log("task-added"),
s.trace("task-added"),
s.notice<Task>({
synced: (task) => `"${task.text ?? "Task"}" synced to your account`,
// The engine already rolled a denied insert out of local reads; this
// durable notice compensates what the user was told, even after reload.
rejected: (task) => `"${task.text ?? "Task"}" was declined by the store and has been removed`,
}),
],
});

/** Toggling completion is a plain verb: no consequences, same lifecycle. */
export const setTaskCompleted = s.mutation("setTaskCompleted", s.update(tasksTable));

/** Subscribes to the latest effect notice; `null` until one is published. */
export function useTaskNotice(): TaskNotice | null {
const [current, setCurrent] = useState<TaskNotice | null>(notice);
useEffect(() => {
const listener = () => setCurrent(notice);
noticeListeners.add(listener);
listener();
return () => void noticeListeners.delete(listener);
}, []);
return current;
}

export function useTasks() {
const query = useLiveQuery(() => tasksTable.orderBy("createdAt", "desc"), []);
const [lastWrite, setLastWrite] = useState<WriteHandle<Task> | null>(null);
Expand Down
10 changes: 3 additions & 7 deletions package/starter/src/islands/TaskList.tsx.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

49 changes: 11 additions & 38 deletions package/starter/src/islands/use-tasks.ts.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package/testdata/starter.snapshot.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.