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
53 changes: 53 additions & 0 deletions scripts/offline-edit-base-ui.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { build } from 'esbuild';
import { pathToFileURL } from 'node:url';
import { GlobalRegistrator } from '@happy-dom/global-registrator';
if (!GlobalRegistrator.isRegistered) GlobalRegistrator.register();
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
const { render, fireEvent, waitFor, cleanup } = await import('@testing-library/react');
const { createElement } = await import('react');
const fixture = globalThis.__editBase = { direct: [] };
await build({ entryPoints: ['src/components/BatchOperations.tsx', 'src/components/ListItem.tsx'], outdir: 'tmp/offline-edit-base-ui', outbase: '.', bundle: true, splitting: true, platform: 'node', format: 'esm', outExtension: { '.js': '.mjs' }, jsx: 'automatic', external: ['react', 'react/jsx-runtime', 'react-dom'], plugins: [{ name: 'edit-base-fixtures', setup(b) {
b.onResolve({ filter: /\/useOffline$|\/useSettings$|\/SubItems$|\/ItemAttribution$|\/ItemDetailsModal$|\/share$/ }, args => ({ path: args.path, namespace: 'fixture' }));
b.onLoad({ filter: /.*/, namespace: 'fixture' }, ({ path }) => ({ contents:
path.endsWith('/useOffline') ? 'export const useOffline=()=>({queueMutation:async(input,snapshots)=>{globalThis.__editBase.direct.push({input,snapshots});return 1;}});' :
path.endsWith('/useSettings') ? 'export const useSettings=()=>({haptic(){}});' :
path.endsWith('/SubItems') ? 'export const useSubItemProgress=()=>null;' :
path.endsWith('/ItemAttribution') ? 'export const ItemAttribution=()=>null;' :
path.endsWith('/ItemDetailsModal') ? 'export const ItemDetailsModal=()=>null;' :
'export const shareItem=async()=>{};',
}));
} }] });
const load = path => import(pathToFileURL(`${process.cwd()}/tmp/offline-edit-base-ui/${path}.mjs`));
const { BatchOperations } = await load('src/components/BatchOperations');
const { ListItem } = await load('src/components/ListItem');
const item = (id, name) => ({ _id: id, _creationTime: 1, listId: 'L1', name, checked: false, createdByDid: 'did:a', createdAt: 1, updatedAt: 1 });

// Without a base snapshot, an uncached target is queued with an 'unknown'
// revision and the server rejects the user's own edit as a conflict. These
// entry points must use useOptimisticItems, which queues against base rows.
test('batch operations queue through the list hook, never directly', async () => {
for (const [label, type] of [['Check all selected items', 'batchCheckItems'], ['Uncheck all selected items', 'batchUncheckItems']]) {
const batches = [];
const view = render(createElement(BatchOperations, { selectedIds: new Set(['I1', 'temp-op']), queueBatch: async (...args) => { batches.push(args); }, onClearSelection() {}, userDid: 'did:a' }));
try {
fireEvent.click(view.getByLabelText(label));
await waitFor(() => assert.equal(batches.length, 1));
assert.equal(batches[0][0], type);
assert.deepEqual(batches[0][1].itemIds, ['I1', 'temp-op']);
assert.deepEqual(fixture.direct, []);
} finally { cleanup(); }
}
});

test('row remove queues through the list hook when provided', async () => {
const removed = [];
const view = render(createElement(ListItem, { item: item('I1', 'Milk'), userDid: 'did:a', onRemove: async (...args) => { removed.push(args); } }));
try {
fireEvent.click(view.getByLabelText('Remove Milk'));
await waitFor(() => assert.equal(removed.length, 1));
assert.deepEqual(removed[0], ['I1', 'did:a', undefined]);
assert.deepEqual(fixture.direct, []);
} finally { cleanup(); }
});
118 changes: 118 additions & 0 deletions scripts/offline-edit-base.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import 'fake-indexeddb/auto';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { loadReplayModules, replayFixture } from './helpers/replay-fixture.mjs';
const modules = await loadReplayModules('offline-edit-base-tests');
const { offline: store, sync: { SyncManager }, optimistic: { projectItems } } = modules;
const queue = (f, type, payload, snapshots = structuredClone(f.rows.items)) => store.queueMutation(f.session.accountId, { type, payload }, snapshots);
const pending = f => store.getQueuedMutations(f.session.accountId);
const all = f => store.getOperations(f.session.accountId);

