Skip to content

fix: deterministic error handling for canceled map shares#163

Merged
gmaclennan merged 1 commit intomainfrom
fix/cancel-states-errors
Mar 26, 2026
Merged

fix: deterministic error handling for canceled map shares#163
gmaclennan merged 1 commit intomainfrom
fix/cancel-states-errors

Conversation

@gmaclennan
Copy link
Member

Summary

When a sender cancels a map share, receiver-side hooks now produce clear, predictable errors that are easy to handle in app code. Previously, errors leaked internal server codes (DECLINE_SHARE_NOT_PENDING) or threw plain Error objects with no .code, making it difficult to distinguish "the sender canceled" from other failures.

Upstream: @comapeo/map-server changes (v1.1.0)

This fix required a change to @comapeo/map-server to return a specific error when the receiver tries to decline a share that the sender has already canceled.

Before (v1.0.x): MapShare.decline() threw DECLINE_SHARE_NOT_PENDING for any non-pending status — the same error whether the share was canceled, completed, downloading, or in any other state. The client had no way to distinguish "canceled by sender" from other failures.

After (v1.1.0): MapShare.decline() now checks for 'canceled' status specifically and throws the new MAP_SHARE_CANCELED error (HTTP 409) before falling through to the generic DECLINE_SHARE_NOT_PENDING check:

// @comapeo/map-server v1.1.0 — src/lib/map-share.ts
decline(reason) {
    if (this.#state.status === 'canceled') {
        throw new errors.MAP_SHARE_CANCELED()  // new — specific error
    }
    if (this.#state.status !== 'pending') {
        throw new errors.DECLINE_SHARE_NOT_PENDING(...)  // unchanged — other non-pending states
    }
    this.#updateState({ status: 'declined', reason })
}

The download path was already correct — DOWNLOAD_SHARE_CANCELED is handled internally by DownloadRequest, which sets the share status to 'canceled' and delivers it to the client via EventSource. No change was needed there.

What changed for consumers

Handling canceled shares

All receiver actions now behave consistently when a share has been canceled by the sender:

  • useDownloadReceivedMapShare — The mutation resolves (the download starts), but the share status ends up as 'canceled' rather than 'completed'. This is the mechanism by which the receiver discovers cancellation. Check share.status after the download settles.

  • useDeclineReceivedMapShare — The mutation throws with code: 'MAP_SHARE_CANCELED'. The share status transitions to 'canceled' (not 'error'). This is true whether the store already knew about the cancellation or the server reported it during the decline request.

  • useAbortReceivedMapShareDownload — The mutation throws with code: 'MAP_SHARE_CANCELED' if the share is known to be canceled.

Decline is no longer optimistic

useDeclineReceivedMapShare previously updated the share status to 'declined' immediately before the server confirmed. This caused an irrecoverable state when the server rejected the decline (the share was stuck in 'declined' locally but the server disagreed). Now the share stays in 'pending' until the server confirms, then transitions to 'declined'. Use mutation.isPending to show a loading state in the UI.

New exports for error handling

All errors now carry a .code string property. Since mutation errors are typed as Error | null, use the new getErrorCode() helper to safely extract the code:

import { getErrorCode, MapShareErrorCode } from '@comapeo/core-react'

const decline = useDeclineReceivedMapShare()

// After mutation fails:
const code = getErrorCode(decline.error)
if (code === MapShareErrorCode.MAP_SHARE_CANCELED) {
  // Show "this share was canceled by the sender"
}

Or in a try/catch:

try {
  await decline.mutateAsync({ shareId, reason: 'user_rejected' })
} catch (e) {
  if (getErrorCode(e) === MapShareErrorCode.MAP_SHARE_CANCELED) {
    // handle cancellation
  }
}

Error code reference

Receiver mutation errors (thrown by hooks — check via getErrorCode(mutation.error)):

Code When Which hooks
MAP_SHARE_CANCELED The sender canceled the share download, decline, abort
INVALID_STATUS_TRANSITION The action isn't valid for the share's current status (e.g. declining a share that is already downloading) download, decline, abort
MAP_SHARE_NOT_FOUND No share with the given shareId exists download, decline, abort
DOWNLOAD_NOT_FOUND Abort called but no download is tracked abort

Receiver share state errors (on share.error.code when share.status === 'error'):

Code When
DOWNLOAD_ERROR Download failed (network, disk, or server error)
DECLINE_CANNOT_CONNECT Decline accepted locally but couldn't reach the sender to notify them

Sender mutation errors (thrown by hooks):

Code When Which hooks
INVALID_STATUS_TRANSITION Action isn't valid for current status cancel
MAP_SHARE_NOT_FOUND No share with the given shareId cancel

Sender share state errors (on share.error.code when share.status === 'error'):

Code When
CANCEL_SHARE_NOT_CANCELABLE Cancel request reached the server but the share is already completed or declined

Common:

Code When
UNKNOWN_ERROR Fallback when the original error has no specific code

Breaking changes

  • useDeclineReceivedMapShare no longer optimistically updates share status. Apps relying on the share immediately showing status: 'declined' before the server responds should use mutation.isPending instead.
  • Requires @comapeo/map-server >= 1.1.0.

Test plan

  • Download after sender canceled → share ends at 'canceled' status
  • Decline after sender canceled → mutation throws MAP_SHARE_CANCELED, share status is 'canceled'
  • All actions after share reaches 'canceled' → throw MAP_SHARE_CANCELED
  • Existing invalid transition tests still pass with .code assertions
  • TypeScript compiles with no errors

When a sender cancels a map share, receiver actions now produce clear,
predictable errors with a `code` property instead of leaking internal
server errors or ambiguous status transition messages.

Behavior changes:

- All receiver actions (download, decline, abort) check for 'canceled'
  status before proceeding. If the share is already known to be canceled,
  they throw MapShareCanceledError (code: 'MAP_SHARE_CANCELED').

- decline() no longer uses an optimistic update. The share status stays
  'pending' until the server confirms, then transitions to 'declined'.
  This avoids an irrecoverable state when the server rejects the decline
  (e.g. because the sender already canceled). If the server reports
  MAP_SHARE_CANCELED, the share transitions to 'canceled' (not 'error').

- decline() now calls .json() on the POST response so that server error
  codes are properly surfaced through the HTTPError chain.

- assertValidStatusTransition() throws InvalidStatusTransitionError
  (code: 'INVALID_STATUS_TRANSITION') instead of a plain Error.

New exports:

- MapShareCanceledError — thrown for actions on canceled shares
- InvalidStatusTransitionError — thrown for invalid status transitions
- MapShareErrorCode — const object of all error codes a consumer may
  encounter, documented by sender/receiver role
- getErrorCode() — safely extracts .code from an unknown error

Requires @comapeo/map-server >=1.1.0 for the MAP_SHARE_CANCELED error
code on the decline path (previously returned DECLINE_SHARE_NOT_PENDING).
@gmaclennan gmaclennan requested a review from achou11 March 25, 2026 12:36
@awana-lockfile-bot
Copy link

package-lock.json changes

Click to toggle table visibility
Name Status Previous Current
@comapeo/map-server UPDATED 1.0.1 1.1.0
@gmaclennan/zip-reader ADDED - 1.0.0
@sqlite.org/sqlite-wasm ADDED - 3.51.2-build8
itty-router UPDATED 5.0.22 5.0.23
smp-noto-glyphs ADDED - 1.0.0-pre.0
styled-map-package-api ADDED - 5.0.0-pre.4
zip-writer ADDED - 2.2.0

@socket-security
Copy link

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Updated@​comapeo/​map-server@​1.0.1 ⏵ 1.1.077 +3100100 +196 +1100

View full report

Copy link
Member

@achou11 achou11 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Provided some thoughts on improvements to how the error types are surfaced from the relevant hooks.

Comment on lines +245 to +248
* @throws {MapShareCanceledError} If the share is already known to be canceled
* (i.e. `status` is `'canceled'` in the store, e.g. after a previous
* download attempt discovered the cancellation).
* @throws {InvalidStatusTransitionError} If the share is not in a valid state
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While having these is helpful for docs purposes, it's not useful when it comes to inferring types. This is basically claiming that the hook throws these errors when it's actually the mutation methods that need to be annotated with these.

Think it would be more appropriate to update the hook implementation to specify these as type param inputs for the useMutation:

export function useDownloadReceivedMapShare() {
	const { download } = useReceivedMapSharesActions()

	return filterMutationResult(
		useMutation<
			void,
			MapShareCanceledError | InvalidStatusTransitionError | Error,
			DownloadMapShareOptions
		>({
			...baseMutationOptions(),
			mutationFn: async (options: DownloadMapShareOptions) => {
				return download(options)
			},
		}),
	)
}

That way the error callback/catch handler will be decorated and can be narrowed:

Image

Doing this might make the getErrorCode export a little less necessary too.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably makes more sense for these @throws annotations to be moved to relevant methods in the maps shares store implementation, although an explicit errors annotation for the useMutation usage will still be needed.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doing this might make the getErrorCode export a little less necessary too.

Ah, I guess if using mutateAsync() then it might still be helpful to have this helper, as the caught error in the try/catch still uses a broad any/unknown type.

Guess that's why using mutate() over mutateAsync() can be preferable 😄

Comment on lines +282 to +286
* @throws {MapShareCanceledError} If the share is already known to be
* canceled, or if the server reports that the sender canceled the share
* while the decline was in flight. In both cases `share.status` will be
* `'canceled'`.
* @throws {InvalidStatusTransitionError} If the share is not in
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment about passing these as explicit type param inputs for the useMutation, and moving these annotations to the map shares store.

Comment on lines +319 to +320
* @throws {MapShareCanceledError} If the share is already known to be canceled.
* @throws {InvalidStatusTransitionError} If the share is not in
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment about using these in useMutation type param inputs and moving these annotations to the map shares store.

Comment on lines +150 to +159
export function getErrorCode(error: unknown): string | undefined {
if (
error instanceof Error &&
'code' in error &&
typeof error.code === 'string'
) {
return error.code
}
return undefined
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

depending on thoughts on my other suggestions, it may not be necessary to export a helper for this, as the check becomes relatively trivial to do in consuming app code.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

my original remark is potentially less convincing based on my follow-up in #163 (comment).

@gmaclennan
Copy link
Member Author

@achou11 I started adding the error type annotations, but then rolled them back. Because errors aren't typed by Typescript, adding the types is really just guidance, and I think misleading. A mutation function could easily throw a different error due to a bug, and the types don't reflect this. On reflection I think it's better not to type the errors on these mutation functions, because it could result in a runtime error relying on the errors to be a specific type, when there is no guarantee that they will be.

Regarding documenting the hook rather than the mutate function, there's not really a clean way to do that with tanstack query. It involves destructuring the return value of useMutation() and adding type docs there, but that looses the error discriminate union and adds a lot of code boilerplate. I think documentation on the hook is sufficient for dev use, and better than nothing.

@gmaclennan gmaclennan merged commit b2c0ccc into main Mar 26, 2026
9 checks passed
@gmaclennan gmaclennan deleted the fix/cancel-states-errors branch March 26, 2026 12:29
@achou11
Copy link
Member

achou11 commented Mar 26, 2026

A mutation function could easily throw a different error due to a bug, and the types don't reflect this

That's why I still included Error as part of the annotation in my example. But yeah I understand your broader point.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants