diff --git a/apps/reference/src/islands/TaskList.tsx b/apps/reference/src/islands/TaskList.tsx index 57fe382..bcc1caf 100644 --- a/apps/reference/src/islands/TaskList.tsx +++ b/apps/reference/src/islands/TaskList.tsx @@ -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. @@ -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(""); @@ -78,11 +78,7 @@ export default function TaskList() { {pending.count} change{pending.count === 1 ? "" : "s"} waiting to sync

)} - {notice && ( -

- {notice.text} -

- )} + diff --git a/apps/reference/src/islands/use-tasks.ts b/apps/reference/src/islands/use-tasks.ts index 9cd25eb..6b923c7 100644 --- a/apps/reference/src/islands/use-tasks.ts +++ b/apps/reference/src/islands/use-tasks.ts @@ -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"; @@ -17,54 +17,27 @@ const tasksTable = app.schema.tasks; /** The row type comes straight from the declared schema. */ export type Task = RowOf; -/** 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({ + 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(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 | null>(null); diff --git a/package/starter/src/islands/TaskList.tsx.txt b/package/starter/src/islands/TaskList.tsx.txt index 57fe382..bcc1caf 100644 --- a/package/starter/src/islands/TaskList.tsx.txt +++ b/package/starter/src/islands/TaskList.tsx.txt @@ -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. @@ -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(""); @@ -78,11 +78,7 @@ export default function TaskList() { {pending.count} change{pending.count === 1 ? "" : "s"} waiting to sync

)} - {notice && ( -

- {notice.text} -

- )} +
    {tasks.map((task) => )}
diff --git a/package/starter/src/islands/use-tasks.ts.txt b/package/starter/src/islands/use-tasks.ts.txt index 9cd25eb..6b923c7 100644 --- a/package/starter/src/islands/use-tasks.ts.txt +++ b/package/starter/src/islands/use-tasks.ts.txt @@ -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"; @@ -17,54 +17,27 @@ const tasksTable = app.schema.tasks; /** The row type comes straight from the declared schema. */ export type Task = RowOf; -/** 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({ + 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(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 | null>(null); diff --git a/package/testdata/starter.snapshot.json b/package/testdata/starter.snapshot.json index 561ce23..1fd1b5e 100644 --- a/package/testdata/starter.snapshot.json +++ b/package/testdata/starter.snapshot.json @@ -15,8 +15,8 @@ "src/app.ts": "2021889ec895b7c758e9c541eb968a480f63610073fe2c2d316faa7b66e85113", "src/env.d.ts": "b44daed05ec5cdfacfd8d8acf7866974b5f6b8db923ab53bd244419093c719da", "src/islands/AccountGate.tsx": "0faa82edb05ea9c6da1f575ebb75cc2f60d8e95e88524c0720e3c885a1eee659", - "src/islands/TaskList.tsx": "7b0da8830e5fab9c59948eff1cbaa357805ecea9c5d070249dd46157e5e36bdf", - "src/islands/use-tasks.ts": "6ae8e1130c76604dba16e4bb7680b2c66163568db8b250fba065996db3fb2146", + "src/islands/TaskList.tsx": "5b76707bf953d12a42d2be26c0a4f3eab77e972e228685a6cd945a65891e18f0", + "src/islands/use-tasks.ts": "277a54f6f77afebc303d869f0aac9db8423a2b5c707333046ce4d95993577a17", "src/layouts/Shell.astro": "57f5c144813fad7d6c7223b0e68498665f41dcd8b0855f2e8bf020d3bb072711", "src/pages/index.astro": "8a696a8154b77c34deba3919f6383dc2ed0a6af15612fd9b16f04416e3331e93", "src/permissions.ts": "7a2dad8ce48816c1611c85049209f4fddef08ab332dcbd94bac4fa162fc6c1b0",