test("a draft opened on an optimistic version chains through the client's own later edits instead of conflicting", async () => {
const f = await replayFixture(modules);
await queue(f, 'checkItem', { itemId: 'I1', checkedAt: 1 });
// The details modal opens on the projected (checked) row, tagged with the check.
const draftSource = projectItems(structuredClone(f.rows.items), await all(f), 'L1')[0];
// While it is open, the same user unchecks the row in the list.
await queue(f, 'uncheckItem', { itemId: 'I1' });
await queue(f, 'updateItem', { itemId: 'I1', name: 'Oat milk' }, [draftSource]);
const [check, uncheck, update] = await all(f);
assert.equal(uncheck.expected[0].predecessor, check.operationId);
assert.equal(update.expected[0].predecessor, uncheck.operationId);
await new SyncManager().sync(f.client, f.session);
assert.deepEqual(await pending(f), []);
assert.equal(f.rows.items[0].name, 'Oat milk');
assert.equal(f.rows.items[0].checked, false);
});

test("a draft chains through the client's own later edit made after its base was acknowledged and observed", async () => {
const f = await replayFixture(modules);
await queue(f, 'checkItem', { itemId: 'I1', checkedAt: 1 });
const draftSource = projectItems(structuredClone(f.rows.items), await all(f), 'L1')[0];
await new SyncManager().sync(f.client, f.session);
const observe = async () => {
const snap = await f.call('items', 'getListItemsForReplay', { listId: 'L1', operationIds: (await all(f)).map(m => m.operationId) });
await store.cacheListSnapshot(f.session.accountId, 'L1', snap.items, snap.acknowledgments, snap.sequence);
return snap.items;
};
// The uncheck is based on the observed server row, so it has no predecessor link.
await queue(f, 'uncheckItem', { itemId: 'I1' }, await observe());
assert.equal((await pending(f))[0].expected[0].predecessor, undefined);
await new SyncManager().sync(f.client, f.session);
await observe();
await queue(f, 'updateItem', { itemId: 'I1', name: 'Oat milk' }, [draftSource]);
await new SyncManager().sync(f.client, f.session);
assert.deepEqual(await pending(f), []);
assert.equal(f.rows.items[0].name, 'Oat milk');
assert.equal(f.rows.items[0].checked, false);
});

test("a draft still conflicts with a collaborator's edit made after the client's own later edits", async () => {
const f = await replayFixture(modules);
await queue(f, 'checkItem', { itemId: 'I1', checkedAt: 1 });
const draftSource = projectItems(structuredClone(f.rows.items), await all(f), 'L1')[0];
await queue(f, 'uncheckItem', { itemId: 'I1' });
await new SyncManager().sync(f.client, f.session);
await f.call('items', 'updateItem', { itemId: 'I1', name: 'Collaborator name' }, f.collaborator);
await queue(f, 'updateItem', { itemId: 'I1', name: 'Oat milk' }, [draftSource]);
await new SyncManager().sync(f.client, f.session);
const [conflict] = await pending(f);
assert.equal(conflict.state, 'conflict');
assert.equal(conflict.payload.name, 'Oat milk');
assert.equal(f.rows.items[0].name, 'Collaborator name');
});

test('a rendered row is a valid edit base for an uncached item; without one the edit cannot be verified', async () => {
const f = await replayFixture(modules);
// No cached snapshot exists yet (e.g. a collaborator's new row, tapped before
// the cache effect commits). The rendered row is the version the user acted on.
await queue(f, 'batchCheckItems', { itemIds: ['I1'] }, structuredClone(f.rows.items));
const [withBase] = await all(f);
assert.notEqual(withBase.expected[0].revision, 'unknown');
assert.deepEqual(withBase.listIds, ['L1']);
await new SyncManager().sync(f.client, f.session);
assert.deepEqual(await pending(f), []);
assert.equal(f.rows.items[0].checked, true);
});

