Skip to content

fix(ios): read isProtectedDataAvailable on the main actor; retry a query refused while the store re-opens - #374

Merged
robertherber merged 2 commits into
kingstinct:masterfrom
idoyana:fix/protected-data-main-actor-and-code6-retry
Sep 7, 2026
Merged

fix(ios): read isProtectedDataAvailable on the main actor; retry a query refused while the store re-opens#374
robertherber merged 2 commits into
kingstinct:masterfrom
idoyana:fix/protected-data-main-actor-and-code6-retry

Conversation

@idoyana

@idoyana idoyana commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

fix(ios): read isProtectedDataAvailable on the main actor; retry a query refused while the store re-opens

Two defects, one file each

1. CoreModule.swiftUIApplication.shared.isProtectedDataAvailable is read off the main thread.
UIApplication is @MainActor-isolated. Nitro sync hybrid methods run on the JS thread, and Promise.resolved(withResult:) evaluates its argument at the call site — so both isProtectedDataAvailable() and isProtectedDataAvailableAsync() read the property from the JS thread. Every other UIKit/HealthKit callback in this file hops to DispatchQueue.main; these two are the only ones that don't. The async variant is fixed here with MainActor.run. The sync variant cannot be fixed by dispatch (blocking the JS thread on main risks deadlock) and is left as-is; callers that need a correct value should use the async one.

2. HKError.errorDatabaseInaccessible is thrown as an opaque string.
handleHKNoDataOrThrow already keys on nsError.domain/code to map errorNoData; errorDatabaseInaccessible (Code 6) falls through to continuation.resume(throwing:), and Nitro flattens the NSError to String(describing:) before it reaches JS — domain and code are gone, leaving consumers to string-match "Code=6".

Measured on an iPhone XS Max / iOS 18.7 with a 1 s sampler through three lock/unlock cycles:

  • HealthKit refuses with Code 6 within 55 ms of the lock, while isProtectedDataAvailable (read on either thread — identical on ~200 paired samples) stays true for exactly ~10 s (iOS's data-protection grace), then flips.
  • On unlock the store re-opens within a sub-second window, and iOS can deliver active to the app before it has — a query issued in that window is refused although protected data is (correctly) reported available.

That second case is Apple's documented recovery for this error ("wait for protected data to become available, then retry"). This PR adds one generic helper in Helpers.swift, retryingWhileStoreReopens { … }: it runs the query and, on exactly that refusal while protected data is available (the store is re-opening rather than sealed), gives it one more attempt after 1 s. A refusal while protected data is unavailable is thrown unchanged, as is every other error. It is applied at the four statistics call sites in QuantityTypeModule.swift (queryStatisticsForQuantity, …SeparateBySource, queryStatisticsCollectionForQuantity, …SeparateBySource); the helper is deliberately generic so the same treatment can be extended to the sample and characteristic queries in a follow-up if you want it there too.

Consumers that previously received "Error Domain=com.apple.healthkit Code=6 …" on the unlock edge now receive the statistics; refusals from a sealed store are unchanged.

Files

ios/CoreModule.swift, ios/Helpers.swift (the helper), ios/QuantityTypeModule.swift (four call sites), src/healthkit.ios.ts, src/healthkit.ts, plus a changeset.

Related: #35 (asked for isProtectedDataAvailable and the protected-data notifications), #176 (same Code 6 in the wild).

Validation (iPhone XS Max, iOS 18.7.10, 1 s sampler + 25 ms burst on active)

  • Lock edge, inside the ~10 s grace (isProtectedDataAvailable still true): every query now takes ~1.05–1.4 s and still throws Code 6 — the retry path ran (sleep + second attempt) and correctly gave up on a sealed store.
  • Deep lock (isProtectedDataAvailable false): queries throw in ~10 ms — no retry, thrown unchanged.
  • Unlock: store already open before the app's active edge on this device; 40 reads in 1.3 s all succeed in ~10 ms — the wrapper adds nothing to healthy reads.

🤖 Generated with Claude Code

@changeset-bot

changeset-bot Bot commented Sep 2, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 151fa27

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@kingstinct/react-native-healthkit Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@idoyana
idoyana force-pushed the fix/protected-data-main-actor-and-code6-retry branch 2 times, most recently from a392b16 to becfeff Compare September 2, 2026 04:35
…ery refused while the store re-opens

UIApplication is @MainActor-isolated, but both isProtectedDataAvailable
accessors evaluated it on the JS thread (sync hybrid method / Promise.resolved
argument). The async accessor now hops to the main actor; the sync one cannot be
fixed by dispatch without risking a deadlock and is left as-is.

HKError.errorDatabaseInaccessible (Code 6) was thrown as an opaque string:
handleHKNoDataOrThrow keys on domain/code for errorNoData but not for this one,
and the NSError is flattened to String(describing:) at the bridge. HealthKit
seals the store the moment the device locks and re-opens it a beat after it
unlocks, and iOS can resume the app inside that beat, so a query issued in the
gap is refused although protected data is reported available.

Add retryingWhileStoreReopens to Helpers.swift: it runs an operation and, on
exactly that refusal while protected data is available, gives it one more
attempt after a short delay. A refusal while protected data is unavailable is
the sealed store itself and is thrown as-is, as is every other error. Applied to
the four statistics call sites; the helper is generic so the sample and
characteristic queries can adopt it in a follow-up.
@idoyana
idoyana force-pushed the fix/protected-data-main-actor-and-code6-retry branch from becfeff to f0f8cef Compare September 2, 2026 04:50
@idoyana

idoyana commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

@robertherber when you have a moment, could you take a look at this one?

It is a small change: isProtectedDataAvailableAsync now reads on the main actor, and the four statistics queries get a single retry when HealthKit refuses with errorDatabaseInaccessible while protected data is reported available (the unlock edge). The on-device measurements behind it are in the description.

The workflows are waiting on approval since this is my first PR here. Happy to adjust anything, or to extend the helper to the sample and characteristic queries in the same PR if you would like it there too. Thanks!

@robertherber robertherber left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for your contribution, overall I think it looks good! 👍

Comment thread packages/react-native-healthkit/ios/Helpers.swift Outdated
Comment thread packages/react-native-healthkit/src/healthkit.ts
@idoyana
idoyana requested a review from robertherber September 7, 2026 16:37
@pkg-pr-new

pkg-pr-new Bot commented Sep 7, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/kingstinct/react-native-healthkit/@kingstinct/react-native-healthkit@374

commit: 151fa27

@robertherber
robertherber merged commit b24b8ed into kingstinct:master Sep 7, 2026
9 checks passed
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