1/**2 * Preact bindings for the per-write sync lifecycle: one write's stage, the3 * reload-safe pending set, and per-row sync badges. All three are4 * level-triggered — they render current truth on mount, so a component that5 * mounts after a transition sees the same state as one that watched it happen.6 *7 * @module8 */910import { useEffect, useState } from "preact/hooks";11import type { WriteHandle, WriteRejection, WriteStage } from "../runtime/write-handle.ts";12import {13 getWriteLedger,14 type PendingWritesSnapshot,15 type RowSyncStatus,16} from "../runtime/write-ledger.ts";1718/** The observable state of one write, re-rendered on every stage change. */19export type WriteState = {20 /** The write's current stage, or `null` while no write is being observed. */21 stage: WriteStage | null;22 /** The rejection carried by a `rejected` write. */23 reason: WriteRejection | null;24};2526/**27 * Observes one write's lifecycle. Pass the {@link WriteHandle} returned by a28 * verb or a table mutation; the component re-renders on each29 * {@link WriteStage} change, and because handles are level-triggered a30 * component that mounts after a transition sees the current stage31 * immediately — no missed events. `reason` carries the {@link WriteRejection}32 * once a write settles as `rejected`.33 *34 * @example35 * ```tsx36 * const [write, setWrite] = useState<WriteHandle<Order> | null>(null);37 * const { stage, reason } = useWrite(write);38 * // stage: "saving" | "saved" | "syncing" | "synced" | "rejected" | null39 * ```40 */41export function useWrite<T>(write: WriteHandle<T> | null | undefined): WriteState {42 const [state, setState] = useState<WriteState>(() => ({43 stage: write?.stage ?? null,44 reason: write?.reason ?? null,45 }));46 useEffect(() => {47 if (!write) {48 setState({ stage: null, reason: null });49 return;50 }51 return write.subscribe(() => setState({ stage: write.stage, reason: write.reason }));52 }, [write]);53 return state;54}5556/**57 * The reload-safe set of writes still waiting to sync, for "N changes waiting58 * to sync" indicators. The set is rebuilt from the durable journal at boot,59 * so it survives a reload with writes still pending.60 *61 * @example62 * ```tsx63 * const pending = usePendingWrites();64 * return pending.count > 0 ? <p>{pending.count} change(s) waiting to sync</p> : null;65 * ```66 *67 * @returns The current {@link PendingWritesSnapshot}: the count and the68 * journaled writes still awaiting their sync fate, oldest first.69 */70export function usePendingWrites(): PendingWritesSnapshot {71 const [snapshot, setSnapshot] = useState<PendingWritesSnapshot>({ count: 0, writes: [] });72 // The ledger is resolved inside the effect so server-side prerendering of an73 // island never opens browser storage.74 useEffect(() => {75 const ledger = getWriteLedger();76 return ledger.subscribe(() => setSnapshot(ledger.getPendingSnapshot()));77 }, []);78 return snapshot;79}8081/**82 * The sync status of one row for per-row badges: `waiting` while a journaled83 * write touching the row has not settled, `rejected` when the row's latest84 * settled write was denied, and `synced` otherwise. `waiting` is85 * reload-safe — it derives from the durable journal. `rejected` outlives the86 * pruned journal entry only for the current session: after a reload the87 * badge is gone. For a durable rejection response, declare an `onRejected`88 * effect on the verb instead of relying on the badge.89 *90 * @example91 * ```tsx92 * const status = useSyncStatus(task);93 * {status === "waiting" && <span class="badge">waiting to sync</span>}94 * ```95 */96export function useSyncStatus(row: { id: string } | null | undefined): RowSyncStatus {97 const rowId = row?.id ?? null;98 const [status, setStatus] = useState<RowSyncStatus>("synced");99 useEffect(() => {100 if (rowId === null) {101 setStatus("synced");102 return;103 }104 const ledger = getWriteLedger();105 return ledger.subscribe(() => setStatus(ledger.rowStatus(rowId)));106 }, [rowId]);107 return status;108}