const observe = async f => {
const snap = await f.call('items', 'getListItemsForReplay', { listId: 'L1', operationIds: (await all(f)).map(m => m.operationId) });
await store.cacheListSnapshot(f.session.accountId, 'L1', snap.items, snap.acknowledgments, snap.sequence);
return snap.items;
};

test('a draft does not chain through an own edit already rejected as a conflict', async () => {
const f = await replayFixture(modules);
f.rows.items.push({ ...structuredClone(f.rows.items[0]), _id: 'I2', name: 'Bread', vcProofs: [] });
await queue(f, 'checkItem', { itemId: 'I1', checkedAt: 1 });
const draftSource = projectItems(structuredClone(f.rows.items), await all(f), 'L1').find(i => i._id === 'I1');
await new SyncManager().sync(f.client, f.session);
await queue(f, 'batchUncheckItems', { itemIds: ['I1', 'I2'] }, await observe(f));
await f.call('items', 'updateItem', { itemId: 'I2', name: 'Rye' }, f.collaborator);
await new SyncManager().sync(f.client, f.session);
const [batch] = await pending(f);
assert.equal(batch.state, 'conflict');
await queue(f, 'updateItem', { itemId: 'I1', name: 'Oat milk' }, [draftSource]);
// The draft must neither wait behind the rejected batch nor join its discard cascade.
assert.deepEqual(store.discardCascade(await all(f), batch.operationId).map(m => m.type), ['batchUncheckItems']);
await new SyncManager().sync(f.client, f.session);
assert.equal(f.rows.items[0].name, 'Oat milk');
assert.deepEqual((await pending(f)).map(m => m.operationId), [batch.operationId]);
});

test('a draft chained behind a pending own edit is part of that edit when it is reviewed as a conflict', async () => {
const f = await replayFixture(modules);
await queue(f, 'checkItem', { itemId: 'I1', checkedAt: 1 });
const draftSource = projectItems(structuredClone(f.rows.items), await all(f), 'L1')[0];
await new SyncManager().sync(f.client, f.session);
await queue(f, 'uncheckItem', { itemId: 'I1' }, await observe(f));
await f.call('items', 'updateItem', { itemId: 'I1', name: 'Collaborator name' }, f.collaborator);
await queue(f, 'updateItem', { itemId: 'I1', name: 'Oat milk' }, [draftSource]);
await new SyncManager().sync(f.client, f.session);
const [uncheck, draft] = await pending(f);
assert.equal(uncheck.state, 'conflict');
assert.equal(f.rows.items[0].name, 'Collaborator name');
// OfflineRecovery lists this cascade beside the reviewed conflict, so the
// draft's rename is never reapplied over the collaborator's without review.
assert.deepEqual(store.discardCascade(await all(f), uncheck.operationId).map(m => m.operationId), [uncheck.operationId, draft.operationId]);
});
21 changes: 21 additions & 0 deletions scripts/offline-hooks.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -301,3 +301,24 @@ test('revocation notification clears mounted cache fallbacks and denied optimist
assert.equal((await store.getOperations(accountId))[0].payload.name, 'Only my new text');
} finally { view.unmount(); cleanup(); }
});

