@@ -338,19 +409,36 @@ export function ObjectHooksPanel({
<>
{String(draft.label || draft.name)}
- {!disabled && (
- 0}
- title={blockingIssues > 0 ? t('perm.cel.saveBlocked', locale) : undefined}
- className="inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-[11px] hover:bg-muted disabled:opacity-50"
- >
- {saving ? : }
- {t('engine.studio.hooks.save', locale)}
-
- )}
+ {/* objectui#11787 — the status line every Studio editor shows in
+ place of a Save button: saving while in flight, the
+ last-saved time once landed, and why an edit is held when a
+ CEL syntax error holds it. */}
+ {disabled ? null : saving ? (
+
+
+ {t('engine.studio.autoSaving', locale)}
+
+ ) : renamedFrom !== null ? null : blockingIssues > 0 && dirty ? (
+
+ {t('perm.cel.saveBlocked', locale)}
+
+ ) : savedAt && savedAt.hook === selected && !dirty ? (
+
+ {tFormat('engine.studio.data.lastSaved', locale, {
+ time: savedAt.at.toLocaleTimeString(displayLocale, { hour: '2-digit', minute: '2-digit' }),
+ })}
+
+ ) : null}
+ {renamedFrom !== null && !disabled && (
+
+ {tFormat('engine.studio.hooks.renameHeld', locale, { name: renamedFrom })}
+
+ )}
{HookInspector ? (
diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.accessGuard.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.accessGuard.test.tsx
index 1e855d4b39..a56484397d 100644
--- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.accessGuard.test.tsx
+++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.accessGuard.test.tsx
@@ -298,7 +298,8 @@ describe('AccessPillar — unsaved OWD overview edits guard (#2600)', () => {
fireEvent.change(within(dialog).getByPlaceholderText('Display name (e.g. Sales permissions)'), {
target: { value: 'Set C' },
});
- fireEvent.click(within(dialog).getByRole('button', { name: 'Create' }));
+ // objectui#11787 — the create dialog's one label: it writes a draft.
+ fireEvent.click(within(dialog).getByRole('button', { name: t('engine.studio.createDraft', 'en-US') }));
await findInMatrix('set_c');
expect(screen.queryByTestId('owd-overview')).not.toBeInTheDocument();
diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.changesCount-11787.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.changesCount-11787.test.tsx
new file mode 100644
index 0000000000..3dcaf89410
--- /dev/null
+++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.changesCount-11787.test.tsx
@@ -0,0 +1,186 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * objectui#11787 — the header's Changes count after a draft save.
+ *
+ * Found in a browser QA pass: after an autosave, an item showed its
+ * "Unpublished draft" chip while the header beside it still said "No drafts
+ * pending publish", until the count's read caught up. A draft save stamped with
+ * this package means at least one draft is pending, so from the render in which
+ * the save lands the header says so, and the read the save asks for is sent
+ * after it rather than joined to one already on the wire.
+ *
+ * Driven through a save every author reaches: the Access pillar's
+ * "New permission set", whose dialog also carries Studio's one create label.
+ *
+ * Real: the surface, its pillar, `useMetadataClient` and the client it builds,
+ * and `usePendingDrafts`. Stubbed: `globalThis.fetch` (the server; every
+ * `_drafts` read is held open until the test answers it), `packages-io`'s
+ * package list, `sonner`, and the dock and panels no assertion reads.
+ */
+
+import '@testing-library/jest-dom/vitest';
+import * as React from 'react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+
+const PACKAGE_ID = 'app.a';
+const DRAFTS = `/api/v1/meta/_drafts?packageId=${PACKAGE_ID}`;
+
+vi.mock('sonner', () => ({
+ toast: {
+ warning: vi.fn(),
+ error: vi.fn(),
+ success: vi.fn(),
+ info: vi.fn(),
+ message: vi.fn(),
+ dismiss: vi.fn(),
+ },
+ Toaster: () => null,
+}));
+
+vi.mock('./packages-io', async (importOriginal) => {
+ const actual = await importOriginal>();
+ return {
+ ...actual,
+ fetchPackages: vi.fn(async () => [{ id: PACKAGE_ID, name: PACKAGE_ID, writable: true, namespace: 'a' }]),
+ };
+});
+
+vi.mock('@object-ui/react', async (importOriginal) => {
+ const actual = await importOriginal>();
+ return { ...actual, useAdapter: () => ({}) };
+});
+
+vi.mock('../../components/SuggestedBindingsPanel', () => ({ SuggestedBindingsPanel: () => null }));
+vi.mock('../metadata-admin/AccessExplainPanel', () => ({ AccessExplainPanel: () => null }));
+vi.mock('./StudioAiCopilot', () => ({ StudioChatDock: () => null }));
+vi.mock('../../preview/DraftChangesPanel', () => ({ DraftChangesPanel: () => null }));
+
+import { StudioDesignSurface } from './StudioDesignSurface';
+import { t } from '../metadata-admin/i18n';
+
+window.matchMedia = ((query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ addListener: () => {},
+ removeListener: () => {},
+ dispatchEvent: () => false,
+})) as unknown as typeof window.matchMedia;
+(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver =
+ (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver ??
+ class {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ };
+
+interface HeldRead {
+ respond: (drafts: Array>) => void;
+}
+
+/** The `_drafts` reads, in the order they were sent, each held until answered. */
+const draftReads: HeldRead[] = [];
+/** Draft saves the surface sent (`PUT …?mode=draft`). */
+const draftSaves: string[] = [];
+
+function json(body: unknown) {
+ return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } });
+}
+
+beforeEach(() => {
+ draftReads.length = 0;
+ draftSaves.length = 0;
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async (input: unknown, init?: { method?: string }) => {
+ const url = String(input);
+ if (url === DRAFTS) {
+ return new Promise((resolve) => {
+ draftReads.push({ respond: (drafts) => resolve(json({ drafts })) });
+ });
+ }
+ if (init?.method === 'PUT' && url.includes('mode=draft')) {
+ draftSaves.push(url);
+ return json({});
+ }
+ // Every other read (the pillar's lists, the type registry, the matrix's
+ // reads) answers empty — none of them is under test here.
+ return json([]);
+ }) as unknown as typeof fetch,
+ );
+});
+
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+});
+
+const NONE_PENDING = t('engine.studio.publishNoneTitle', 'en');
+const changesButton = () => screen.getByRole('button', { name: new RegExp(`^${t('engine.studio.changes', 'en')}`) });
+
+async function drain() {
+ for (let i = 0; i < 5; i++) await new Promise((r) => setTimeout(r, 0));
+}
+
+async function createSetThroughTheDialog() {
+ fireEvent.click(await screen.findByRole('button', { name: t('engine.studio.access.new', 'en') }));
+ const dialog = await screen.findByRole('dialog');
+ fireEvent.change(within(dialog).getByPlaceholderText(t('engine.studio.access.labelPlaceholder', 'en')), {
+ target: { value: 'Set C' },
+ });
+ // Studio's one create label (objectui#11787): every create dialog writes a draft.
+ fireEvent.click(within(dialog).getByRole('button', { name: t('engine.studio.createDraft', 'en') }));
+}
+
+describe('the Changes count after a draft save (objectui#11787)', () => {
+ it('says a draft is pending from the render the save lands in, and the read it asks for is its own', async () => {
+ render(
+
+
+ } />
+
+ ,
+ );
+ // The mount read is on the wire and unanswered.
+ await waitFor(() => expect(draftReads.length).toBeGreaterThan(0));
+ await act(drain);
+ const beforeSave = draftReads.length;
+
+ await act(async () => {
+ await createSetThroughTheDialog();
+ });
+ await waitFor(() => expect(draftSaves).toHaveLength(1));
+ await act(drain);
+
+ // The save landed: the header no longer says nothing is pending, and the
+ // count is at least the draft the save wrote — before any read answers.
+ expect(screen.queryByText(NONE_PENDING)).toBeNull();
+ expect(changesButton()).toHaveTextContent('· 1');
+ // The save sent a read of its own, not one joined to the read on the wire.
+ expect(draftReads.length).toBe(beforeSave + 1);
+
+ // The reads sent before the save answer with the ledger as it was: ignored.
+ await act(async () => {
+ for (const read of draftReads.slice(0, beforeSave)) read.respond([]);
+ await drain();
+ });
+ expect(screen.queryByText(NONE_PENDING)).toBeNull();
+ expect(changesButton()).toHaveTextContent('· 1');
+
+ // The read sent after the save answers with the server's count.
+ await act(async () => {
+ draftReads[beforeSave].respond([
+ { type: 'permission', name: 'set_c', packageId: PACKAGE_ID },
+ { type: 'object', name: 'a_account', packageId: PACKAGE_ID },
+ ]);
+ await drain();
+ });
+ await waitFor(() => expect(changesButton()).toHaveTextContent('· 2'));
+ expect(screen.queryByText(NONE_PENDING)).toBeNull();
+ });
+});
diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx
index 9d6ced425b..9b725ed0fb 100644
--- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx
+++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx
@@ -205,6 +205,8 @@ import {
newField,
} from '../metadata-admin/previews/object-fields-io.js';
import { CreateItemDialog } from './CreateItemDialog.js';
+// objectui#11787 — the shared draft autosave, moved to its own module unchanged.
+import { useDraftAutoSave, type DraftSend } from './useDraftAutoSave.js';
import { FLOW_PRESETS, flowPresetTrigger, type FlowPreset } from './flowPresets.js';
import {
CreatePackageDialog,
@@ -254,68 +256,6 @@ const PILLARS: ReadonlyArray<{ key: string; label: string; Icon: LucideIcon }> =
{ key: 'automations', label: 'Automations', Icon: Workflow },
{ key: 'interfaces', label: 'Interfaces', Icon: LayoutDashboard },
];
-// objectui#5813 — debounced draft auto-save, shared by the pillars' editors.
-// Drafts never touch the live app, so persisting them automatically is
-// zero-risk; the Save draft buttons it replaces were a standing tax on the
-// topbars AND a real loss point (forgot-to-save). Semantics:
-// - re-arms 1.5s after the LAST edit (the snapshot key changes per edit);
-// - `blocked` mirrors each site's old disabled-guard — in particular a
-// CEL-blocking inspector must gate the TIMER, not just a button, or the
-// timer publishes the malformed definition a second later (objectui#4306);
-// - a FAILED save does not retry until the user edits again (the snapshot
-// it attempted is remembered), so an invalid draft can't toast-loop.
-// - objectui#11189 — the returned `flush` fires the pending save now instead
-// of at the timer, under the same conditions, and says whether a save went
-// out. A flushed snapshot counts as attempted, so the timer never sends it
-// a second time.
-// - objectui#11204 — a save that lands clears its dirty flag only if nothing
-// was edited while it was in flight. The hook hands every save it sends a
-// `DraftSend` claim on the snapshot it sent, and the caller's
-// `set*Dirty(false)` after its await runs only while `sent.unmoved()`. An
-// edit taken meanwhile keeps the buffer dirty, and the autosave, unblocked
-// by the save's end, sends it next. A save the caller sends itself takes
-// its claim from `sending(body)`.
-// - objectui#11232 — a dirty period belongs to the item that was open when it
-// began. The caller names the item its `save` addresses as `target`, a
-// primitive identity (type and name; never a memoised object, AGENTS.md
-// #10). On a switch the caller's `save` addresses the newly opened item at
-// once, while the buffer holds the previous item's document until the new
-// load installs its own; so a period that began on another item is never
-// sent, by the timer or by `flush`, and it ends only when the caller's dirty
-// flag falls.
-// The previous item's pending edit is dropped: what the Data pillar's switch
-// has always done, and what every pillar did whenever the new load landed
-// inside the debounce. A claim also reads moved once the target has
-// changed, so a save that lands after a switch never clears the dirty flag
-// of the item opened since.
-// - objectui#11272 — the buffer belongs to the item it was loaded for. The
-// caller names that item as `loadedFor`, in `target`'s spelling, and sets
-// it where its load installs a buffer and nowhere else. Until the open
-// item's own document is in, `loadedFor` is not `target`: then the timer
-// and `flush` send nothing, `sending` gives no claim (every save the caller
-// sends itself asks it first, and sends nothing without one), and `loaded`
-// is false, which the caller reads to show and offer nothing of the buffer
-// under the open item. An edit a period begins while the buffer is another
-// item's is never sent. It ends with the dirty flag, which every caller's
-// buffer install clears: the page inspector's and the Data pillar's as
-// they install (the Data pillar also at its load's start), a leaf with no
-// editable draft as its `{}` goes in, and Automations when the load
-// settles. The Interfaces nav installs only over a clean buffer, or on its
-// mount (it is keyed by package).
-
-/**
- * objectui#11204 — one draft save's claim on the buffer it sent. `unmoved()`
- * is true while the buffer, as last committed, is still the snapshot that
- * save sent, compared the way the autosave compares snapshots (serialised).
- * It compares content, not an edit count: an edit undone while the save was
- * in flight leaves the buffer as the server now holds it, and so clean.
- * objectui#11232 — and only while the hook's `target` is still the item the
- * save was sent for: after a switch the buffer is the next item's to clear.
- */
-interface DraftSend {
- unmoved: () => boolean;
-}
-
/**
* objectui#11357 — the body the Interfaces pillar saves for its open leaf. A
* page goes through the `page` registration's `fromDraft`, the serialiser
@@ -336,95 +276,6 @@ function interfacesSaveBody(type: string, draft: Record): Recor
return fromDraft ? fromDraft(draft) : draft;
}
-function draftSnapshotKey(snapshot: unknown): string {
- try {
- return JSON.stringify(snapshot ?? null);
- } catch {
- // Unserializable draft (never the case for metadata bodies): a constant
- // key means one auto-save per dirty period instead of per edit, and a
- // save that lands always reads unmoved: degraded, as before.
- return '"__unserializable__"';
- }
-}
-
-function useDraftAutoSave(opts: {
- /** objectui#11232 — the item `save` addresses, as a primitive identity. */
- target: string;
- /** objectui#11272 — the item whose document the buffer holds: the target
- * the caller's load installed it for, in the same spelling. */
- loadedFor: string;
- dirty: boolean;
- blocked: boolean;
- snapshot: unknown;
- save: (sent: DraftSend) => void | Promise;
-}): {
- flush: () => boolean;
- /** A claim for a save the caller sends itself; `null` refuses it (objectui#11272). */
- sending: (snapshot: unknown) => DraftSend | null;
- /** objectui#11272 — the buffer is the open item's own document. */
- loaded: boolean;
-} {
- const { target, loadedFor, dirty, blocked, snapshot, save } = opts;
- // Pure (the react compiler forbids impure render calls).
- const snapKey = React.useMemo(() => draftSnapshotKey(snapshot), [snapshot]);
- const lastAttemptRef = React.useRef(null);
- const saveRef = React.useRef(save);
- // What the timer below would send, as last committed, for `flush` and for a
- // landing save's claim to read. A layout effect, so it is current as soon as
- // a render commits: a save that lands right after an edit reads the edit.
- // `since` is the target the dirty period began on (objectui#11232): taken
- // as the flag rises, kept while it stays up.
- const pendingRef = React.useRef({ dirty, blocked, snapKey, target, loadedFor, since: target });
- React.useLayoutEffect(() => {
- const prev = pendingRef.current;
- saveRef.current = save;
- pendingRef.current = { dirty, blocked, snapKey, target, loadedFor, since: dirty && prev.dirty ? prev.since : target };
- });
- // A state initializer, not a memo: React keeps its identity by contract, so
- // a caller may list it, or a member of it, as an effect dependency
- // (AGENTS.md #10).
- const [api] = React.useState(() => {
- const claim = (key: string, sentFor: string): DraftSend => ({
- unmoved: () => pendingRef.current.target === sentFor && pendingRef.current.snapKey === key,
- });
- // objectui#11232 — the pending edit is the open item's own: its dirty
- // period began on the item `save` now addresses. objectui#11272 — and the
- // buffer it edits is that item's document: its load installed it for it.
- const owned = (): boolean => {
- const { since, target: now, loadedFor: holds } = pendingRef.current;
- return since === now && holds === now;
- };
- const send = (key: string): void => {
- lastAttemptRef.current = key;
- void saveRef.current(claim(key, pendingRef.current.target));
- };
- return {
- send,
- owned,
- flush: (): boolean => {
- const pending = pendingRef.current;
- if (!pending.dirty || pending.blocked || !owned() || lastAttemptRef.current === pending.snapKey) return false;
- send(pending.snapKey);
- return true;
- },
- sending: (sent: unknown): DraftSend | null =>
- owned() ? claim(draftSnapshotKey(sent), pendingRef.current.target) : null,
- };
- });
- React.useEffect(() => {
- if (!dirty || blocked) return;
- if (lastAttemptRef.current === snapKey) return;
- const timer = setTimeout(() => {
- if (lastAttemptRef.current === snapKey) return;
- // Read as last committed: a switch since the edit left it with its item.
- if (!api.owned()) return;
- api.send(snapKey);
- }, 1500);
- return () => clearTimeout(timer);
- }, [dirty, blocked, snapKey, api]);
- return { flush: api.flush, sending: api.sending, loaded: loadedFor === target };
-}
-
/**
* objectui#11786 — tells the surface's Publish what edit a pillar holds unsent
* (`StudioHeld.clause`, or `null`), and takes it back when the pillar unmounts,
@@ -1260,7 +1111,7 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React
const [changesOpen, setChangesOpen] = React.useState(false);
const [publishing, setPublishing] = React.useState(false);
const [publishNonce, setPublishNonce] = React.useState(0); // ↑ → pillars re-read the published baseline
- const [draftNonce, setDraftNonce] = React.useState(0); // ↑ → refresh the pending-draft count
+ const [draftNonce, setDraftNonce] = React.useState(0); // ↑ → a draft save landed (`onDraftSaved`)
// objectui#5801 — the shared pending-drafts source: same fetch, same count,
// and the assistant bus's metadata-refresh pulse keeps this topbar in step
@@ -1274,13 +1125,20 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React
count: feedCount,
entries: pendingEntries,
refresh: refreshPending,
+ behindSave,
} = usePendingDrafts({ packageId });
const pendingCount =
packageId !== null ? feedCount : feedCount === null ? null : pendingEntries.filter(isOrgScopeDraft).length;
+ // A draft save asks for its own read (`onDraftSaved` below, objectui#11787);
+ // this one serves the mount and a publish.
React.useEffect(() => {
void refreshPending();
- }, [refreshPending, publishNonce, draftNonce]);
+ }, [refreshPending, publishNonce]);
+ const refreshPendingRef = React.useRef(refreshPending);
+ React.useLayoutEffect(() => {
+ refreshPendingRef.current = refreshPending;
+ });
// objectui#11786 — what the open pillar's autosave holds unsent (an
// incomplete-but-normal edit), as the clause naming what it needs. A publish
@@ -1427,8 +1285,19 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React
await refreshPending();
}, [shellClient, packageId, refreshPending, locale, heldEdit]);
- const onDraftSaved = React.useCallback(() => setDraftNonce((n) => n + 1), []);
- const hasPending = (pendingCount ?? 0) > 0;
+ // objectui#11787 — a Studio draft save reports here when it lands, and the
+ // count follows it: the read it asks for is sent after the save, never
+ // answered by a read already on the wire, and until that read answers the
+ // count is at least the one draft the save wrote (`behindSave`). So the
+ // header stops saying "No drafts pending publish" in the same render that
+ // shows an item's "Unpublished draft" chip, both being set by the save that
+ // landed. Pinned by `StudioDesignSurface.changesCount-11787.test.tsx`.
+ const onDraftSaved = React.useCallback(() => {
+ setDraftNonce((n) => n + 1);
+ void refreshPendingRef.current({ afterSave: true });
+ }, []);
+ const shownPendingCount = behindSave ? Math.max(pendingCount ?? 0, 1) : pendingCount;
+ const hasPending = (shownPendingCount ?? 0) > 0;
const publishNoneReasonId = React.useId();
// Builder → running-app bridge (Airtable's Launch): the builder edits the
@@ -1496,14 +1365,14 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React
toast.success(tFormat('engine.studio.app.savedDraft', locale, { label }));
setAppDraftPending(label);
setAppCreating(false);
- setDraftNonce((n) => n + 1);
+ onDraftSaved();
} catch (e) {
setAppErr(formatMetadataError(e));
} finally {
setAppBusy(false);
}
},
- [appAddObjects, loadPackageObjects, shellClient, packageId, locale],
+ [appAddObjects, loadPackageObjects, shellClient, packageId, locale, onDraftSaved],
);
// objectui#5800, fixed in passing — the topbar's app detection used to
@@ -1733,7 +1602,7 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React
className="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
>
- {t('engine.studio.changes', locale)}{hasPending ? ` · ${pendingCount}` : ''}
+ {t('engine.studio.changes', locale)}{hasPending ? ` · ${shownPendingCount}` : ''}
{/* objectui#8219 — with nothing to publish, say so on the page
rather than only in the hover tooltip. */}
@@ -5305,6 +5174,7 @@ export function DataPillar({
disabled={readOnly}
hookSchema={typeSchemas.hook}
publishNonce={publishNonce}
+ onDraftSaved={onDraftSaved}
/>
) : viewMode === 'actions' ? (
boolean;
+}
+
+function draftSnapshotKey(snapshot: unknown): string {
+ try {
+ return JSON.stringify(snapshot ?? null);
+ } catch {
+ // Unserializable draft (never the case for metadata bodies): a constant
+ // key means one auto-save per dirty period instead of per edit, and a
+ // save that lands always reads unmoved: degraded, as before.
+ return '"__unserializable__"';
+ }
+}
+
+export function useDraftAutoSave(opts: {
+ /** objectui#11232 — the item `save` addresses, as a primitive identity. */
+ target: string;
+ /** objectui#11272 — the item whose document the buffer holds: the target
+ * the caller's load installed it for, in the same spelling. */
+ loadedFor: string;
+ dirty: boolean;
+ blocked: boolean;
+ snapshot: unknown;
+ save: (sent: DraftSend) => void | Promise;
+}): {
+ flush: () => boolean;
+ /** A claim for a save the caller sends itself; `null` refuses it (objectui#11272). */
+ sending: (snapshot: unknown) => DraftSend | null;
+ /** objectui#11272 — the buffer is the open item's own document. */
+ loaded: boolean;
+} {
+ const { target, loadedFor, dirty, blocked, snapshot, save } = opts;
+ // Pure (the react compiler forbids impure render calls).
+ const snapKey = React.useMemo(() => draftSnapshotKey(snapshot), [snapshot]);
+ const lastAttemptRef = React.useRef(null);
+ const saveRef = React.useRef(save);
+ // What the timer below would send, as last committed, for `flush` and for a
+ // landing save's claim to read. A layout effect, so it is current as soon as
+ // a render commits: a save that lands right after an edit reads the edit.
+ // `since` is the target the dirty period began on (objectui#11232): taken
+ // as the flag rises, kept while it stays up.
+ const pendingRef = React.useRef({ dirty, blocked, snapKey, target, loadedFor, since: target });
+ React.useLayoutEffect(() => {
+ const prev = pendingRef.current;
+ saveRef.current = save;
+ pendingRef.current = { dirty, blocked, snapKey, target, loadedFor, since: dirty && prev.dirty ? prev.since : target };
+ });
+ // A state initializer, not a memo: React keeps its identity by contract, so
+ // a caller may list it, or a member of it, as an effect dependency
+ // (AGENTS.md #10).
+ const [api] = React.useState(() => {
+ const claim = (key: string, sentFor: string): DraftSend => ({
+ unmoved: () => pendingRef.current.target === sentFor && pendingRef.current.snapKey === key,
+ });
+ // objectui#11232 — the pending edit is the open item's own: its dirty
+ // period began on the item `save` now addresses. objectui#11272 — and the
+ // buffer it edits is that item's document: its load installed it for it.
+ const owned = (): boolean => {
+ const { since, target: now, loadedFor: holds } = pendingRef.current;
+ return since === now && holds === now;
+ };
+ const send = (key: string): void => {
+ lastAttemptRef.current = key;
+ void saveRef.current(claim(key, pendingRef.current.target));
+ };
+ return {
+ send,
+ owned,
+ flush: (): boolean => {
+ const pending = pendingRef.current;
+ if (!pending.dirty || pending.blocked || !owned() || lastAttemptRef.current === pending.snapKey) return false;
+ send(pending.snapKey);
+ return true;
+ },
+ sending: (sent: unknown): DraftSend | null =>
+ owned() ? claim(draftSnapshotKey(sent), pendingRef.current.target) : null,
+ };
+ });
+ React.useEffect(() => {
+ if (!dirty || blocked) return;
+ if (lastAttemptRef.current === snapKey) return;
+ const timer = setTimeout(() => {
+ if (lastAttemptRef.current === snapKey) return;
+ // Read as last committed: a switch since the edit left it with its item.
+ if (!api.owned()) return;
+ api.send(snapKey);
+ }, 1500);
+ return () => clearTimeout(timer);
+ }, [dirty, blocked, snapKey, api]);
+ return { flush: api.flush, sending: api.sending, loaded: loadedFor === target };
+}