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
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,48 @@ To skip undo tracking:
project.update({ lastViewed: Date.now() }, { undoable: false })
```

#### Navigation-aware undo/redo

When an undo action is tagged with a `path`, undo/redo can return the user to
the route where the change occurred before reverting it. Wire your router's
`navigate` via `onNavigate` on `FirestateProvider`:

```tsx
import { useNavigate } from 'react-router-dom'

function App() {
const navigate = useNavigate()

return (
<FirestateProvider
firestore={db}
onNavigate={(path) => navigate(path)}
>
{children}
</FirestateProvider>
)
}
```

When creating the store manually, pass `onNavigate` to `createStore`:

```ts
const store = createStore({
firestore: db,
onNavigate: (path) => router.push(path),
})
```

Actions record a path via the `path` field on `UndoAction`:

```tsx
undoManager.push({
undo: () => restoreValue(),
redo: () => applyValue(),
path: '/projects/123', // navigate here on undo/redo
})
```

### Lazy Collections

For large applications, you may not want to subscribe to every collection immediately:
Expand Down Expand Up @@ -592,6 +634,7 @@ Main provider component.
autosave={1000} // Optional: default debounce (ms)
minLoadTime={0} // Optional: minimum loading time (ms)
maxUndoLength={20} // Optional: max undo stack size
onNavigate={(path) => navigate(path)} // Optional: router navigate for path-aware undo/redo
onError={(error, context) => {
// Optional: custom error handler
console.error(context.path, error)
Expand Down
12 changes: 8 additions & 4 deletions src/collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,8 +220,12 @@ export const createCollectionSubscription = <TData extends FirestoreObject>(
return
}

const currentData = getMergedData()
const newLocalState = deepClone(currentData)
// Use raw localState as the mutation base so serverTimestamp() sentinels
// in localState survive into newLocalState. getMergedData() substitutes
// display-override Timestamps at sentinel paths, which would erase the
// sentinel from state.localState on the next update() call.
const rawBase = state.localState ?? state.syncState ?? {}
const newLocalState = deepClone(rawBase)
applyDiffMutable(newLocalState, diff as Record<string, unknown>)

// Ensure each document has its id
Expand All @@ -238,10 +242,10 @@ export const createCollectionSubscription = <TData extends FirestoreObject>(
if (undoOptions?.undoable !== false && onPushUndo) {
const undoDiff = computeDiff(
newLocalState as FirestoreObject,
currentData as FirestoreObject
rawBase as FirestoreObject
)
const redoDiff = computeDiff(
currentData as FirestoreObject,
rawBase as FirestoreObject,
newLocalState as FirestoreObject
)
onPushUndo(
Expand Down
19 changes: 14 additions & 5 deletions src/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,12 @@ export const createDocumentSubscription = <TData extends FirestoreObject>(
return
}

const newLocalState = deepClone(currentData)
// Use raw localState as the mutation base so serverTimestamp() sentinels
// in localState survive into newLocalState. getMergedData() substitutes
// display-override Timestamps at sentinel paths, which would erase the
// sentinel from state.localState on the next update() call.
const rawBase = (state.localState ?? state.syncState) as TData
const newLocalState = deepClone(rawBase)
applyDiffMutable(newLocalState, diff as Record<string, unknown>)

// Push undo eagerly against the pre-mutation state. Cmd+Z within the
Expand All @@ -254,10 +259,10 @@ export const createDocumentSubscription = <TData extends FirestoreObject>(
if (undoOptions?.undoable !== false && onPushUndo) {
const undoDiff = computeDiff(
newLocalState as FirestoreObject,
currentData as FirestoreObject
rawBase as FirestoreObject
)
const redoDiff = computeDiff(
currentData as FirestoreObject,
rawBase as FirestoreObject,
newLocalState as FirestoreObject
)
onPushUndo(
Expand Down Expand Up @@ -303,7 +308,10 @@ export const createDocumentSubscription = <TData extends FirestoreObject>(
undoOptions
)
} else {
const dataToRestore = deepClone(currentData)
// Snapshot raw localState so the restore payload contains
// serverTimestamp() sentinels, not the frozen Timestamps that
// getMergedData() substitutes for display purposes.
const dataToRestore = deepClone((state.localState ?? state.syncState) as TData)
onPushUndo(
() => setData(dataToRestore, { undoable: false }),
() => setData(dataForRedo, { undoable: false }),
Expand All @@ -329,7 +337,8 @@ export const createDocumentSubscription = <TData extends FirestoreObject>(
// Push undo against the pre-delete data (which includes any pending
// local edits at this moment).
if (undoOptions?.undoable !== false && onPushUndo) {
const dataToRestore = deepClone(currentData)
// Snapshot raw localState — same reason as in setData above.
const dataToRestore = deepClone((state.localState ?? state.syncState) as TData)
onPushUndo(
() => setData(dataToRestore, { undoable: false }),
() => deleteDocument({ undoable: false }),
Expand Down
71 changes: 71 additions & 0 deletions src/firestate.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,4 +254,75 @@ describe('Document subscription: serverTimestamp display overrides', () => {
// And the displayed value is NOT the sentinel that will ship.
expect(displayed).not.toBe(sentinel)
})

it('a chained update() after a serverTimestamp update keeps the sentinel in localState', () => {
// Regression: updateState used getMergedData() as the mutation base.
// getMergedData() substitutes the display-override Timestamp at the
// sentinel path; a second update() would clone that Timestamp into
// newLocalState, silently erasing the sentinel. If the sentinel is
// erased, reconcileDisplayOverrides drops the override and
// getMergedData() falls back to the syncState Timestamp(1000).
const definition = buildDocumentDefinition(
doc({ path: 'tasks/{taskId}', schema })
)
const sub = createDocumentSubscription({
store,
definition,
docId: 't1',
collectionPath: 'tasks',
})
sub.load()
fireSnapshot({ title: 'first', updatedAt: Timestamp.fromMillis(1000) })

sub.getHandle().update({ updatedAt: serverTimestamp() })
// Chain a second update that doesn't touch updatedAt.
sub.getHandle().update({ title: 'second' })

// Sentinel must still be in localState. Proxy: the display override is
// still active, so updatedAt is NOT the syncState Timestamp(1000). If the
// sentinel had been erased, the override would drop and Timestamp(1000) would show.
const displayed = sub.getState().data!.updatedAt
expect(displayed).toBeInstanceOf(Timestamp)
expect(displayed).not.toEqual(Timestamp.fromMillis(1000))
expect(sub.getState().data!.title).toBe('second')
})

it('setData undo restore payload contains the sentinel, not a frozen client Timestamp', async () => {
// Regression: setData snapshotted deepClone(getMergedData()) for the
// undo restore payload. getMergedData() substitutes frozen Timestamps
// for sentinels; undo would then call setData() with a client Timestamp,
// re-introducing the C1 regression through the undo path.
const definition = buildDocumentDefinition(
doc({ path: 'tasks/{taskId}', schema })
)
const sub = createDocumentSubscription({
store,
definition,
docId: 't1',
collectionPath: 'tasks',
onPushUndo: (undoFn, redoFn, opts) =>
store.undoManager.push({ undo: undoFn, redo: redoFn, groupId: opts?.undoGroupId }),
})
sub.load()
fireSnapshot({ title: 'first', updatedAt: Timestamp.fromMillis(1000) })

sub.getHandle().update({ updatedAt: serverTimestamp() })
// Display override active — shows a Timestamp that is not the syncState value.
expect(sub.getState().data!.updatedAt).not.toEqual(Timestamp.fromMillis(1000))

// set() captures the undo restore snapshot of the pre-set state (with sentinel).
sub.getHandle().set({ title: 'replaced', updatedAt: Timestamp.fromMillis(9999) })
expect(sub.getState().data!.title).toBe('replaced')

// Undo restores the pre-set state. Sentinel is restored into localState,
// triggering a fresh display-override capture.
await store.undoManager.undo()

expect(sub.getState().data!.title).toBe('first')
// updatedAt must NOT be Timestamp(9999) — that would mean the undo payload
// held the display-override Timestamp rather than the sentinel.
expect(sub.getState().data!.updatedAt).not.toEqual(Timestamp.fromMillis(9999))
// It IS a Timestamp (display override fired for the restored sentinel).
expect(sub.getState().data!.updatedAt).toBeInstanceOf(Timestamp)
})
})
35 changes: 31 additions & 4 deletions src/provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,26 @@ export interface FirestateProviderProps {
minLoadTime?: number;
/** Maximum undo stack length, default 20 */
maxUndoLength?: number;
/**
* Called before undo/redo when the action carries a `path`. Wire your
* router's `navigate` here to return users to where a change occurred
* before reverting it.
*
* @example
* ```tsx
* import { useNavigate } from 'react-router-dom'
*
* function App() {
* const navigate = useNavigate()
* return (
* <FirestateProvider onNavigate={(path) => navigate(path)}>
* {children}
* </FirestateProvider>
* )
* }
* ```
*/
onNavigate?: (path: string) => void;
/** Custom error handler */
onError?: (error: Error, context: ErrorContext) => void;
/** React children */
Expand Down Expand Up @@ -55,12 +75,14 @@ export const FirestateProvider: React.FC<FirestateProviderProps> = ({
minLoadTime = 0,
maxUndoLength = 20,
onError,
onNavigate,
children,
}) => {
// onError is intentionally excluded from the deps so that an inline
// callback (new reference per render) does not re-create the store and
// drop every existing subscription. The store exposes setOnError so the
// latest handler can be applied without store re-creation.
// onError and onNavigate are intentionally excluded from the deps so that
// inline callbacks (new reference per render) do not re-create the store and
// drop every existing subscription. The store exposes setOnError /
// setOnNavigate so the latest handlers can be applied without store
// re-creation.
const store = useMemo(
() =>
createStore({
Expand All @@ -69,6 +91,7 @@ export const FirestateProvider: React.FC<FirestateProviderProps> = ({
minLoadTime,
maxUndoLength,
onError,
onNavigate,
}),
[firestore, autosave, minLoadTime, maxUndoLength]
);
Expand All @@ -77,6 +100,10 @@ export const FirestateProvider: React.FC<FirestateProviderProps> = ({
store.setOnError(onError);
}, [store, onError]);

useEffect(() => {
store.setOnNavigate(onNavigate);
}, [store, onNavigate]);

return (
<FirestateContext.Provider value={store}>
{children}
Expand Down
60 changes: 60 additions & 0 deletions src/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,66 @@ describe('createStore', () => {
})
})

describe('onNavigate integration', () => {
it('calls onNavigate before undo when action has a path', async () => {
const onNavigate = vi.fn()
const store = createStore({ firestore: mockFirestore, onNavigate })

store.undoManager.push({ undo: vi.fn(), redo: vi.fn(), path: '/route/123' })
await store.undoManager.undo()

expect(onNavigate).toHaveBeenCalledWith('/route/123')
})

it('calls onNavigate before redo when action has a path', async () => {
const onNavigate = vi.fn()
const store = createStore({ firestore: mockFirestore, onNavigate })

store.undoManager.push({ undo: vi.fn(), redo: vi.fn(), path: '/route/123' })
await store.undoManager.undo()
await store.undoManager.redo()

expect(onNavigate).toHaveBeenCalledTimes(2)
expect(onNavigate).toHaveBeenLastCalledWith('/route/123')
})

it('does not call onNavigate when action has no path', async () => {
const onNavigate = vi.fn()
const store = createStore({ firestore: mockFirestore, onNavigate })

store.undoManager.push({ undo: vi.fn(), redo: vi.fn() })
await store.undoManager.undo()

expect(onNavigate).not.toHaveBeenCalled()
})

it('setOnNavigate replaces the handler without recreating the store', async () => {
const first = vi.fn()
const second = vi.fn()
const store = createStore({ firestore: mockFirestore, onNavigate: first })

store.setOnNavigate(second)

store.undoManager.push({ undo: vi.fn(), redo: vi.fn(), path: '/route/456' })
await store.undoManager.undo()

expect(first).not.toHaveBeenCalled()
expect(second).toHaveBeenCalledWith('/route/456')
})

it('setOnNavigate with undefined disables navigation', async () => {
const onNavigate = vi.fn()
const store = createStore({ firestore: mockFirestore, onNavigate })

store.setOnNavigate(undefined)

store.undoManager.push({ undo: vi.fn(), redo: vi.fn(), path: '/route/789' })
await store.undoManager.undo()

expect(onNavigate).not.toHaveBeenCalled()
})
})

describe('undo manager integration', () => {
it('provides access to undo manager', () => {
const store = createStore({
Expand Down
16 changes: 15 additions & 1 deletion src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ export interface FirestateStore {
* callback that changes reference on every render.
*/
setOnError: (handler?: (error: Error, context: ErrorContext) => void) => void
/**
* Replace the navigation handler at runtime. Used by FirestateProvider to
* keep the store identity stable when consumers pass an inline `onNavigate`
* callback that changes reference on every render.
*/
setOnNavigate: (handler?: (path: string) => void) => void
/** Subscribe to sync state changes */
subscribeToSyncState: (fn: Subscriber<boolean>) => Unsubscribe
/** Report a document/collection sync state change */
Expand Down Expand Up @@ -62,11 +68,15 @@ export const createStore = (config: FirestateConfig): FirestateStore => {
maxUndoLength = 20,
} = config

// Mutable so the provider can update it without re-creating the store.
// Mutable so the provider can update them without re-creating the store.
let onError = config.onError
let onNavigate = config.onNavigate

const undoManager = createUndoManager({
maxLength: maxUndoLength,
// Stable wrapper — delegates to the mutable onNavigate ref so the
// undo manager doesn't need to be recreated when the callback changes.
onNavigate: (path) => onNavigate?.(path),
})

// Track sync state of all documents/collections
Expand Down Expand Up @@ -106,6 +116,10 @@ export const createStore = (config: FirestateConfig): FirestateStore => {
onError = handler
},

setOnNavigate: (handler) => {
onNavigate = handler
},

subscribeToSyncState: (fn) => {
syncSubscribers.add(fn)
return () => syncSubscribers.delete(fn)
Expand Down
8 changes: 6 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,8 +266,12 @@ export interface FirestateConfig {
minLoadTime?: number;
/** Maximum undo stack length, default 20 */
maxUndoLength?: number;
/** Enable navigation-aware undo/redo */
enableNavigation?: boolean;
/**
* Callback invoked before undo/redo when the action carries a `path`.
* Wire your router's `navigate` here so undo/redo returns the user to
* where a change occurred before reverting it.
*/
onNavigate?: (path: string) => void;
/** Custom error handler */
onError?: (error: Error, context: ErrorContext) => void;
}
Expand Down
Loading