test('row remove and batch edits queue against the server base rows, even before the list cache is written', async () => {
const { revision } = await import('../shared/replay.ts');
fixture.user = { turnkeySubOrgId: 'hook-edit-base', did: 'did:a' }; fixture.token = 'token-edit-base';
// A collaborator's new row: visible in the live snapshot, never cached on this device.
const remote = { ...item('I7'), name: 'Collaborator row' };
fixture.snapshots.set('hook-edit-base:L1', { items: [remote], acknowledgments: [], sequence: 0 });
const view = renderHook(() => useOptimisticItems('L1'));
try {
await waitFor(() => assert.equal(view.result.current.items.length, 1));
await act(async () => {
await view.result.current.queueBatch('batchCheckItems', { itemIds: ['I7'], checkedByDid: 'did:a' });
await view.result.current.removeItem('I7', 'did:a');
});
const operations = await store.getOperations('hook-edit-base');
assert.deepEqual(operations.map(m => m.type), ['batchCheckItems', 'removeItem']);
assert.equal(operations[0].expected[0].revision, await revision(remote));
assert.deepEqual(operations.map(m => m.listIds), [['L1'], ['L1']]);
assert.equal(operations[1].expected[0].predecessor, operations[0].operationId);
} finally { view.unmount(); cleanup(); }
});
27 changes: 7 additions & 20 deletions src/components/BatchOperations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,26 @@
import { useState } from "react";
import type { Id } from "../../convex/_generated/dataModel";
import { useSettings } from "../hooks/useSettings";
import { useOffline } from "../hooks/useOffline";
import type { BatchMutationType } from "../hooks/useOptimisticItems";
import { ConfirmDialog } from "./ConfirmDialog";

interface BatchOperationsProps {
selectedIds: Set<Id<"items">>;
/** useOptimisticItems().queueBatch: queues against the list's base rows. */
queueBatch: (type: BatchMutationType, payload: { itemIds: Id<"items">[]; [key: string]: unknown }) => Promise<void>;
onClearSelection: () => void;
userDid: string;
legacyDid?: string;
}

export function BatchOperations({
selectedIds,
queueBatch,
onClearSelection,
userDid,
legacyDid,
}: BatchOperationsProps) {
const { haptic } = useSettings();
const { queueMutation } = useOffline();
const [isProcessing, setIsProcessing] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);

