fix(deps): pin react-native to 0.85.3 via pnpm.overrides #232

Merged
james merged 12 commits from fix-rn-dedupe into main 2026-06-23 11:47:35 +00:00
Owner

Summary

Follow-up to PR #231. The react + react-dom overrides shipped there pinned the bundle to 19.2.3, but Expo Go still reported the React-version mismatch. Investigation found a second react-native version in the workspace.

Root cause

@carol/api-client declares @react-native-community/netinfo and react-native as optional peer deps with the wildcard * version range. When pnpm resolves api-client into the workspace, its peer resolver picks the latest matching for each peer — react-native@0.86.0 for netinfo's peer, not the workspace-pinned 0.85.3.

Result: two react-native sub-graphs in node_modules/.pnpm/:

react-native@0.85.3_…    (pinned, used by apps/client direct)
react-native@0.86.0_…    (transitive via netinfo, brings its own React)

Expo Go's renderer is built against react-native@0.85.3 exactly, so anything else in the bundle triggers the React-version-mismatch error at runtime. The earlier React-version overrides didn't help because the mismatch was downstream, in the RN sub-graph.

Fix

Add react-native: 0.85.3 to root pnpm.overrides. Forces every consumer — including api-client's optional peer-dep resolution — to the workspace's pinned version. The optional-peer declaration in api-client stays intact (it's still the correct intent).

Verified

$ pnpm install --frozen-lockfile
$ find node_modules/.pnpm -name 'react-native' -type d | grep -v 0.86
# only 0.85.3 directories remain
$ pnpm -F @carol/client export:web
# bundle sniff: react 19.2.3, no 19.2.7 anywhere

Test plan

  • pnpm install --frozen-lockfile clean.
  • No react-native@0.86 references in pnpm-lock.yaml.
  • pnpm -F @carol/client export:web succeeds; rebuilt bundle has only React 19.2.3.
  • Manual: Expo Go on a real device boots the bundle without the React/renderer mismatch error.

Forward compatibility

When Expo SDK 57 lands and bumps react-native to the next major, bump the override here in the same PR (and the workspace pins in apps/client).

## Summary Follow-up to PR #231. The `react` + `react-dom` overrides shipped there pinned the bundle to 19.2.3, but Expo Go still reported the React-version mismatch. Investigation found a **second react-native version** in the workspace. ## Root cause `@carol/api-client` declares `@react-native-community/netinfo` and `react-native` as optional peer deps with the wildcard `*` version range. When pnpm resolves api-client into the workspace, its peer resolver picks the **latest matching** for each peer — `react-native@0.86.0` for netinfo's peer, not the workspace-pinned `0.85.3`. Result: two `react-native` sub-graphs in `node_modules/.pnpm/`: ``` react-native@0.85.3_… (pinned, used by apps/client direct) react-native@0.86.0_… (transitive via netinfo, brings its own React) ``` Expo Go's renderer is built against `react-native@0.85.3` exactly, so anything else in the bundle triggers the React-version-mismatch error at runtime. The earlier React-version overrides didn't help because the mismatch was downstream, in the RN sub-graph. ## Fix Add `react-native: 0.85.3` to root `pnpm.overrides`. Forces every consumer — including api-client's optional peer-dep resolution — to the workspace's pinned version. The optional-peer declaration in `api-client` stays intact (it's still the correct intent). ## Verified ``` $ pnpm install --frozen-lockfile $ find node_modules/.pnpm -name 'react-native' -type d | grep -v 0.86 # only 0.85.3 directories remain $ pnpm -F @carol/client export:web # bundle sniff: react 19.2.3, no 19.2.7 anywhere ``` ## Test plan - [x] `pnpm install --frozen-lockfile` clean. - [x] No `react-native@0.86` references in `pnpm-lock.yaml`. - [x] `pnpm -F @carol/client export:web` succeeds; rebuilt bundle has only React 19.2.3. - [ ] Manual: Expo Go on a real device boots the bundle without the React/renderer mismatch error. ## Forward compatibility When Expo SDK 57 lands and bumps `react-native` to the next major, bump the override here in the same PR (and the workspace pins in `apps/client`).
fix(deps): pin react-native to 0.85.3 via pnpm.overrides
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 8s
PR / OSV-Scanner (pull_request) Successful in 2m5s
PR / pnpm audit (pull_request) Successful in 2m30s
PR / Static analysis (pull_request) Successful in 2m29s
PR / Lint (pull_request) Successful in 2m57s
PR / Client (web export smoke) (pull_request) Successful in 3m9s
PR / Test (sqlite) (pull_request) Successful in 3m51s
PR / Package age policy (soft) (pull_request) Successful in 1m19s
PR / OpenAPI (pull_request) Successful in 3m58s
PR / Typecheck (pull_request) Successful in 4m4s
PR / Build (pull_request) Successful in 4m17s
PR / Test (postgres) (pull_request) Failing after 4m17s
Secrets / gitleaks (pull_request) Successful in 1m22s
PR / Coverage (soft) (pull_request) Successful in 2m3s
PR / Trivy (image) (pull_request) Failing after 3m0s
f85521ed1b
The react@19.2.3 + react-dom@19.2.3 overrides shipped in #231 didn't
fully solve the Expo Go version-mismatch error. Investigation showed
the workspace was resolving two react-native versions:

- 0.85.3 (Expo SDK 56's required, pinned in apps/client).
- 0.86.0 (latest), pulled in by @react-native-community/netinfo's
  peer dep — which it inherits because @carol/api-client declares it
  as an optional peer dep with the wildcard "*" range.

pnpm's peer resolver picked the latest matching version (0.86.0) to
satisfy api-client's optional peer, dragging in a second RN sub-graph
alongside the explicit 0.85.3. Expo Go's renderer is built against
0.85.3 exactly, so anything else triggers the React-version-mismatch
error.

Pinning react-native via pnpm.overrides forces every consumer to
0.85.3. The optional-peer declaration in api-client stays — it's
correct intent — but the workspace now produces a single resolution
regardless.

Bump react-native here in the same PR when Expo SDK 57 lands.

📊 Test coverage

Patch coverage: no testable lines changed.

Overall (app/, lib/, db/, excluding UI per ADR-0019):

Metric Value Soft target
Lines 83.0% ≥ 50%
Branches 75.9% ≥ 75%
Functions 91.3% informational

Soft thresholds per ADR-0019. Coverage is informational and does not block merge.

<!-- coverage-comment --> ## 📊 Test coverage **Patch coverage:** no testable lines changed. **Overall** (`app/`, `lib/`, `db/`, excluding UI per ADR-0019): | Metric | Value | Soft target | |---|---|---| | Lines | 83.0% ✅ | ≥ 50% | | Branches | 75.9% ✅ | ≥ 75% | | Functions | 91.3% | informational | Soft thresholds per [ADR-0019](docs/adr/0019-coverage-soft-targets.md). Coverage is informational and does not block merge.

Trivy (container image)

Threshold: high  ·  Total findings: 121  ·  At/above threshold: 1

critical high medium low
0 1 50 70
severity id package installed / range fix
high CVE-2026-12151 undici 6.25.0 6.27.0, 7.28.0, 8.5.0
<!-- scanner-comment: trivy --> ### Trivy (container image) **Threshold:** `high` &nbsp;·&nbsp; **Total findings:** 121 &nbsp;·&nbsp; **At/above threshold:** 1 | critical | high | medium | low | |---:|---:|---:|---:| | 0 | 1 | 50 | 70 | | severity | id | package | installed / range | fix | |---|---|---|---|---| | high | [CVE-2026-12151](https://avd.aquasec.com/nvd/cve-2026-12151) | undici | 6.25.0 | `6.27.0, 7.28.0, 8.5.0` |
fix(client): rewrite relative URLs in the off-origin rewriter
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 8s
PR / Static analysis (pull_request) Successful in 2m1s
PR / OSV-Scanner (pull_request) Successful in 2m10s
PR / Typecheck (pull_request) Successful in 2m20s
PR / pnpm audit (pull_request) Successful in 2m42s
PR / OpenAPI (pull_request) Successful in 3m14s
PR / Lint (pull_request) Successful in 3m40s
PR / Build (pull_request) Has been cancelled
PR / Trivy (image) (pull_request) Has been cancelled
PR / Coverage (soft) (pull_request) Has been cancelled
PR / Test (postgres) (pull_request) Has been cancelled
Secrets / gitleaks (pull_request) Has been cancelled
PR / Package age policy (soft) (pull_request) Has been cancelled
PR / Client (web export smoke) (pull_request) Has been cancelled
PR / Test (sqlite) (pull_request) Has been cancelled
745e3b1e70
The runtime URL middleware assumed `request.url` was always absolute —
true in browsers (fetch resolves Request URLs against the document
origin), false in React Native (the polyfill keeps relative inputs as
literal "/api/foo" strings).

Result: on Android, the rewriter's `new URL(originalUrl)` threw on
the relative path, the catch returned the unchanged value, and the
relative URL leaked to RN's fetch — which resolved it against
something other than the configured server. Manifested as 404s on
login from Expo Go even though the same API worked from the device's
browser.

Branch on whether the input parses as an absolute URL; if it doesn't
and starts with `/`, splice the configured base directly. Anything
else (foreign URL) passes through.
fix(client): explicitly copy request body in the off-origin rewriter
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 39s
PR / OSV-Scanner (pull_request) Successful in 2m13s
PR / OpenAPI (pull_request) Successful in 2m46s
PR / Typecheck (pull_request) Successful in 3m7s
PR / Static analysis (pull_request) Successful in 3m6s
PR / Lint (pull_request) Successful in 3m40s
PR / pnpm audit (pull_request) Successful in 3m19s
PR / Client (web export smoke) (pull_request) Successful in 3m42s
PR / Build (pull_request) Successful in 3m56s
Secrets / gitleaks (pull_request) Successful in 1m0s
PR / Test (sqlite) (pull_request) Successful in 3m52s
PR / Package age policy (soft) (pull_request) Successful in 1m11s
PR / Test (postgres) (pull_request) Successful in 4m3s
PR / Coverage (soft) (pull_request) Has been cancelled
PR / Trivy (image) (pull_request) Has been cancelled
74c70c9e71
`new Request(newUrl, originalRequest)` is documented to copy fields
from a Request as init, but RN's whatwg-fetch polyfill doesn't
reliably transfer the body stream through that shortcut. The server
then sees an empty body — login surfaced as "Invalid JSON body" on
Android via Expo Go.

Read the original body out explicitly via `request.text()` and pass
it as a string in the init. Same pattern for method / headers /
credentials / signal etc. — pass each field rather than letting RN
guess.

GET / HEAD never carry a body; skip the text() read so we don't
trigger the body-already-consumed error on those.
fix(client): middlewares return undefined when not modifying
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 12s
PR / OSV-Scanner (pull_request) Successful in 2m15s
PR / OpenAPI (pull_request) Successful in 2m40s
PR / Static analysis (pull_request) Successful in 2m44s
PR / Typecheck (pull_request) Has been cancelled
PR / Trivy (image) (pull_request) Has been cancelled
PR / Test (postgres) (pull_request) Has been cancelled
PR / Test (sqlite) (pull_request) Has been cancelled
PR / Coverage (soft) (pull_request) Has been cancelled
PR / Client (web export smoke) (pull_request) Has been cancelled
PR / Lint (pull_request) Has been cancelled
PR / Build (pull_request) Has been cancelled
PR / Package age policy (soft) (pull_request) Has been cancelled
Secrets / gitleaks (pull_request) Has been cancelled
PR / pnpm audit (pull_request) Has been cancelled
827ef5b8a7
openapi-fetch 0.17's middleware contract is: return undefined for "no
change", or a new Request / Response when modifying. Carol's middlewares
were returning the unchanged input instance instead of undefined, which
RN's polyfill environment treats as a different class identity — the
runtime `instanceof Request` / `instanceof Response` check fails and
openapi-fetch throws "onResponse: must return new Response() when
modifying the response".

Three middlewares affected:

- `authMiddleware` (api-client) mutates headers in place; was returning
  the input Request. Mutations carry through without re-returning.
- `errorMiddleware` (api-client) returned `response` on the ok path.
- `apiClient.ts` rewriter returned `request` when the URL was unchanged.

All three now return undefined for the no-change branch. The actual
replacement paths (new Request when URL changes, throw CarolApiError
on non-ok responses) are unchanged.
fix(client): iterate Headers explicitly when rewriting Request
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 35s
PR / OSV-Scanner (pull_request) Successful in 1m42s
PR / OpenAPI (pull_request) Successful in 2m40s
PR / Typecheck (pull_request) Successful in 2m50s
PR / Static analysis (pull_request) Successful in 2m46s
PR / Client (web export smoke) (pull_request) Has been cancelled
PR / Test (postgres) (pull_request) Has been cancelled
PR / Trivy (image) (pull_request) Has been cancelled
PR / Build (pull_request) Has been cancelled
PR / Test (sqlite) (pull_request) Has been cancelled
PR / Lint (pull_request) Has been cancelled
PR / Coverage (soft) (pull_request) Has been cancelled
Secrets / gitleaks (pull_request) Has been cancelled
PR / Package age policy (soft) (pull_request) Has been cancelled
PR / pnpm audit (pull_request) Has been cancelled
69bb01e270
Passing `headers: request.headers` to RN's Request constructor doesn't
reliably iterate an existing Headers instance — the resulting Request
ships with no headers (or partial), so the Authorization header
authMiddleware just set disappears. The first authenticated GET after
login (useMe()) answers 401, and the protected layout bounces straight
back to /login.

Walk the Headers via forEach into an array of [key, value] tuples;
RN's polyfill copies tuples reliably.
fix(client): invalidate useMe cache after login
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 31s
PR / OSV-Scanner (pull_request) Successful in 2m11s
PR / OpenAPI (pull_request) Successful in 2m51s
PR / Package age policy (soft) (pull_request) Has been cancelled
PR / Test (sqlite) (pull_request) Has been cancelled
PR / Test (postgres) (pull_request) Has been cancelled
PR / Lint (pull_request) Has been cancelled
PR / Client (web export smoke) (pull_request) Has been cancelled
PR / Trivy (image) (pull_request) Has been cancelled
PR / Build (pull_request) Has been cancelled
PR / Typecheck (pull_request) Has been cancelled
PR / Coverage (soft) (pull_request) Has been cancelled
Secrets / gitleaks (pull_request) Has been cancelled
PR / pnpm audit (pull_request) Has been cancelled
PR / Static analysis (pull_request) Has been cancelled
f7b33be1d2
`useMe()` runs under the (app)/_layout auth gate. The QueryClient
defaults staleTime to 30s, so a pre-login 401 stays cached when the
post-login navigation lands. The layout reads the stale error,
redirects to /login, and the app bounces straight back to where it
started — even though the bearer tokens are now in SecureStore.

Invalidate `keys.auth.me` right before `router.replace("/notes")`.
The layout's first read re-queries with the fresh Authorization
header and sees the actual user.
fix(api): /api/auth/me accepts bearer tokens, not session-only
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 37s
PR / OSV-Scanner (pull_request) Successful in 1m52s
PR / Lint (pull_request) Successful in 2m45s
PR / Static analysis (pull_request) Successful in 2m19s
PR / pnpm audit (pull_request) Successful in 2m42s
PR / OpenAPI (pull_request) Successful in 3m33s
PR / Typecheck (pull_request) Successful in 3m57s
PR / Package age policy (soft) (pull_request) Successful in 1m31s
PR / Test (postgres) (pull_request) Successful in 3m43s
PR / Test (sqlite) (pull_request) Successful in 3m43s
PR / Client (web export smoke) (pull_request) Successful in 3m47s
PR / Build (pull_request) Successful in 3m58s
Secrets / gitleaks (pull_request) Successful in 1m16s
PR / Trivy (image) (pull_request) Failing after 2m29s
PR / Coverage (soft) (pull_request) Successful in 1m49s
a813f2a17b
The route called getSession() directly, which means native clients —
who hold a bearer access token in SecureStore but no session cookie —
can never satisfy it. The universal client's (app)/_layout auth gate
fires useMe() on every protected route mount; on Android the bearer
header is present but the route ignores it, returns 401, and the
layout redirects back to /login. Result: post-login navigation
appears to "flash" the app then bounce straight back, even though
the token mint succeeded and the bearer is well-formed.

Swap getSession() for getAuthIdentity() so either auth method works.
The theme-cookie refresh remains session-only — bearer callers have
no cookie to refresh, and the universal client's ThemeProvider reads
useSettings() directly.
fix(client): respect the status-bar inset in the mobile header
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 9s
PR / Trivy (image) (pull_request) Has been cancelled
PR / Coverage (soft) (pull_request) Has been cancelled
PR / Package age policy (soft) (pull_request) Has been cancelled
PR / pnpm audit (pull_request) Has been cancelled
PR / Test (postgres) (pull_request) Has been cancelled
PR / OpenAPI (pull_request) Has been cancelled
PR / Test (sqlite) (pull_request) Has been cancelled
PR / Client (web export smoke) (pull_request) Has been cancelled
PR / Lint (pull_request) Has been cancelled
PR / Typecheck (pull_request) Has been cancelled
PR / Static analysis (pull_request) Has been cancelled
PR / OSV-Scanner (pull_request) Has been cancelled
PR / Build (pull_request) Has been cancelled
Secrets / gitleaks (pull_request) Has been cancelled
7c4c969ba4
The narrow-shell MobileHeader rendered at y=0, so on Android the
brand + hamburger sat under the status bar. SafeAreaProvider wasn't
mounted, so useSafeAreaInsets() would have returned zeros anyway.

Two changes:
- Mount SafeAreaProvider at the root, inside GestureHandlerRootView,
  outside the rest of the providers. Single instance for the whole
  tree.
- MobileHeader reads useSafeAreaInsets() and inflates its height +
  padding-top by `insets.top`. The 48px content height stays; the
  padding pushes the row down below the status bar without shrinking
  the touch target.
fix(client): give the sidebar shell flex: 1 so the nav list renders
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 24s
PR / OSV-Scanner (pull_request) Successful in 2m5s
PR / pnpm audit (pull_request) Successful in 2m42s
PR / Static analysis (pull_request) Successful in 3m14s
PR / OpenAPI (pull_request) Successful in 3m16s
PR / Lint (pull_request) Successful in 3m39s
PR / Client (web export smoke) (pull_request) Successful in 3m44s
PR / Typecheck (pull_request) Successful in 4m9s
PR / Package age policy (soft) (pull_request) Has been cancelled
PR / Build (pull_request) Has been cancelled
PR / Trivy (image) (pull_request) Has been cancelled
PR / Test (sqlite) (pull_request) Has been cancelled
PR / Coverage (soft) (pull_request) Has been cancelled
Secrets / gitleaks (pull_request) Has been cancelled
PR / Test (postgres) (pull_request) Has been cancelled
ceb678006c
The outer sidebar View had `flexDirection: column` but no `flex: 1`.
Inside it, the nav-list ScrollView is `flex: 1` — meant to fill the
space between the brand row and the footer. Without a flex parent
the View's height is whatever its content adds up to, which means
ScrollView's `flex: 1` distributes against zero, collapsing the nav
list to 0px. Users saw just the brand row + theme/locale footer +
logout — no Notes / Profile / Skills / etc. rows.

Permanent web sidebar didn't trip on this because the layout wraps
it in a fixed-width parent that already imposes a height. The
react-navigation drawer's `drawerContent` callback hands the Sidebar
a container without that imposition.
fix(api): domain routes accept bearer tokens, not just session
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 29s
PR / Typecheck (pull_request) Successful in 2m46s
PR / Static analysis (pull_request) Successful in 2m27s
PR / Lint (pull_request) Successful in 2m58s
PR / OSV-Scanner (pull_request) Successful in 2m28s
PR / OpenAPI (pull_request) Successful in 2m46s
PR / Build (pull_request) Successful in 2m39s
PR / pnpm audit (pull_request) Successful in 3m9s
PR / Package age policy (soft) (pull_request) Successful in 44s
Secrets / gitleaks (pull_request) Successful in 54s
PR / Client (web export smoke) (pull_request) Successful in 3m38s
PR / Test (sqlite) (pull_request) Successful in 3m38s
PR / Test (postgres) (pull_request) Successful in 3m46s
PR / Trivy (image) (pull_request) Has been cancelled
PR / Coverage (soft) (pull_request) Has been cancelled
4246d17199
Same issue as /api/auth/me but across the rest of the protected
surface. Every domain route (skills, jobs, positions, contributions,
educations, settings, skill-sections) called getSession(req)
directly, ignoring the Authorization header the universal client
sends on native. The OpenAPI spec already declares them as
authenticated by either cookieAuth OR bearerAuth (the `authed`
stanza in openapi-routes.ts); the handlers just didn't follow
through.

Swap getSession → getAuthIdentity across 15 route files. The
explicit session-only routes — /api/account/tokens/* (ADR-0021),
OAuth start + callback — keep getSession unchanged.

Notes and profile already used getAuthIdentity; this brings the rest
of the protected surface in line.
fix(client): configure i18next for single-brace {var} interpolation
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 11s
PR / OSV-Scanner (pull_request) Successful in 2m19s
PR / Typecheck (pull_request) Successful in 3m13s
PR / Static analysis (pull_request) Successful in 3m20s
PR / pnpm audit (pull_request) Successful in 3m13s
PR / OpenAPI (pull_request) Successful in 3m31s
PR / Client (web export smoke) (pull_request) Successful in 3m38s
PR / Lint (pull_request) Successful in 3m45s
PR / Build (pull_request) Successful in 3m53s
PR / Test (postgres) (pull_request) Successful in 4m0s
PR / Test (sqlite) (pull_request) Successful in 4m24s
PR / Package age policy (soft) (pull_request) Successful in 1m10s
Secrets / gitleaks (pull_request) Successful in 1m12s
PR / Trivy (image) (pull_request) Failing after 2m12s
PR / Coverage (soft) (pull_request) Successful in 2m17s
4ca13567f1
Carol's translation catalog at packages/i18n/messages/*.json uses ICU
single-brace interpolation ({email}, {url}) — the same shape next-intl
already consumes on the API side. i18next defaults to double-brace
{{var}}, so every interpolated string rendered literal: "Signed in as
{email}." instead of the email, "Connected to {url}." instead of the
server URL.

Override interpolation.prefix and .suffix to "{"/"}" so the same
catalog works in both runtimes.
perf(client): cache the access token in memory
Some checks failed
Commits / Conventional Commits (pull_request) Successful in 13s
PR / OSV-Scanner (pull_request) Successful in 2m5s
PR / OpenAPI (pull_request) Successful in 2m30s
PR / pnpm audit (pull_request) Successful in 2m37s
PR / Lint (pull_request) Successful in 2m54s
PR / Static analysis (pull_request) Successful in 2m53s
PR / Build (pull_request) Successful in 3m16s
PR / Client (web export smoke) (pull_request) Successful in 3m27s
PR / Package age policy (soft) (pull_request) Successful in 1m3s
PR / Test (postgres) (pull_request) Successful in 3m42s
PR / Test (sqlite) (pull_request) Successful in 3m49s
Secrets / gitleaks (pull_request) Successful in 57s
PR / Typecheck (pull_request) Successful in 4m13s
PR / Coverage (soft) (pull_request) Successful in 1m59s
PR / Trivy (image) (pull_request) Failing after 2m23s
a5930f8597
SecureStore.getItemAsync hops to the native bridge on every call —
~5-15ms on Android. The bearer-header middleware fires per HTTP
request, so a screen with four queries pays that four times on mount
and again on each re-fetch. Caching the value in JS memory after the
first read removes the per-request bridge hop without weakening
storage durability — the token still lives in
EncryptedSharedPreferences / Keychain.

Cache is sentinel-undefined ("not loaded") vs null ("no token") vs
string ("token present") so we don't confuse a fresh-mount with a
known-empty state. setAccessToken updates the cache after writing
through; clearTokens nulls it.

The same hot path on web is a no-op (cookies cover auth on
same-origin) so no behaviour change there.
james merged commit 729f10ccbe into main 2026-06-23 11:47:35 +00:00
james deleted branch fix-rn-dedupe 2026-06-23 11:47:35 +00:00
Sign in to join this conversation.
No description provided.