Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
30db490
feat: recover query caches after a backend restart
gmaclennan Aug 13, 2026
01fced6
fix: tolerate a closed project wrapper in sync teardown
gmaclennan Aug 13, 2026
dfb2e2c
fix: make the received map shares listen() idempotent
gmaclennan Aug 13, 2026
ae9042c
fix: remove dead project queries on restart instead of invalidating
gmaclennan Aug 13, 2026
f60af9f
feat: retry queries once-over on transport-closed rejections
gmaclennan Aug 13, 2026
2411e04
fix: match the shipped RPC_CHANNEL_CLOSED transport code
gmaclennan Aug 20, 2026
2ddfc17
feat: refresh active sync stores after a backend restart
gmaclennan Aug 20, 2026
d9086dd
fix: surface map-share stream failures instead of hanging
gmaclennan Aug 20, 2026
6418249
test: cover the invites refetch and the left-project error path
gmaclennan Aug 20, 2026
7852654
docs: describe v10 project reference semantics
gmaclennan Aug 20, 2026
518083b
test: stop asserting a fresh project reference after re-joining
gmaclennan Aug 20, 2026
3538101
chore: accept @comapeo/ipc v10 as a peer dependency
gmaclennan Aug 20, 2026
acdabaa
fix: stop a sync store error outliving the subscription that raised it
gmaclennan Aug 20, 2026
24bd8db
fix: make a lost map-share stream survivable rather than terminal
gmaclennan Aug 20, 2026
f7daa33
docs: replace the v9 rationale for the restart reset
gmaclennan Aug 20, 2026
986562c
test: pin the re-joined project reference through a direct call
gmaclennan Aug 20, 2026
857221a
docs: regenerate the API reference against the pinned @comapeo/ipc
gmaclennan Aug 20, 2026
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
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,52 @@ function App() {

Hooks that communicate with the map server will wait for `getMapServerBaseUrl()` to resolve before making requests, so the provider can be mounted before the server is ready. You can also provide an optional `fetch` prop to use a custom fetch implementation.

### Recovering from a backend restart

On platforms where the backend can restart underneath a running app — Android, where it lives in a separate OS process that the system may kill under memory pressure — pass a `subscribeToBackendRestart` function. It receives a listener and returns a function that removes it. Call the listener once the transport has reconnected, i.e. once requests will reach the new backend:

```tsx
// Defined outside the component (or wrapped in `useCallback`) so that its
// identity is stable: a new function on every render makes the provider
// unsubscribe and resubscribe on every render.
function subscribeToBackendRestart(listener: () => void) {
return subscribeToRestarts(listener)
}

function App() {
return (
<ComapeoCoreProvider
clientApi={clientApi}
queryClient={queryClient}
getMapServerBaseUrl={servicesClient.mapServer.getBaseUrl}
subscribeToBackendRestart={subscribeToBackendRestart}
>
<MyApp />
</ComapeoCoreProvider>
)
}
```

Platforms whose backend cannot outlive the app, such as desktop, should omit the prop.

#### What a notification does

The client API and every project reference survive a restart: under `@comapeo/ipc` v10 a project reference is permanent, and its channel re-opens transparently against the new backend. What does not survive is the data read through them — the media server comes back on a different port, and everything the backend held in memory (invites, sync state) is gone. On each notification the provider resets its own queries (never the consuming app's) in four steps:

1. **Removes** every query read through a project instance — project settings, members, documents, and the cached media server origin. Removal, rather than invalidation, is what reaches the media server origin, which is cached with `staleTime: 'static'` and so cannot be invalidated at all. It also guarantees that a fetcher which failed as the old backend went away cannot stay latched in `status: 'error'`, which a suspense query with retries disabled never recovers from.
2. **Resets** the cached per-project API instances. This is what makes mounted screens react: removal on its own is invisible to a mounted component, which keeps rendering its last result, whereas a reset suspends it. On resume it re-runs the queries removed in step 1.
3. **Invalidates** what is left — device info, invites, the project list. These are read through the client API, so a background refetch is enough and screens showing them do not flash a loading state.
4. **Refreshes** the sync state, which is an external store rather than a query. Because the project reference is permanent, step 2 hands back the same store, so the error it latched when the backend went away has to be cleared and its state re-read explicitly.

#### Projects this device has left

A restart is recoverable; leaving a project is not. Every call on a project this device has left rejects with an error carrying `code: 'PROJECT_LEFT'`, as does `useSingleProject` for a project left before it was ever fetched. That error is deliberately neither retried nor reset — it surfaces at the nearest error boundary, and the project only becomes usable again by re-joining through an invite.

#### What it does not cover

- **In-memory map share state.** The received and sent map share stores are plain in-memory stores, not queries, and are not reset, so a share that was pending when the backend restarted stays pending. A share that was _downloading_ keeps waiting while its progress stream reconnects, and ends up in `error` with code `EVENT_STREAM_ERROR` only once the stream has been down for a minute; downloading it again starts a fresh download. Resuming an interrupted download where it left off is a separate piece of work.
- **Events emitted during the disconnect window.** Invites, sync state and `map-share` events raised while the app was disconnected from the backend are lost. Refetching the queries above is the compensation for this: the state they carry is re-read from the new backend, but one-off notifications are not replayed.

## API Documentation

Still a work in progress. Currently lives in [`docs/API.md`](./docs/API.md).
Expand Down
53 changes: 49 additions & 4 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,13 @@ Create a context provider that holds a CoMapeo API client instance.

| Function | Type |
| ---------- | ---------- |
| `ClientApiProvider` | `({ children, clientApi, }: ClientApiProviderProps) => Element` |
| `ClientApiProvider` | `({ children, clientApi, subscribeToBackendRestart, }: ClientApiProviderProps) => Element` |

Parameters:

* `opts.children`: React children node
* `opts.clientApi`: Client API instance
* `opts.subscribeToBackendRestart`: Optional, referentially stable subscribe function for backend-restart notifications


### useClientApi
Expand Down Expand Up @@ -287,7 +288,7 @@ Throws if used outside of MapServerProvider.

| Function | Type |
| ---------- | ---------- |
| `ComapeoCoreProvider` | `({ children, clientApi, getMapServerBaseUrl, fetch, queryClient, }: ComapeoCoreProviderProps) => Element` |
| `ComapeoCoreProvider` | `({ children, clientApi, getMapServerBaseUrl, fetch, queryClient, subscribeToBackendRestart, }: ComapeoCoreProviderProps) => Element` |

### useProjectSettings

Expand Down Expand Up @@ -319,6 +320,15 @@ Retrieve a project API instance for a project.

This is mostly used internally by the other hooks and should only be used if certain project APIs are not exposed via the hooks.

Project instances are permanent references (`@comapeo/ipc` v10): the same
instance is handed back for the lifetime of the client, and it keeps working
across a backend restart. The one case where it stops working is a project
this device has left — every call on it, and this hook itself for a project
left before it was ever fetched, then rejects with an error carrying
`code: 'PROJECT_LEFT'`. That error is not retried and is not recovered from:
it surfaces at the nearest error boundary, and the project only becomes
usable again by re-joining through an invite.

| Function | Type |
| ---------- | ---------- |
| `useSingleProject` | `({ projectId, }: { projectId: string; }) => Pick<UseSuspenseQueryResult<ClientApi<MapeoProject>>, "data" or "error" or "isRefetching">` |
Expand Down Expand Up @@ -1432,7 +1442,7 @@ function SentShareStatus({ shareId }: { shareId: string }) {

| Constant | Type |
| ---------- | ---------- |
| `ReceivedMapSharesContext` | `Context<{ subscribe: (listener: () => void) => () => boolean; getSnapshot: () => ReceivedMapShareState[]; actions: { download({ shareId }: DownloadMapShareOptions): Promise<...>; decline({ shareId, reason }: DeclineMapShareOptions): Promise<...>; abort({ shareId }: AbortMapShareOptions): Promise<...>; }; } or null>` |
| `ReceivedMapSharesContext` | `Context<{ subscribe: (listener: () => void) => () => boolean; getSnapshot: () => ReceivedMapShareState[]; actions: { download({ shareId }: DownloadMapShareOptions): Promise<...>; decline({ shareId, reason }: DeclineMapShareOptions): Promise<...>; abort({ shareId }: AbortMapShareOptions): Promise<...>; }; listen(): (...` |

### SentMapSharesContext

Expand All @@ -1450,18 +1460,53 @@ function SentShareStatus({ shareId }: { shareId: string }) {

## Types

- [SubscribeToBackendRestart](#subscribetobackendrestart)
- [ClientApiProviderProps](#clientapiproviderprops)
- [CompatFile](#compatfile)
- [ExpoFileDuckType](#expofileducktype)
- [MapServerApiOptions](#mapserverapioptions)
- [MapServerApi](#mapserverapi)
- [MapServerProviderProps](#mapserverproviderprops)

### SubscribeToBackendRestart

Subscribe to notifications that the CoMapeo backend restarted, so that cached
data pointing at the previous backend instance can be discarded.

A "restart" means the backend lost all of its in-memory state and came back
as a fresh instance — for example on Android, where the backend runs in its
own OS process that the system can kill under memory pressure and later
restart while the app keeps running. It does *not* mean a dropped and
re-established transport connection to a backend that is still alive.

The listener should be called after the RPC transport has reconnected to the
new backend, i.e. once requests made on it will reach the new instance.

Pass a referentially stable function — a module-scope function, or one
wrapped in `useCallback` — because a new identity on every render makes the
provider unsubscribe and resubscribe on every render.

Platforms whose backend cannot outlive the app (desktop, where the backend
dying exits the app) should omit this prop.

| Type | Type |
| ---------- | ---------- |
| `SubscribeToBackendRestart` | `(listener: () => void) => () => void` |

Parameters:

* `listener`: Called each time the backend has restarted


Returns:

A function that removes the listener

### ClientApiProviderProps

| Type | Type |
| ---------- | ---------- |
| `ClientApiProviderProps` | `PropsWithChildren<{ clientApi: ComapeoCoreClientApi }>` |
| `ClientApiProviderProps` | `PropsWithChildren<{ clientApi: ComapeoCoreClientApi /** * Subscribe function for backend-restart notifications. See * {@link SubscribeToBackendRestart}. */ subscribeToBackendRestart?: SubscribeToBackendRestart }>` |

### CompatFile

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@
},
"peerDependencies": {
"@comapeo/core": "^7.2.0",
"@comapeo/ipc": "^9.0.0",
"@comapeo/ipc": "^9.0.0 || ^10.0.0",
"@tanstack/react-query": "^5",
"react": "^18 || ^19"
},
Expand Down
53 changes: 52 additions & 1 deletion src/contexts/ClientApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,33 +4,84 @@ import {
createContext,
createElement,
useEffect,
useRef,
type Context,
type JSX,
type PropsWithChildren,
} from 'react'

import { getInvitesQueryKey } from '../lib/react-query.js'
import {
getInvitesQueryKey,
resetQueriesAfterBackendRestart,
} from '../lib/react-query.js'

export const ClientApiContext: Context<ComapeoCoreClientApi | null> =
createContext<ComapeoCoreClientApi | null>(null)

/**
* Subscribe to notifications that the CoMapeo backend restarted, so that cached
* data pointing at the previous backend instance can be discarded.
*
* A "restart" means the backend lost all of its in-memory state and came back
* as a fresh instance — for example on Android, where the backend runs in its
* own OS process that the system can kill under memory pressure and later
* restart while the app keeps running. It does *not* mean a dropped and
* re-established transport connection to a backend that is still alive.
*
* The listener should be called after the RPC transport has reconnected to the
* new backend, i.e. once requests made on it will reach the new instance.
*
* Pass a referentially stable function — a module-scope function, or one
* wrapped in `useCallback` — because a new identity on every render makes the
* provider unsubscribe and resubscribe on every render.
*
* Platforms whose backend cannot outlive the app (desktop, where the backend
* dying exits the app) should omit this prop.
*
* @param listener Called each time the backend has restarted
* @returns A function that removes the listener
*/
export type SubscribeToBackendRestart = (listener: () => void) => () => void

export type ClientApiProviderProps = PropsWithChildren<{
clientApi: ComapeoCoreClientApi
/**
* Subscribe function for backend-restart notifications. See
* {@link SubscribeToBackendRestart}.
*/
subscribeToBackendRestart?: SubscribeToBackendRestart
}>

/**
* Create a context provider that holds a CoMapeo API client instance.
*
* @param opts.children React children node
* @param opts.clientApi Client API instance
* @param opts.subscribeToBackendRestart Optional, referentially stable subscribe function for backend-restart notifications
*
*/
export function ClientApiProvider({
children,
clientApi,
subscribeToBackendRestart,
}: ClientApiProviderProps): JSX.Element {
const queryClient = useQueryClient()

// Parked in a ref so that the subscribe effect only depends on the subscribe
// function, and a new `queryClient` identity cannot churn the subscription.
const queryClientRef = useRef(queryClient)
useEffect(() => {
queryClientRef.current = queryClient
}, [queryClient])

useEffect(() => {
if (!subscribeToBackendRestart) return

return subscribeToBackendRestart(() => {
resetQueriesAfterBackendRestart(queryClientRef.current)
})
}, [subscribeToBackendRestart])

useEffect(() => {
function invalidateInviteCache() {
queryClient.invalidateQueries({ queryKey: getInvitesQueryKey() })
Expand Down
3 changes: 2 additions & 1 deletion src/contexts/ComapeoCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@ export function ComapeoCoreProvider({
getMapServerBaseUrl,
fetch,
queryClient,
subscribeToBackendRestart,
}: ComapeoCoreProviderProps): JSX.Element {
return createElement(
ClientApiProvider,
{ clientApi },
{ clientApi, subscribeToBackendRestart },
createElement(
MapServerProvider,
{ getBaseUrl: getMapServerBaseUrl, fetch, queryClient },
Expand Down
4 changes: 4 additions & 0 deletions src/contexts/MapShares.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
createElement,
useCallback,
useContext,
useEffect,
useMemo,
useSyncExternalStore,
type Context,
Expand Down Expand Up @@ -55,6 +56,9 @@ export function ReceivedMapSharesProvider({
createReceivedMapSharesStore({ clientApi, mapServerApi, queryClient }),
[clientApi, mapServerApi, queryClient],
)

useEffect(() => mapSharesStore.listen(), [mapSharesStore])

return createElement(
ReceivedMapSharesContext.Provider,
{ value: mapSharesStore },
Expand Down
20 changes: 20 additions & 0 deletions src/hooks/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,15 @@ Pick<
*
* This is mostly used internally by the other hooks and should only be used if certain project APIs are not exposed via the hooks.
*
* Project instances are permanent references (`@comapeo/ipc` v10): the same
* instance is handed back for the lifetime of the client, and it keeps working
* across a backend restart. The one case where it stops working is a project
* this device has left — every call on it, and this hook itself for a project
* left before it was ever fetched, then rejects with an error carrying
* `code: 'PROJECT_LEFT'`. That error is not retried and is not recovered from:
* it surfaces at the nearest error boundary, and the project only becomes
* usable again by re-joining through an invite.
*
* @param opts.projectId Project public ID
*
* @example
Expand Down Expand Up @@ -375,6 +384,17 @@ const FAKE_BLOB_ID: BlobApi.BlobId = {
/**
* @internal
* Hack to retrieve the media server origin (protocol + host).
*
* The probe is why `resetQueriesAfterBackendRestart` has to special-case this
* key: it is read from a project instance but lives outside the
* `projects/<projectId>` namespace, and `staleTime: 'static'` makes
* invalidation a structural no-op, so removal is the only thing that reaches
* it. The port changes on every restart, so missing it means every image URL
* points at a dead port for the life of the app.
*
* TODO: replace the FAKE_BLOB_ID probe with a direct origin API, which would
* make both the odd query key and that special case unnecessary. See
* digidem/comapeo-core-react#96.
*/
function useMediaServerOrigin({
projectApi,
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { ComapeoCoreProvider } from './contexts/ComapeoCore.js'
export type { SubscribeToBackendRestart } from './contexts/ClientApi.js'
export {
useClientApi,
useIsArchiveDevice,
Expand Down Expand Up @@ -51,6 +52,7 @@ export {
MapShareErrorCode,
getErrorCode,
MapShareCanceledError,
MapShareStreamError,
InvalidStatusTransitionError,
} from './lib/map-shares-stores.js'
export {
Expand Down
Loading
Loading