Split typecheck into per-platform passes (ios, android, web)

The single native pass resolved [".ios", ".android", ".native", ""],
which models a platform that does not exist - on iOS Metro never falls
back to .android files and vice versa. Replace it with
tsconfig.check.ios.json and tsconfig.check.android.json, each mirroring
Metro's real resolution order for its platform, alongside the existing
web pass. pnpm typecheck now runs all three, and the lint workflow
matrix runs them as separate jobs.

The ambient check shims in src/platform are now excluded from passes
they aren't written for (previously the web shims were silently included
in the native pass via the "src" include glob), and a new Android-pass
shim pins react-native-device-attest to its platform-neutral base class,
whose Android variant omits the iOS-only getDeviceCheckToken static.

The Android pass surfaced a real bug: Referrer.getReferrerInfo() is
async on Android (the Play referrer API only exposes a promise) but sync
on iOS/web, and the shared callers used it synchronously - so on Android
the deepLink:referrerReceived metric fired with a Promise instead of
data. The API is now uniformly promise-returning and the callers await
it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BCuMKXWHuyGoNhTVAHBMyk
This commit is contained in:
Claude
2026-07-12 17:06:04 +00:00
committed by Samuel Newman
parent 3652b30811
commit 08eadd8149
13 changed files with 84 additions and 20 deletions
+2 -1
View File
@@ -21,7 +21,8 @@ jobs:
strategy:
fail-fast: false
matrix:
job: [lint, prettier, typecheck, 'typecheck:web']
job:
[lint, prettier, 'typecheck:ios', 'typecheck:android', 'typecheck:web']
steps:
- name: Check out Git repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
@@ -6,7 +6,15 @@ export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo
throw new NotImplementedError()
}
export function getReferrerInfo(): ReferrerInfo | null {
/*
* Promise-returning for parity with Android, whose native referrer API only
* exposes a promise.
*/
export function getReferrerInfo(): Promise<ReferrerInfo | null> {
return Promise.resolve(getReferrerInfoSync())
}
function getReferrerInfoSync(): ReferrerInfo | null {
const referrer = SharedPrefs.getString('referrer')
if (referrer) {
SharedPrefs.removeValue('referrer')
@@ -5,6 +5,6 @@ export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo
throw new NotImplementedError()
}
export function getReferrerInfo(): ReferrerInfo | null {
export function getReferrerInfo(): Promise<ReferrerInfo | null> {
throw new NotImplementedError()
}
@@ -7,7 +7,15 @@ export function getGooglePlayReferrerInfoAsync(): Promise<GooglePlayReferrerInfo
throw new NotImplementedError()
}
export function getReferrerInfo(): ReferrerInfo | null {
/*
* Promise-returning for parity with Android, whose native referrer API only
* exposes a promise.
*/
export function getReferrerInfo(): Promise<ReferrerInfo | null> {
return Promise.resolve(getReferrerInfoSync())
}
function getReferrerInfoSync(): ReferrerInfo | null {
if (
Platform.OS === 'web' &&
// for ssr
+3 -1
View File
@@ -62,7 +62,9 @@
"lint": "oxlint --quiet src modules",
"lint-native": "swiftlint ./modules && ktlint ./modules",
"lint-native:fix": "swiftlint --fix ./modules && ktlint --format ./modules",
"typecheck": "tsgo --project ./tsconfig.check.json",
"typecheck": "pnpm run typecheck:ios && pnpm run typecheck:android && pnpm run typecheck:web",
"typecheck:ios": "tsgo --project ./tsconfig.check.ios.json",
"typecheck:android": "tsgo --project ./tsconfig.check.android.json",
"typecheck:web": "tsgo --project ./tsconfig.check.web.json",
"e2e:mock-server": "cd dev-env && pnpm start",
"e2e:build": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios",
+9 -8
View File
@@ -1003,14 +1003,15 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
})
if (IS_WEB) {
const referrerInfo = Referrer.getReferrerInfo()
if (referrerInfo && referrerInfo.hostname !== 'bsky.app') {
ax.metric('deepLink:referrerReceived', {
to: window.location.href,
referrer: referrerInfo?.referrer,
hostname: referrerInfo?.hostname,
})
}
void Referrer.getReferrerInfo().then(referrerInfo => {
if (referrerInfo && referrerInfo.hostname !== 'bsky.app') {
ax.metric('deepLink:referrerReceived', {
to: window.location.href,
referrer: referrerInfo?.referrer,
hostname: referrerInfo?.hostname,
})
}
})
}
// temp, just testing
+1 -1
View File
@@ -40,7 +40,7 @@ export function useIntentHandler() {
await WebBrowser.dismissBrowser().catch(() => {})
}
const referrerInfo = Referrer.getReferrerInfo()
const referrerInfo = await Referrer.getReferrerInfo()
if (referrerInfo && referrerInfo.hostname !== 'bsky.app') {
ax.metric('deepLink:referrerReceived', {
to: url,
+13
View File
@@ -0,0 +1,13 @@
/*
* Used ONLY by the Android typecheck pass (tsconfig.check.android.json) - it
* is excluded from the other passes and has no runtime effect.
*
* react-native-device-attest's Android variant implements only the Play
* Integrity statics, so the iOS-only getDeviceCheckToken (whose app call
* site is IS_IOS-guarded) fails to resolve under .android module suffixes.
* Pin the module to its platform-neutral base class, which declares the
* full API surface.
*/
declare module 'react-native-device-attest' {
export {DeviceAttestBase as default} from 'react-native-device-attest/build/DeviceAttestBase'
}
+2 -2
View File
@@ -1,6 +1,6 @@
/*
* Used ONLY by the web typecheck pass (tsconfig.check.web.json) - it is not
* included by the main tsconfig, and it has no runtime effect. See
* Used ONLY by the web typecheck pass (tsconfig.check.web.json) - it is
* excluded from the other passes and has no runtime effect. See
* react-native-svg.web-check.d.ts for the full background on why some
* packages need to be pinned to their native declarations under
* `moduleSuffixes: [".web", ""]`.
+3 -3
View File
@@ -1,13 +1,13 @@
/*
* Used ONLY by the web typecheck pass (tsconfig.check.web.json) - it is not
* included by the main tsconfig, and it has no runtime effect.
* Used ONLY by the web typecheck pass (tsconfig.check.web.json) - it is
* excluded from the other passes and has no runtime effect.
*
* Under `moduleSuffixes: [".web", ""]`, react-native-svg's type entry
* resolves to its DOM-flavored `ReactNativeSVG.web.d.ts`, a different API
* surface (no SvgProps/PathProps, react-native-web style types) than the
* native one the app is written against. At runtime the web build accepts
* the same props, so this ambient declaration pins the package to its
* native declarations for one coherent type surface across both passes.
* native declarations for one coherent type surface across all passes.
*
* moduleSuffixes remaps even explicit `.d.ts` specifiers, so this mirrors
* the package's ReactNativeSVG.d.ts + elements.d.ts via deep module paths
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "./tsconfig.check.json",
"compilerOptions": {
/*
* Mirrors Metro's resolution order for platform=android.
*/
"moduleSuffixes": [".android", ".native", ""]
},
/*
* Narrows the base exclude so the *.android-check.d.ts ambient shims
* apply to this pass.
*/
"exclude": ["src/platform/*.web-check.d.ts"]
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.check.json",
"compilerOptions": {
/*
* Mirrors Metro's resolution order for platform=ios.
*/
"moduleSuffixes": [".ios", ".native", ""]
}
}
+9 -1
View File
@@ -1,4 +1,12 @@
{
"extends": "./tsconfig.json",
"include": ["src", "modules", "app.config.js"]
"include": ["src", "modules", "app.config.js"],
/*
* Ambient check shims apply only to the platform pass that includes them
* back (tsconfig.check.android.json, tsconfig.check.web.json).
*/
"exclude": [
"src/platform/*.android-check.d.ts",
"src/platform/*.web-check.d.ts"
]
}