No reviewers
Labels
No labels
area:auth
area:ci
area:db
area:infra
area:native
area:pwa
area:service
epic
feature
foundation
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
james/carol!260
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "253-256-android-profile-picture-2"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Background
PR #257 shipped first-pass fixes for #253 (upload fails on Android) and #256 (uploaded picture doesn't render on Android). Both symptoms persisted post-merge. This PR digs into the actual root causes.
What was actually broken
Upload (#253) —
request.arrayBuffer()throws on FormData under RN's whatwg-fetchPR #257 made
buildPictureFormData()branch onPlatform.OSand append{ uri, name, type }on native. That part was right. The off-origin URL-rewriter middleware inapps/client/lib/apiClient.tsis what killed the upload at runtime.Path on Android:
client.POST("/api/profile/picture", { body: form, bodySerializer: passthrough }).Requestwhose body is the FormData. RN's whatwg-fetch stores it as_bodyFormData(the live object, not bytes).onRequestfires, sees the URL needs the runtime server URL spliced in, and callsextractRequestBody(request).extractRequestBodyseesmultipart/form-datain the Content-Type and callsrequest.arrayBuffer().arrayBuffer()falls back tothis.blob(), which throws"could not read FormData body as blob"when_bodyFormDatais set. (fetch.js#L283)trycatches it and the screen shows the generic "Upload failed. Try again."The first-pass tests in
apiClientBody.test.tsandpicture.test.tspassed because node's Request (undici) does serialise FormData into a readable stream — the bug only manifests on RN's polyfill. PR #257 never had a test that would have caught the platform-specific behaviour.Fix: detect multipart in the rewriter and skip body extraction. Clone via
new Request(next, request), which uses whatwg-fetch's documented pattern (_bodyInitis carried over unchanged). RN's fetch then sees a Request whose body is still the live FormData with the{ uri, name, type }descriptor, and streams the file off disk at send time. The Authorization header is re-applied explicitly after the clone — belt-and-braces against the same RN Headers-iteration quirk the JSON-rebuild branch has long-standing workaround comments about.Display (#256) — token cache is empty at first paint; nothing re-renders when it fills
PR #257 added
getCachedAccessToken()(sync read of an in-memory mirror of SecureStore) and made the Avatar feed it togetProfilePictureSource(). The helper returnsnullwhen the bearer is missing, the consumer falls back to initials — correct so far.The cache is only populated when
getAccessToken()is awaited. That happens on every authenticated request viagetAuthHeader(). On a cold launch the Avatar mounts as part of the Profile screen render; the underlyinguseProfile()query firesgetAccessToken(), but the Avatar's render and the SecureStore round-trip race. The cache loses, the source builder returns null, the Avatar paints initials.Worse: the Avatar's
useEffectdepends only on[pictureUploadedAt, offOrigin]. Once the cache fills, nothing in the dep array changes, so the Avatar never re-resolves and stays on initials until the screen unmounts.Fix: two-part.
loadAccessToken()is called from the root layout's mount effect — same shape asloadServerUrl(). The bearer cache is warm before any screen mounts.subscribeAccessToken()lets render-path consumers re-render on cache transitions. The Avatar subscribes (off-origin only — the PWA's source never depends on the bearer) so it re-resolves the instant the token arrives. Both write paths (setAccessToken,setTokens,clearTokens) and the first successfulgetAccessToken()read notify subscribers.Why PR #257's tests didn't catch this
apiClientBody.test.tsran in node, where Request's FormData serialisation works. RN's whatwg-fetch was never exercised.buildPictureFormData()andgetProfilePictureSource()but not the rewriter — which is the actual broken layer.pictureSrc.test.tscovers the helper, but it's a pure function. The "cache empty at first paint" failure is a state-machine bug between the storage cache and the React component, not a helper bug.New tests
apps/client/tests/apiClient.test.ts: drives a real POST throughapiClientwith FormData and a JSON body. Asserts:apps/client/tests/storage.test.ts: coversloadAccessToken(), the newsubscribeAccessToken()pub/sub, and verifies unsubscribe works and PWA stays a no-op.Verification
Local gates all pass on this branch:
pnpm -F @carol/client typecheck✔pnpm -F @carol/client lint✔pnpm -F @carol/client test— 90 tests, including the 7 new ones ✔pnpm -F @carol/client export:web✔pnpm -F @carol/api-client typecheck/lint/test/check✔pnpm -F @carol/api typecheck/lint/test✔I was not able to run the fix on a real Android device from this worktree — that requires the Android build pipeline and a connected device or emulator, which isn't available in this environment. Confirmation should be:
pnpm -F @carol/client build:android, sideload, point at a Carol instance.Surprises
_bodyFormDataand lets the native networking layer serialise at send time. Any middleware that triesrequest.arrayBuffer()/request.text()/request.blob()on a FormData-bodied Request throws. This is the spec-defined behaviour for FormData bodies in whatwg-fetch, but it's easy to miss if you only test against node.<Image source.headers>works on Android — RN'sRCTImageLoaderdoes forward the headers map to OkHttp viagetSizeWithHeaders. The display fix's source-building logic was correct; the cache-timing was the only issue.getCachedAccessToken()made the race visible.Reopens #253.
Reopens #256.
📊 Test coverage
Patch coverage: no testable lines changed.
Overall (
app/,lib/,db/, excluding UI per ADR-0019):Soft thresholds per ADR-0019. Coverage is informational and does not block merge.