Expand All @@ -39,12 +41,7 @@ export function BatchOperations({
legacyDid,
};

await queueMutation({
type: "batchCheckItems",
payload,
timestamp: Date.now(),
retryCount: 0,
});
await queueBatch("batchCheckItems", payload);

haptic("success");
onClearSelection();
Expand All @@ -67,12 +64,7 @@ export function BatchOperations({
legacyDid,
};

await queueMutation({
type: "batchUncheckItems",
payload,
timestamp: Date.now(),
retryCount: 0,
});
await queueBatch("batchUncheckItems", payload);

haptic("success");
onClearSelection();
Expand All @@ -95,12 +87,7 @@ export function BatchOperations({
legacyDid,
};

await queueMutation({
type: "batchDeleteItems",
payload,
timestamp: Date.now(),
retryCount: 0,
});
await queueBatch("batchDeleteItems", payload);

haptic("success");
onClearSelection();
Expand Down
5 changes: 4 additions & 1 deletion src/components/ListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ interface ListItemProps {
onTouchStart?: (e: React.TouchEvent, itemId: string, element: HTMLElement) => void;
onCheck?: (itemId: Id<"items">, checkedByDid: string, legacyDid?: string) => Promise<void>;
onUncheck?: (itemId: Id<"items">, userDid: string, legacyDid?: string) => Promise<void>;
onRemove?: (itemId: Id<"items">, userDid: string, legacyDid?: string) => Promise<void>;
// Selection mode props
isSelectMode?: boolean;
isSelected?: boolean;
Expand All @@ -53,6 +54,7 @@ export const ListItem = memo(function ListItem({
onTouchStart,
onCheck,
onUncheck,
onRemove,
isSelectMode = false,
isSelected = false,
onToggleSelect,
Expand Down Expand Up @@ -125,7 +127,8 @@ export const ListItem = memo(function ListItem({
haptic('medium');
setIsUpdating(true);
try {
await queueMutation({ type: "removeItem", payload: { itemId: item._id, userDid, legacyDid } });
if (onRemove) await onRemove(item._id, userDid, legacyDid);
else await queueMutation({ type: "removeItem", payload: { itemId: item._id, userDid, legacyDid } });
} catch (err) {
console.error("Failed to remove item:", err);
haptic('error');
Expand Down
4 changes: 4 additions & 0 deletions src/components/NestedListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ interface NestedListItemProps {
onTouchStart?: (e: React.TouchEvent, itemId: string, element: HTMLElement) => void;
onCheck?: (itemId: Id<"items">, checkedByDid: string, legacyDid?: string) => Promise<void>;
onUncheck?: (itemId: Id<"items">, userDid: string, legacyDid?: string) => Promise<void>;
onRemove?: (itemId: Id<"items">, userDid: string, legacyDid?: string) => Promise<void>;
isSelectMode?: boolean;
isSelected?: boolean;
onToggleSelect?: () => void;
Expand All @@ -51,6 +52,7 @@ export function NestedListItem({
onTouchStart,
onCheck,
onUncheck,
onRemove,
isSelectMode = false,
isSelected = false,
onToggleSelect,
Expand Down Expand Up @@ -136,6 +138,7 @@ export function NestedListItem({
onTouchStart={onTouchStart}
onCheck={onCheck}
onUncheck={onUncheck}
onRemove={onRemove}
isSelectMode={isSelectMode}
isSelected={isSelected}
onToggleSelect={onToggleSelect}
Expand Down Expand Up @@ -171,6 +174,7 @@ export function NestedListItem({
canEdit={canEdit}
onCheck={onCheck}
onUncheck={onUncheck}
onRemove={onRemove}
isSelectMode={isSelectMode}
depth={depth + 1}
maxDepth={maxDepth}
Expand Down
6 changes: 5 additions & 1 deletion src/components/offline/OfflineRecovery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export function OfflineRecovery() {
const discardRoot = discard?.accountId === accountId && discard.token === token
? operations.find(m => m.operationId === discard.operationId && (m.state === 'conflict' || m.state === 'failed')) : undefined;
const discardEdits = discardRoot ? discardCascade(operations, discardRoot.operationId) : [];
const reviewDependents = review ? discardCascade(operations, review.mutation.operationId).filter(m => m.operationId !== review.mutation.operationId) : [];
const confirmDiscard = async () => {
if (!isCurrent() || !discardRoot || discarding) return;
setDiscarding(true);
Expand Down Expand Up @@ -88,10 +89,13 @@ export function OfflineRecovery() {
{review?.accountId === accountId && !operations.find(m => m.operationId === review.mutation.operationId)?.denied && <div className="border p-2">
<p>Current server version</p><pre className="whitespace-pre-wrap break-all">{JSON.stringify(review.items.map(i => ({ name: i.name, checked: i.checked, description: i.description })), null, 2)}</pre>
<p>Your saved edit</p><pre className="whitespace-pre-wrap break-all">{JSON.stringify(review.mutation.payload, null, 2)}</pre>
{/* Later edits that build on this one are reapplied with it. Show them,
so none overwrites the newer server version without being reviewed. */}
{reviewDependents.length > 0 && <><p>Later edits applied with it</p>{reviewDependents.map(m => <pre key={m.operationId} className="whitespace-pre-wrap break-all">{m.type}: {m.legacyRecoveryPending ? 'Older edit retained for recovery' : JSON.stringify(m.payload, null, 2)}</pre>)}</>}
<button className="underline" onClick={async () => {
try { if (!isCurrent()) return; await rebaseOperation(accountId, review.mutation.id!, review.items); if (isCurrent()) { setReview(undefined); await manualSync(); } }
catch (error) { if (isCurrent()) setMessage(error instanceof Error ? error.message : 'Recovery failed'); }
}}>Apply saved edit to this version</button>
}}>{reviewDependents.length ? `Apply ${reviewDependents.length + 1} saved edits to this version` : 'Apply saved edit to this version'}</button>
</div>}
{legacy && <div className="border-t pt-2"><p>Older offline edits are retained on this device. They have no verified account binding and will not replay automatically.</p><button className="underline" onClick={() => void exportLegacy()}>Export my identifiable legacy edits</button><p>Sign in to the original account to export and manually review its edits. Unattributed entries stay retained and hidden.</p></div>}
{message && <p role="status">{message}</p>}
Expand Down
Loading
Loading