Finish the Expo SDK migration: SDK 52→57, RN 0.76.5→0.86.2, expo-av → expo-audio + expo-video - #2056
Finish the Expo SDK migration: SDK 52→57, RN 0.76.5→0.86.2, expo-av → expo-audio + expo-video#2056rbuergi wants to merge 5 commits into
Conversation
Dependency half of #1584. Every version is the pairing expo@57's own bundledNativeModules.json names, not a guess: react-native 0.76.5 -> 0.86.2 (peers react ^19.2.3, which the tree already has) react / react-dom 18.3.1 -> 19.2.8 (matches clients/react) react-native-web 0.19.13 -> 0.21.2 react-native-svg 15.8.0 -> 15.15.4 expo 52 -> 57.0.15 (+ every expo-* to its 57.x line) expo-av 15.1.7 -> REMOVED (not in SDK 57 at all) expo-audio / expo-video -> NEW (the two halves expo-av split into) typescript deliberately stays 5.x: @expo/cli still reads tsconfig through the classic TS compiler API that TS 7 deleted, so `expo export` (the RN web Playwright job) dies without it. The call-site port follows in the next commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The substantive half of #1584. expo-av is not a rename — it is a SPLIT into two packages with different APIs, so each call site had to be sorted: src/speech/expoRecorder.ts -> expo-audio (microphone capture, 1 site) src/rnContainers.tsx -> expo-video (the Video leaf, 1 site) Capture (expo-audio). The FORMAT contract is what /api/speech/transcribe and the Whisper container consume, so it is preserved exactly — 16 kHz mono, WAV/LINEARPCM on iOS, AAC/.m4a on Android, same contentType/fileName. What moved: - Audio.Recording.createAsync() -> construct + prepareToRecordAsync() + record() - stopAndUnloadAsync() returned the duration; stop() resolves void, so the duration is read from getStatus() BEFORE stopping (the web recorder drops its MediaRecorder inside stop(), after which it can no longer measure). - recording.getURI() -> recorder.uri - allowsRecordingIOS/playsInSilentModeIOS -> allowsRecording/playsInSilentMode - RecordingOptions moved extension/sampleRate/channels/bitRate from PER-PLATFORM to top level. bitRate has no per-platform slot at all now, so expo-av's two values (256k iOS / 64k Android) are selected by Platform. - expo-audio ships no imperative recorder constructor under one name on every platform (native: AudioModule.AudioRecorder; web: AudioModule.AudioRecorderWeb, and the types describe only the native shape). Both implement the same interface, so the ctor is resolved at runtime behind one documented cast. Renamed ExpoAvRecorder -> ExpoAudioRecorder; the Recorder seam is unchanged, so PushToTalkController and its tests are untouched. Video (expo-video). Prop renames are mechanical (useNativeControls -> nativeControls, resizeMode={ResizeMode.CONTAIN} -> contentFit="contain") and the source moved off the view onto a player (useVideoPlayer). One prop has NO successor: expo-video has no posterSource/usePoster and VideoViewProps carries no poster of any kind. VideoControl.Poster is a real field that Blazor honours as <video poster>, so rather than drop it silently it is reproduced as an overlay that clears on the first playingChange, inside a pointerEvents="none" View so the native play button still takes the tap. Three tests pin it. app.json: the expo-av plugin becomes expo-audio with the same microphonePermission, plus enableBackgroundPlayback:false — that option DEFAULTS TO TRUE and would add UIBackgroundModes:[audio] and FOREGROUND_SERVICE_MEDIA_PLAYBACK to a build that only records. typecheck 0 errors; vitest 16 files / 161 tests green; npm ci replays the lock. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found by RUNNING the web export, not by typechecking it: with SDK 57 the exported
app was a blank page (the noscript text) and all 5 Playwright tests failed on
"element(s) not found", with one page error before React ever mounted —
TypeError: Cannot read properties of undefined (reading 'SharedObject')
expo-video@57's web build defines `class VideoPlayerWeb extends
globalThis.expo.SharedObject` at MODULE-EVALUATION time (build/VideoPlayer.web.js),
and nothing in its own import graph reaches expo-modules-core, whose src/index.ts
is what runs installExpoGlobalPolyfill() and creates globalThis.expo on web:
expo-video/index -> ./VideoModule -> NativeVideoModule.web (no imports)
-> ./VideoView -> VideoView.web -> VideoPlayer.web <- throws
Nor does this app's entry: expo/AppEntry.js -> registerRootComponent ->
Expo.fx.web -> winter / async-require / rsc, none of which touch
expo-modules-core. Router-based Expo apps get the global for free from their
entry, which is why upstream ships this. expo-audio is unaffected — its index.js
re-exports from 'expo' on the first line.
A side-effect `import "expo"` above the expo-video import installs it. That pulls
the whole Expo runtime into vitest, where expo/src/async-require/setup.ts reads
__DEV__ (a Metro-transform define) and killed 10 of 16 suites, so the test tree
aliases `expo` to a stub that installs the same global with a usable SharedObject
base — the reason the import exists stays visible instead of being aliased to
nothing.
typecheck 0; vitest 16 files / 161 tests; expo export + Playwright 5/5 green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The last piece of #1584: clients/react-native/package.json and .github/dependabot.yml both carried holdbacks whose stated reason was the SDK 52 pin, with an explicit instruction to lift them AS PART of this migration. Each was re-measured on SDK 57 rather than assumed: typescript -> 7.0.2 (RETIRED). The blocker was @expo/cli SDK 52 reading tsconfig through the classic TS compiler API that TS 7 deleted. SDK 57's CLI does not: `expo export --platform web` runs clean on a COLD Metro cache (503 modules) under 7.0.2, and the Playwright e2e is 5/5 on the resulting bundle. Two tsconfig changes belong to TS 7 itself, not to Expo: `baseUrl` is removed (TS5102 — every `paths` entry here is already relative, which is what baseUrl:"." meant), and @types is no longer auto-included, so `types:["node"]` + a declared @types/node — the same fix, for the same TS2591 on the same kind of file, that clients/react already carries. react/@types/react majors: kept, but the REASON is rewritten. The 0.76.5-era hard `react ^18.2.0` peer is gone; what remains is that react's major here is whatever react-native peers, so it still arrives with the SDK jump or not at all. react-native / react-native-web / expo* / @expo* majors: unchanged. That coupling is the whole reason this directory has its own dependabot config. CI Node 20 -> 22 for both RN jobs. react-native 0.86 and metro declare engines.node ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0; Node 20 clears that floor only on its final patches and went EOL 2026-04-30, so `node-version: 20` would leave the jobs depending on setup-node continuing to resolve a 20.19.4+ patch of an unsupported line. The react job has been on 22 all along. Verified after a clean `npm ci`: tsc 0, vitest 16 files / 161 tests, cold `expo export --platform web`, Playwright 5/5. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Completes the Expo SDK migration for clients/react-native, aligning the package with Expo SDK 57’s upstream version pairing (RN 0.86.2 / React 19.x) and replacing deprecated expo-av usage with expo-audio (recording) and expo-video (video playback).
Changes:
- Upgraded Expo/RN/React/TypeScript to the SDK 57 + RN 0.86.2 + React 19 toolchain and updated CI Node version for RN jobs.
- Ported
expo-avcall sites toexpo-audio(speech recorder) andexpo-video(Video control), adding test scaffolding/mocks as needed. - Adjusted RN web/test environment wiring (Vitest aliases + new Expo stub) to accommodate
expo-video’s web initialization ordering.
Reviewed changes
Copilot reviewed 15 out of 16 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| clients/react-native/vitest.config.ts | Swaps module aliases from expo-av to expo-video and adds an expo stub for tests. |
| clients/react-native/tsconfig.json | Updates TS 7-related compiler options and keeps path-based type identity dedupe. |
| clients/react-native/test/react-native.mock.tsx | Extends RN mock StyleSheet to include absolute-fill helpers. |
| clients/react-native/test/expo.mock.ts | Adds an expo stub to install globalThis.expo for headless tests. |
| clients/react-native/test/expo-video.mock.tsx | Adds a minimal expo-video mock (player + view) for unit tests. |
| clients/react-native/test/expo-av.mock.tsx | Removes the old expo-av mock. |
| clients/react-native/src/speech/expoRecorder.ts | Ports the recorder implementation from expo-av to expo-audio. |
| clients/react-native/src/rnContainers.tsx | Ports the Video control from expo-av to expo-video, including poster overlay behavior. |
| clients/react-native/src/rnContainers.test.tsx | Updates/extends unit tests to validate the new expo-video mapping and poster overlay behavior. |
| clients/react-native/README.md | Updates documentation to reference ExpoAudioRecorder / expo-audio. |
| clients/react-native/package.json | Updates dependencies to Expo SDK 57 stack; replaces expo-av with expo-audio + expo-video. |
| clients/react-native/App.tsx | Renames recorder usage to ExpoAudioRecorder and updates related comments. |
| clients/react-native/app.json | Switches config plugin from expo-av to expo-audio and explicitly disables background playback. |
| .github/workflows/clients.yml | Bumps RN job Node version to 22 for SDK 57 / RN 0.86 engine requirements. |
| .github/dependabot.yml | Updates RN/Expo holdback rationale now that SDK 57 migration is completed. |
Suppressed comments (1)
clients/react-native/src/speech/expoRecorder.ts:150
- On web,
stop()returnscontentType: "audio/mp4"/fileName: "audio.m4a"even though the configured web recorder usesmimeType: "audio/webm". This makes the returnedAudioInputmetadata inconsistent with the actual file format.
return {
uri,
contentType: ios ? "audio/wav" : "audio/mp4",
fileName: ios ? "audio.wav" : "audio.m4a",
durationMs,
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| const ios = Platform.OS === "ios"; | ||
|
|
||
| const RECORDING_OPTIONS: RecordingOptions = { | ||
| isMeteringEnabled: false, | ||
| // Top-level now (see the note above). `ios`/`android` below re-state the extension because the | ||
| // platform blocks are spread OVER these on the way to the native module. | ||
| extension: ios ? ".wav" : ".m4a", | ||
| sampleRate: 16_000, | ||
| numberOfChannels: 1, |
There was a problem hiding this comment.
Fixed in 0f96653 — good catch, and it was worse than a new-code slip: the expo-av original branched on Platform.OS === "ios" alone too, so Expo web has been labelling its audio/webm MediaRecorder output as .m4a / audio/mp4 all along. Porting made it visible because the new RECORDING_OPTIONS spells the web mimeType out next to the extension.
CONTAINER now keeps extension, contentType and fileName together across three cases (ios / web / android), so the label cannot drift from the bytes again.
One thing I deliberately did NOT fix, recorded in the file: web capture is still not end-to-end regardless of the label. The web recorder yields a blob: URL, and transcription.ts posts a uri through React Native's {uri, name, type} FormData extension, which is native-only. That gap predates this port.
| const player = useVideoPlayer(!src || isEmbed ? null : src); | ||
| const [started, setStarted] = useState(false); | ||
| useEffect(() => { | ||
| if (!player) return; | ||
| const sub = player.addListener("playingChange", ({ isPlaying }) => { | ||
| if (isPlaying) setStarted(true); | ||
| }); | ||
| return () => sub.remove(); | ||
| }, [player]); |
There was a problem hiding this comment.
Fixed in 0f96653 — correct, and it is reachable: useVideoPlayer memoizes on JSON.stringify(source), so a control whose Src changes IN PLACE gets a new player while started stayed true, and the new video would never show its poster. started is now reset with the player, in the same effect that subscribes to it, rather than only on unmount.
Pinned by a test that was verified non-vacuous: with setStarted(false) removed it fails ("brings the poster back when the Src changes after the first video played"), restored it passes.
Test Results (shard 3) 9 files - 1 9 suites - 1 4m 56s ⏱️ +10s Results for commit 0f96653. ± Comparison against base commit 7968b8e. This pull request removes 48 and adds 4 tests. Note that renamed tests count towards both.♻️ This comment has been updated with latest results. |
Test Results (shard 0)1 230 tests +1 1 230 ✅ +5 12m 16s ⏱️ +56s Results for commit 0f96653. ± Comparison against base commit 7968b8e. This pull request removes 4 and adds 5 tests. Note that renamed tests count towards both.♻️ This comment has been updated with latest results. |
Test Results (shard 5)1 233 tests +16 1 232 ✅ +16 6m 44s ⏱️ -30s Results for commit 0f96653. ± Comparison against base commit 7968b8e. This pull request removes 4 and adds 20 tests. Note that renamed tests count towards both.♻️ This comment has been updated with latest results. |
Test Results (shard 4)2 093 tests +192 1 794 ✅ - 1 9m 37s ⏱️ -28s For more details on these failures, see this check. Results for commit 0f96653. ± Comparison against base commit 7968b8e. This pull request removes 5 and adds 197 tests. Note that renamed tests count towards both.This pull request removes 1 skipped test and adds 193 skipped tests. Note that renamed tests count towards both.♻️ This comment has been updated with latest results. |
Test Results (shard 2)3 214 tests - 18 3 214 ✅ - 18 8m 27s ⏱️ +7s Results for commit 0f96653. ± Comparison against base commit 7968b8e. This pull request removes 18 tests.♻️ This comment has been updated with latest results. |
Test Results (shard 1)1 744 tests - 195 1 744 ✅ - 2 9m 11s ⏱️ +11s Results for commit 0f96653. ± Comparison against base commit 7968b8e. This pull request removes 195 tests.♻️ This comment has been updated with latest results. |
Test Results 55 files - 3 55 suites - 3 51m 13s ⏱️ +26s For more details on these failures, see this check. Results for commit 0f96653. ± Comparison against base commit 7968b8e. This pull request removes 52 and adds 4 tests. Note that renamed tests count towards both.♻️ This comment has been updated with latest results. |
1. The web recording was labelled with the WRONG container. Both this file and
its expo-av predecessor branched on `Platform.OS === "ios"` alone, so Expo web
— which records audio/webm through MediaRecorder, and always did — reached
/api/speech/transcribe as `.m4a` / `audio/mp4`. The new RECORDING_OPTIONS spells
the web mimeType out, which is what made the mismatch visible. `CONTAINER` now
keeps extension, contentType and fileName together across three cases.
(Web capture is still not end-to-end — the recorder yields a blob: URL and
transcription.ts posts a uri through RN's native-only {uri,name,type} FormData
extension. That gap predates the port and is recorded, not fixed, here.)
2. The Video poster's `started` flag was sticky. useVideoPlayer memoizes on the
source, so a control whose Src changes IN PLACE gets a new player — but
`started` stayed true and the new video's poster never appeared. Reset it with
the player rather than only on unmount.
The second is pinned by a test that was verified non-vacuous: with setStarted(false)
removed it fails, restored it passes. vitest 16 files / 162 tests; tsc 0;
export + Playwright 5/5.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Finishes the migration #1584 opened: Expo SDK 52 → 57, react-native 0.76.5 → 0.86.2, react 18.3.1 → 19.2.8, and
expo-av→expo-audio+expo-video.One correction to the issue title up front: SDK 57 does not pair with RN ~0.82.
expo@57.0.15's ownbundledNativeModules.jsonnamesreact-native: 0.86.2(which is whatclients/react-native/package.json's existing note already said — "expo@57 + react-native@0.86"). Every version in this PR is taken from that file rather than chosen, andexpo-avis not in it at all.1. The
expo-avcall-site split — enumerated before anything was changedexpo-avis a package split, not a rename, so every site was sorted first. There are exactly two:src/speech/expoRecorder.tsexpo-audio/api/speech/transcribepipelinesrc/rnContainers.tsxexpo-videoVideoleaf of the RN control packSo: 1 audio, 1 video. Plus five supporting references that are bookkeeping, not API:
test/expo-av.mock.tsx(replaced), thevitestalias, theapp.jsonconfig-plugin entry, thepackage.jsondependency, and a comment + class name inApp.tsx/README.md.What consumes this client (checked, per the brief)
The speech lane is real and it is the thing at risk.
ExpoAudioRecorder→AudioInput→ multipart POST toPOST /api/speech/transcribe(memex/Memex.Portal.Shared/Api/SpeechEndpoints.cs, and the LocalMesh twin inmemex/Memex.LocalMesh/Program.cs) → the Whisper container. What that endpoint consumes is the FORMAT, not the API, so the format is preserved exactly: 16 kHz mono, WAV/LINEARPCM on iOS, AAC/.m4aon Android, samecontentType/fileName. TheRecorderinterface is untouched, soPushToTalkControllerand its 16 tests never see the vendor change.VideoControl.Posteris likewise a real server-side field (src/MeshWeaver.Layout/VideoControl.cs, rendered by Blazor as<video poster>) — see below.2. What changed BEHAVIOUR, not just API surface
Everything else is a mechanical rename. These five are not:
a.
Videolost its poster prop — and it is reimplemented rather than dropped.expo-videohas noposterSource/usePoster;VideoViewPropscarries no poster of any kind, andVideoSource.metadata.artworkis the lock-screen image, not an in-view one. SinceVideoControl.Posteris a documented field Blazor honours, it is reproduced as an overlayImageinside apointerEvents="none"View(so the native play button still takes the tap), cleared on the firstplayingChange. Nuance: expo-av swapped the poster out when the video loaded; this clears it when playback starts — which is what<video poster>does. Three unit tests pin it.b. Recording duration is now read before
stop(), not returned by it.expo-av'sstopAndUnloadAsync()resolved a status carryingdurationMillis;expo-audio'sstop()resolvesvoid, and the web recorder drops itsMediaRecorderinsidestop(), after whichgetStatus()can no longer measure. So the duration is sampled fromgetStatus()immediately before stopping. It is therefore a few ms short of the old value. (durationMsis produced but never consumed anywhere in the tree.)c. Expo web's recording label was wrong, and is corrected. Both this file and its expo-av predecessor branched on
Platform.OS === "ios"alone, so Expo web — which recordsaudio/webmthrough MediaRecorder, and always did — reached the transcribe endpoint labelled.m4a/audio/mp4. The port made it visible becauseexpo-audio'sRecordingOptionsspells the web mimeType out next to the extension. A singleCONTAINERnow keeps extension/contentType/fileName together across three cases. (Deliberately NOT fixed, and recorded in the file: web capture is still not end-to-end either way — the recorder yields ablob:URL andtranscription.tsposts aurithrough React Native's native-only{uri, name, type}FormData extension. That gap predates this port.)d.
bitRatemoved from per-platform to top level.expo-audio'sRecordingOptionshoistsextension/sampleRate/numberOfChannels/bitRateout of the platform blocks, andbitRatehas no per-platform slot any more. expo-av carried two values (256 kbps iOS / 64 kbps Android), so they are now selected byPlatform. Same values, same files.e.
app.json:enableBackgroundPlayback: falseis passed explicitly.expo-audio's config plugin defaults it totrue, which would addUIBackgroundModes: [audio]to Info.plist plusFOREGROUND_SERVICE_MEDIA_PLAYBACKand a media service on Android — to an app that only records. expo-av declared none of that; an unused background-audio entitlement is an App Store review flag, so the default is overridden to preserve the previous native surface.f. 🚨 A blank page on the web export — an upstream ordering bug in
expo-video, found by RUNNING the export. After the port,npm run web:exportsucceeded and all 5 Playwright tests failed with "element(s) not found": the page was thenoscripttext, with one error before React ever mounted —expo-video@57's web build definesclass VideoPlayerWeb extends globalThis.expo.SharedObjectat module-evaluation time (build/VideoPlayer.web.js), and nothing in its own import graph reachesexpo-modules-core, whosesrc/index.tsis what runsinstallExpoGlobalPolyfill():Nor does this app's entry:
expo/AppEntry.js → registerRootComponent → Expo.fx.web → winter / async-require / rsc, none of which touchexpo-modules-core. Router-based Expo apps get the global for free from their entry, which is why upstream ships this.expo-audiois unaffected — itsindex.jsre-exports fromexpoon the first line.A side-effect
import "expo"above theexpo-videoimport installs it. That in turn pulled the whole Expo runtime into vitest, whereexpo/src/async-require/setup.tsreads__DEV__(a Metro-transform define) and killed 10 of 16 suites — so the test tree aliasesexpoto a stub that installs the same global with a usableSharedObjectbase, keeping the reason for the import visible instead of aliasing it to nothing.This is exactly the failure that
typecheck+buildcannot see. It typechecks perfectly and bundles without a warning.3. Holdbacks retired (the migration's other half)
package.jsonand.github/dependabot.ymlboth carried holdbacks whose stated reason was the SDK 52 pin, with an explicit instruction to lift them as part of this migration. Each was re-measured on SDK 57, not assumed:typescript→ 7.0.2 — RETIRED. The blocker was@expo/cliSDK 52 reading tsconfig through the classic TS compiler API that TS 7 deleted. SDK 57's CLI does not:expo export --platform webruns clean on a cold Metro cache (503 modules) under 7.0.2, and Playwright is 5/5 on the resulting bundle. Two tsconfig changes belong to TS 7 itself:baseUrlis removed (TS5102 — everypathsentry here is already relative, which is whatbaseUrl: "."meant), and@typesis no longer auto-included, sotypes: ["node"]+ a declared@types/node— the same fix, for the same TS2591, on the same kind of file, thatclients/reactalready carries. This puts the RN package on the same TypeScript as the other six client workspaces.react/@types/reactmajors — kept, reason rewritten. RN 0.76.5's hardreact ^18.2.0peer is gone (0.86.2 peersreact ^19.2.3), but react's major here is still whateverreact-nativepeers, so the coupling outlives the version.react-native/react-native-web/expo*/@expo*majors — unchanged. That coupling is the whole reason this directory has its own dependabot config.tsconfigReact-identity dedupe survives untouched, exactly as Finish the Expo SDK migration for clients/react-native: RN 0.76.5 → ~0.82 and expo-av → expo-audio/expo-video (currently held back at SDK 52) #1584's own comment predicted: both packages are on React 19 now and it is still required, because the problem is twonode_modulestrees sharing source, not a version skew.CI Node 20 → 22 for both RN jobs:
react-native0.86 andmetrodeclareengines.node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0. Node 20 clears that floor only on its final patches and went EOL 2026-04-30, sonode-version: 20would leave the jobs depending onsetup-nodecontinuing to resolve a 20.19.4+ patch of an unsupported line. Thereactjob has been on 22 all along.4. Verified here
After a clean
rm -rf node_modules && npm ci(so the lockfile is proven, not just the working tree):npm ci--legacy-peer-deps, nooverridesentry, no forced peernpx tsc --noEmit(TS 7.0.2)npx vitest runexpo export --platform web(cold cache)npm run e2e(Playwright, real Chromium)clients/react: typecheck / test / buildciTrigger.test.ts)…and on CI: the
Clientsworkflow ran green on all 8 jobs, with both RN jobs verified from the job LOG rather than the colour —RN connectorshows16 passed (16) / 161 passed (161)on Node 22,RN web (Playwright)showsWeb Bundled … (505 modules)and5 passed.Review findings, both fixed (0f96653)
Copilot raised two, both correct:
Videoposter'sstartedflag was sticky:useVideoPlayermemoizes on the source, so a control whoseSrcchanged in place got a new player whilestartedstayedtrue, and the new video's poster never appeared. Reset with the player now. The regression test was verified non-vacuous — removesetStarted(false)and it fails, restore it and it passes.The
Clientsworkflow will run on this diff — its path filter lists bothclients/**and.github/workflows/clients.yml, and this PR touches both. Not a skip.5. 🚨 What I could NOT verify
Stated plainly, because an RN upgrade that compiles in CI and breaks on device is worse than one that is honestly reported as unverified.
No native build was performed at all. No
expo prebuild, nopod install, no Xcode or Gradle build, no simulator, no device.ios/andandroid/are gitignored, so nothing stale is committed — but nothing has been regenerated or built either. Concretely, this means:Microphone capture on iOS/Android is unverified.
expo-audiois a native module. The recording options map 1:1 onto the previous ones and the format contract is preserved by construction, but no recording was made and no audio was posted to/api/speech/transcribe. The first native build is where a wrongoutputFormatstring or a permission-plugin change would show up.Video playback on device is unverified. The poster overlay is pinned by unit tests against a mock player; a real
VideoPlayeremittingplayingChangehas never driven it.contentFit="contain"vsResizeMode.CONTAINis likewise a types-level equivalence, not an observed one.The New Architecture is unexercised.
app.jsonalready carriednewArchEnabled: true, so this is not a flag flip — but RN 0.86 makes it the only mode and the native side has never been built under it here. Six RN minors (0.76 → 0.86) also regenerate the native template;react-native-render-html@6andreact-native-fetch-apiin particular have not been run against 0.86 on a device.Only the web target was driven end to end.
react-native-webis what the Playwright job renders. Every native claim above rests on the type checker and on upstream's own version pairing.npx expo install --checkadvisories, unresolved on purpose: it expectsreact/react-dom19.2.3(we ship19.2.8, which satisfies RN 0.86.2's^19.2.3peer and matchesclients/react, so the two source-shared packages stay on one version), andtypescript ~6.0.3(TS 6 isbetaon npm;latestis 7.0.2, and all six other client workspaces are on^7.0.2).MeshWeaver.AI.Testis red on this branch, and it is a pre-existing trunk flake — evidenced, not waved through. Two runs, two different tests in that one suite, each an observable that never emitted:b54cd0bf)ThreadTokenUsageTest.CompletedRound_ProviderOmitsTotal_DerivesTotalFromInPlusOutObservableAssertionException: … within 20s … emitted nothing at all0f966534)ProbeHubCostTest.ValidateContentAgainstSchema_DoesNotBuildTheNodeControlPlaneThe second is the same test name that fails on
main— main runs 32564051012 and 32559747093 both die onProbeHubCostTest.ValidateContentAgainstSchema_DoesNotBuildTheNodeControlPlane, and 32562019976 on its siblingGetContentSchema_…. That is 3 of main's last 8 runs, all inMeshWeaver.AI.Test.And this PR's diff contains zero C# files — 16 files, all of them
clients/react-native/**plus.github/dependabot.ymland.github/workflows/clients.yml— so it has no path toMeshWeaver.AI.Testat all. Recorded here so the red is not mistaken for fallout from this change; it needs its own issue, not a fix in this PR.Recommended before this is trusted on a device: one
npx expo prebuild --clean+expo run:ios/expo run:android, then press-and-hold the chat mic once and play oneVideoleaf with a poster.Closes #1584.
🤖 Generated with Claude Code