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}
-
- )}
+
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",