From 31a91f99cd3508e7237e2f5ffe78ae083184cd13 Mon Sep 17 00:00:00 2001 From: Andrew Bierman <94939237+andrew-bierman@users.noreply.github.com> Date: Thu, 30 Apr 2026 12:38:50 -0600 Subject: [PATCH 01/24] =?UTF-8?q?=F0=9F=90=9B=20fix:=20disable=20Eden=20Tr?= =?UTF-8?q?eaty=20date=20reviver=20to=20preserve=20ISO=20strings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseDate:false prevents Eden Treaty's JSON reviver from silently converting date-like strings to Date objects, which broke Zod z.string().datetime() validation on API responses. --- packages/api-client/src/index.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts index 9dc1a14230..bef70a70e3 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -127,7 +127,13 @@ export function createApiClient(config: ApiClientConfig) { // Treaty only uses the callable form of `fetch`; the globalThis.fetch type // includes a `preconnect` method our wrapper doesn't need. Cast through // unknown to bridge the two shapes without pulling preconnect into scope. - return treaty(config.baseUrl, { fetcher: authFetcher as unknown as typeof fetch }).api; + // parseDate:false disables Eden Treaty's JSON reviver that silently converts + // date-like strings (ISO 8601, "YYYY-MM-DD HH:MM") to Date objects. Without + // this, every Zod z.string().datetime() field in API response schemas fails. + return treaty(config.baseUrl, { + fetcher: authFetcher as unknown as typeof fetch, + parseDate: false, + }).api; } export type ApiClient = ReturnType; From 0938f0f532f845a170f29c87e39119194cfe8c2b Mon Sep 17 00:00:00 2001 From: Andrew Bierman <94939237+andrew-bierman@users.noreply.github.com> Date: Thu, 30 Apr 2026 12:40:49 -0600 Subject: [PATCH 02/24] =?UTF-8?q?=F0=9F=94=A7=20feat(web):=20add=20Metro?= =?UTF-8?q?=20module=20stubs=20for=20native-only=20packages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redirects platform-incompatible imports to no-op web shims via Metro resolveRequest, so the web bundle never loads native code: - react-native-maps → placeholder View - @react-native-ai/llama + llama.rn → null stubs - @react-native-ai/apple → null stub - @react-native-async-storage/async-storage → SSR-safe localStorage wrapper - react-native-blob-util → no-op fs/config stub - expo-sqlite/kv-store → localStorage-backed SQLiteStorage shim (sync + async API parity; Legend State requires getItemSync) Also: wasm in assetExts, conditionNames restricted to CJS to prevent Metro picking up Jotai's ESM .mjs build which contains import.meta. --- apps/expo/metro.config.js | 55 ++++++- apps/expo/mocks/async-storage.ts | 63 ++++++++ apps/expo/mocks/expo-sqlite-kv-store.ts | 174 ++++++++++++++++++++++ apps/expo/mocks/react-native-ai-apple.ts | 1 + apps/expo/mocks/react-native-ai-llama.ts | 5 + apps/expo/mocks/react-native-blob-util.ts | 31 ++++ apps/expo/mocks/react-native-maps.ts | 22 +++ 7 files changed, 350 insertions(+), 1 deletion(-) create mode 100644 apps/expo/mocks/async-storage.ts create mode 100644 apps/expo/mocks/expo-sqlite-kv-store.ts create mode 100644 apps/expo/mocks/react-native-ai-apple.ts create mode 100644 apps/expo/mocks/react-native-ai-llama.ts create mode 100644 apps/expo/mocks/react-native-blob-util.ts create mode 100644 apps/expo/mocks/react-native-maps.ts diff --git a/apps/expo/metro.config.js b/apps/expo/metro.config.js index 5deea20121..c95f432233 100644 --- a/apps/expo/metro.config.js +++ b/apps/expo/metro.config.js @@ -1,9 +1,62 @@ // Learn more https://docs.expo.io/guides/customizing-metro -const { getSentryExpoConfig } = require('@sentry/react-native/metro'); // ensures unique Debug IDs get assigned to the generated bundles and source maps uploaded to Sentry [read more](https://docs.sentry.io/platforms/react-native/manual-setup/expo/#add-sentry-metro-plugin) +const path = require('node:path'); +const { getSentryExpoConfig } = require('@sentry/react-native/metro'); // ensures unique Debug IDs get assigned to the generated bundles and source maps uploaded to Sentry [read more](https://docs.sentry.io/platforms/react-native/manual-setup/expo/#add-sentry-metro-native-setup) const { withNativeWind } = require('nativewind/metro'); /** @type {import('expo/metro-config').MetroConfig} */ // eslint-disable-next-line no-undef const config = getSentryExpoConfig(__dirname); +config.resolver = { + ...config.resolver, + assetExts: [...(config.resolver?.assetExts ?? []), 'wasm'], + // Exclude the ESM "import" condition so packages like Jotai resolve to their + // CJS builds instead of .mjs files that contain import.meta (invalid in + // Metro's __d() CJS module wrapper). + unstable_conditionNames: ['require', 'default', 'react-native', 'browser'], +}; + +const originalResolveRequest = config.resolver?.resolveRequest; +config.resolver = { + ...config.resolver, + // biome-ignore lint/complexity/useMaxParams: Metro resolveRequest requires exactly 3 params + resolveRequest: (context, moduleName, platform) => { + if (platform === 'web' && moduleName === 'react-native-maps') { + return { filePath: path.join(__dirname, 'mocks/react-native-maps.ts'), type: 'sourceFile' }; + } + if ( + platform === 'web' && + (moduleName === '@react-native-ai/llama' || moduleName === 'llama.rn') + ) { + return { + filePath: path.join(__dirname, 'mocks/react-native-ai-llama.ts'), + type: 'sourceFile', + }; + } + if (platform === 'web' && moduleName === '@react-native-ai/apple') { + return { + filePath: path.join(__dirname, 'mocks/react-native-ai-apple.ts'), + type: 'sourceFile', + }; + } + if (platform === 'web' && moduleName === '@react-native-async-storage/async-storage') { + return { filePath: path.join(__dirname, 'mocks/async-storage.ts'), type: 'sourceFile' }; + } + if (platform === 'web' && moduleName === 'react-native-blob-util') { + return { + filePath: path.join(__dirname, 'mocks/react-native-blob-util.ts'), + type: 'sourceFile', + }; + } + if (platform === 'web' && moduleName === 'expo-sqlite/kv-store') { + return { + filePath: path.join(__dirname, 'mocks/expo-sqlite-kv-store.ts'), + type: 'sourceFile', + }; + } + if (originalResolveRequest) return originalResolveRequest(context, moduleName, platform); + return context.resolveRequest(context, moduleName, platform); + }, +}; + module.exports = withNativeWind(config, { input: './global.css', inlineRem: 16 }); diff --git a/apps/expo/mocks/async-storage.ts b/apps/expo/mocks/async-storage.ts new file mode 100644 index 0000000000..0e4893a3ed --- /dev/null +++ b/apps/expo/mocks/async-storage.ts @@ -0,0 +1,63 @@ +// SSR-safe async-storage shim for web — guards window.localStorage access +const isClient = typeof window !== 'undefined'; + +const storage: typeof import('@react-native-async-storage/async-storage').default = { + getItem: (key) => { + if (!isClient) return Promise.resolve(null); + return Promise.resolve(window.localStorage.getItem(key)); + }, + setItem: (key, value) => { + if (!isClient) return Promise.resolve(); + window.localStorage.setItem(key, value); + return Promise.resolve(); + }, + removeItem: (key) => { + if (!isClient) return Promise.resolve(); + window.localStorage.removeItem(key); + return Promise.resolve(); + }, + mergeItem: (key, value) => { + if (!isClient) return Promise.resolve(); + const existing = window.localStorage.getItem(key); + const merged = existing + ? JSON.stringify({ ...JSON.parse(existing), ...JSON.parse(value) }) + : value; + window.localStorage.setItem(key, merged); + return Promise.resolve(); + }, + clear: () => { + if (!isClient) return Promise.resolve(); + window.localStorage.clear(); + return Promise.resolve(); + }, + getAllKeys: () => { + if (!isClient) return Promise.resolve([]); + return Promise.resolve(Object.keys(window.localStorage)); + }, + multiGet: (keys) => { + if (!isClient) return Promise.resolve(keys.map((k) => [k, null])); + return Promise.resolve(keys.map((k) => [k, window.localStorage.getItem(k)])); + }, + multiSet: (pairs) => { + if (!isClient) return Promise.resolve(); + for (const [k, v] of pairs) window.localStorage.setItem(k, v); + return Promise.resolve(); + }, + multiRemove: (keys) => { + if (!isClient) return Promise.resolve(); + for (const k of keys) window.localStorage.removeItem(k); + return Promise.resolve(); + }, + multiMerge: (pairs) => { + if (!isClient) return Promise.resolve(); + for (const [k, v] of pairs) { + const existing = window.localStorage.getItem(k); + const merged = existing ? JSON.stringify({ ...JSON.parse(existing), ...JSON.parse(v) }) : v; + window.localStorage.setItem(k, merged); + } + return Promise.resolve(); + }, + flushGetRequests: () => {}, +}; + +export default storage; diff --git a/apps/expo/mocks/expo-sqlite-kv-store.ts b/apps/expo/mocks/expo-sqlite-kv-store.ts new file mode 100644 index 0000000000..86863cea1e --- /dev/null +++ b/apps/expo/mocks/expo-sqlite-kv-store.ts @@ -0,0 +1,174 @@ +type UpdateFn = (prevValue: string | null) => string; + +const PREFIX = '__kv__'; + +const isClient = typeof window !== 'undefined'; +const memFallback = new Map(); + +const rawGet = (key: string): string | null => + isClient ? window.localStorage.getItem(PREFIX + key) : (memFallback.get(key) ?? null); + +const rawSet = (key: string, value: string): void => { + if (isClient) window.localStorage.setItem(PREFIX + key, value); + else memFallback.set(key, value); +}; + +const rawRemove = (key: string): boolean => { + const had = rawGet(key) !== null; + if (isClient) window.localStorage.removeItem(PREFIX + key); + else memFallback.delete(key); + return had; +}; + +const rawKeys = (): string[] => { + if (!isClient) return Array.from(memFallback.keys()); + return Object.keys(window.localStorage) + .filter((k) => k.startsWith(PREFIX)) + .map((k) => k.slice(PREFIX.length)); +}; + +const deepMerge = ( + target: Record, + source: Record, +): Record => { + const out = { ...target }; + for (const key of Object.keys(source)) { + if ( + typeof source[key] === 'object' && + source[key] !== null && + typeof target[key] === 'object' && + target[key] !== null + ) { + out[key] = deepMerge( + target[key] as Record, + source[key] as Record, + ); + } else { + out[key] = source[key]; + } + } + return out; +}; + +class LocalStorageStorage { + getItemSync(key: string): string | null { + return rawGet(key); + } + + setItemSync(key: string, value: string | UpdateFn): void { + const v = typeof value === 'function' ? value(rawGet(key)) : value; + rawSet(key, v); + } + + removeItemSync(key: string): boolean { + return rawRemove(key); + } + + getAllKeysSync(): string[] { + return rawKeys(); + } + + clearSync(): boolean { + const keys = rawKeys(); + for (const k of keys) rawRemove(k); + return true; + } + + closeSync(): void {} + + getLengthSync(): number { + return rawKeys().length; + } + + getKeyByIndexSync(index: number): string | null { + return rawKeys()[index] ?? null; + } + + async getItemAsync(key: string): Promise { + return Promise.resolve(this.getItemSync(key)); + } + + async setItemAsync(key: string, value: string | UpdateFn): Promise { + this.setItemSync(key, value); + } + + async removeItemAsync(key: string): Promise { + return Promise.resolve(this.removeItemSync(key)); + } + + async getAllKeysAsync(): Promise { + return Promise.resolve(this.getAllKeysSync()); + } + + async clearAsync(): Promise { + return Promise.resolve(this.clearSync()); + } + + async closeAsync(): Promise {} + + async getLengthAsync(): Promise { + return Promise.resolve(this.getLengthSync()); + } + + async getKeyByIndexAsync(index: number): Promise { + return Promise.resolve(this.getKeyByIndexSync(index)); + } + + getItem(key: string): Promise { + return this.getItemAsync(key); + } + + setItem(key: string, value: string | UpdateFn): Promise { + return this.setItemAsync(key, value); + } + + removeItem(key: string): Promise { + return this.removeItemAsync(key).then(() => undefined); + } + + getAllKeys(): Promise { + return this.getAllKeysAsync(); + } + + clear(): Promise { + return this.clearAsync().then(() => undefined); + } + + close(): Promise { + return this.closeAsync(); + } + + async mergeItem(key: string, value: string): Promise { + const existing = this.getItemSync(key); + if (existing) { + try { + const merged = deepMerge(JSON.parse(existing), JSON.parse(value)); + rawSet(key, JSON.stringify(merged)); + } catch { + rawSet(key, value); + } + } else { + rawSet(key, value); + } + } + + async multiGet(keys: string[]): Promise<[string, string | null][]> { + return Promise.resolve(keys.map((k) => [k, this.getItemSync(k)])); + } + + async multiSet(pairs: [string, string][]): Promise { + for (const [k, v] of pairs) this.setItemSync(k, v); + } + + async multiRemove(keys: string[]): Promise { + for (const k of keys) this.removeItemSync(k); + } + + async multiMerge(pairs: [string, string][]): Promise { + for (const [k, v] of pairs) await this.mergeItem(k, v); + } +} + +export const AsyncStorage = new LocalStorageStorage(); +export const Storage = AsyncStorage; +export default AsyncStorage; diff --git a/apps/expo/mocks/react-native-ai-apple.ts b/apps/expo/mocks/react-native-ai-apple.ts new file mode 100644 index 0000000000..7646bbd17d --- /dev/null +++ b/apps/expo/mocks/react-native-ai-apple.ts @@ -0,0 +1 @@ +export default null; diff --git a/apps/expo/mocks/react-native-ai-llama.ts b/apps/expo/mocks/react-native-ai-llama.ts new file mode 100644 index 0000000000..f7360ed2e7 --- /dev/null +++ b/apps/expo/mocks/react-native-ai-llama.ts @@ -0,0 +1,5 @@ +export type LlamaLanguageModel = never; + +export const llama = { + languageModel: () => null, +}; diff --git a/apps/expo/mocks/react-native-blob-util.ts b/apps/expo/mocks/react-native-blob-util.ts new file mode 100644 index 0000000000..e5f8bbbfa6 --- /dev/null +++ b/apps/expo/mocks/react-native-blob-util.ts @@ -0,0 +1,31 @@ +const noop = () => Promise.resolve(); + +const RNBlobUtil = { + fs: { + dirs: { + DocumentDir: '', + CacheDir: '', + MainBundleDir: '', + MovieDir: '', + MusicDir: '', + PictureDir: '', + LibraryDir: '', + DCIMDir: '', + DownloadDir: '', + SDCardDir: '', + SDCardApplicationDir: '', + }, + exists: () => Promise.resolve(false), + stat: () => Promise.resolve(null), + unlink: noop, + mkdir: noop, + writeFile: noop, + readFile: () => Promise.resolve(''), + ls: () => Promise.resolve([]), + }, + config: () => ({ + fetch: () => Promise.resolve(null), + }), +}; + +export default RNBlobUtil; diff --git a/apps/expo/mocks/react-native-maps.ts b/apps/expo/mocks/react-native-maps.ts new file mode 100644 index 0000000000..ed35b25414 --- /dev/null +++ b/apps/expo/mocks/react-native-maps.ts @@ -0,0 +1,22 @@ +import React from 'react'; +import { View } from 'react-native'; + +const MapView = ({ + children, + style, + ...props +}: { + children?: React.ReactNode; + style?: unknown; + [key: string]: unknown; +}) => + React.createElement( + View, + { style: [{ backgroundColor: '#e0e0e0', flex: 1 }, style], ...props }, + children, + ); + +const Marker = () => null; + +export default MapView; +export { Marker }; From 0ced8c1ea9fd38cc0342a2d2f53fa34fc36a7f04 Mon Sep 17 00:00:00 2001 From: Andrew Bierman <94939237+andrew-bierman@users.noreply.github.com> Date: Thu, 30 Apr 2026 12:40:56 -0600 Subject: [PATCH 03/24] =?UTF-8?q?=F0=9F=94=A7=20config(web):=20switch=20Ex?= =?UTF-8?q?po=20web=20output=20to=20SPA=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit output: 'static' triggers SSR/static generation which fails on window references and async storage during prerender. 'single' ships one index.html with client-side routing — no SSR. --- apps/expo/app.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/expo/app.config.ts b/apps/expo/app.config.ts index bbefd2112f..6763009a7d 100644 --- a/apps/expo/app.config.ts +++ b/apps/expo/app.config.ts @@ -41,7 +41,7 @@ export default (): ExpoConfig => scheme: 'packrat', web: { bundler: 'metro', - output: 'static', + output: 'single', favicon: './assets/favicon.png', }, plugins: [ From 52294c9ce8639f98a4a668cd0ef6a4f07229864a Mon Sep 17 00:00:00 2001 From: Andrew Bierman <94939237+andrew-bierman@users.noreply.github.com> Date: Thu, 30 Apr 2026 12:41:26 -0600 Subject: [PATCH 04/24] =?UTF-8?q?=F0=9F=90=9B=20fix(web):=20dark=20mode=20?= =?UTF-8?q?class,=20logo=20size,=20NativeWind=20color=20overrides?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _layout: toggle html.classList 'dark' from isDarkColorScheme so NativeWind's darkMode:'class' config activates CSS dark variants - auth/index: set explicit style dimensions on logo Image — RN Web injects the PNG's native 1080×1080 dimensions as inline styles, overriding the h-8/w-8 NativeWind class - global.css: add @media screen overrides for NativeWind color utility classes using rgb(var()) syntax; NativeWind emits platformSelect() which browsers ignore, leaving all themed text black on dark bg --- apps/expo/app/_layout.tsx | 9 +++++- apps/expo/app/auth/index.tsx | 4 +++ apps/expo/global.css | 53 ++++++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 1 deletion(-) diff --git a/apps/expo/app/_layout.tsx b/apps/expo/app/_layout.tsx index af81e293c3..beb212a925 100644 --- a/apps/expo/app/_layout.tsx +++ b/apps/expo/app/_layout.tsx @@ -13,7 +13,7 @@ import { userStore } from 'expo-app/features/auth/store'; import { useColorScheme, useInitialAndroidBarSync } from 'expo-app/lib/hooks/useColorScheme'; import { Providers } from 'expo-app/providers'; import { NAV_THEME } from 'expo-app/theme'; -import { useRef } from 'react'; +import { useEffect, useRef } from 'react'; Sentry.init({ dsn: clientEnvs.EXPO_PUBLIC_SENTRY_DSN, @@ -41,6 +41,13 @@ function RootLayout() { const { colorScheme, isDarkColorScheme } = useColorScheme(); + // Sync NativeWind dark mode class to on web (darkMode: 'class' requires it) + useEffect(() => { + if (typeof document !== 'undefined') { + document.documentElement.classList.toggle('dark', isDarkColorScheme); + } + }, [isDarkColorScheme]); + return ( diff --git a/apps/expo/global.css b/apps/expo/global.css index cdb50e6e20..cfaca0ff4a 100644 --- a/apps/expo/global.css +++ b/apps/expo/global.css @@ -2,6 +2,59 @@ @tailwind components; @tailwind utilities; +/* Web: NativeWind emits platformSelect() for color classes which browsers can't parse. + These overrides use standard rgb(var()) syntax so web gets themed colors. */ +@media screen { + .text-foreground { + color: rgb(var(--foreground)); + } + .text-muted-foreground { + color: rgb(var(--muted-foreground)); + } + .text-card-foreground { + color: rgb(var(--card-foreground)); + } + .text-popover-foreground { + color: rgb(var(--popover-foreground)); + } + .text-primary-foreground { + color: rgb(var(--primary-foreground)); + } + .text-secondary-foreground { + color: rgb(var(--secondary-foreground)); + } + .text-accent-foreground { + color: rgb(var(--accent-foreground)); + } + .text-destructive-foreground { + color: rgb(var(--destructive-foreground)); + } + .bg-background { + background-color: rgb(var(--background)); + } + .bg-card { + background-color: rgb(var(--card)); + } + .bg-primary { + background-color: rgb(var(--primary)); + } + .bg-secondary { + background-color: rgb(var(--secondary)); + } + .bg-muted { + background-color: rgb(var(--muted)); + } + .bg-accent { + background-color: rgb(var(--accent)); + } + .bg-destructive { + background-color: rgb(var(--destructive)); + } + .border-border { + border-color: rgb(var(--border)); + } +} + @layer base { :root { --background: 242 242 247; From 9081fb49c9301979140c30da7f3f9a775f029af0 Mon Sep 17 00:00:00 2001 From: Andrew Bierman <94939237+andrew-bierman@users.noreply.github.com> Date: Thu, 30 Apr 2026 12:42:35 -0600 Subject: [PATCH 05/24] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(web):=20rep?= =?UTF-8?q?lace=20per-module=20if-chains=20with=20WEB=5FSTUBS=20lookup=20t?= =?UTF-8?q?able?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/expo/metro.config.js | 46 ++++++++++++--------------------------- 1 file changed, 14 insertions(+), 32 deletions(-) diff --git a/apps/expo/metro.config.js b/apps/expo/metro.config.js index c95f432233..debee2058c 100644 --- a/apps/expo/metro.config.js +++ b/apps/expo/metro.config.js @@ -16,43 +16,25 @@ config.resolver = { unstable_conditionNames: ['require', 'default', 'react-native', 'browser'], }; +// Native-only packages that need no-op web shims. +// Add new entries here when a package crashes on web. +const WEB_STUBS = { + 'react-native-maps': 'mocks/react-native-maps.ts', + 'react-native-blob-util': 'mocks/react-native-blob-util.ts', + '@react-native-async-storage/async-storage': 'mocks/async-storage.ts', + '@react-native-ai/llama': 'mocks/react-native-ai-llama.ts', + 'llama.rn': 'mocks/react-native-ai-llama.ts', + '@react-native-ai/apple': 'mocks/react-native-ai-apple.ts', + 'expo-sqlite/kv-store': 'mocks/expo-sqlite-kv-store.ts', +}; + const originalResolveRequest = config.resolver?.resolveRequest; config.resolver = { ...config.resolver, // biome-ignore lint/complexity/useMaxParams: Metro resolveRequest requires exactly 3 params resolveRequest: (context, moduleName, platform) => { - if (platform === 'web' && moduleName === 'react-native-maps') { - return { filePath: path.join(__dirname, 'mocks/react-native-maps.ts'), type: 'sourceFile' }; - } - if ( - platform === 'web' && - (moduleName === '@react-native-ai/llama' || moduleName === 'llama.rn') - ) { - return { - filePath: path.join(__dirname, 'mocks/react-native-ai-llama.ts'), - type: 'sourceFile', - }; - } - if (platform === 'web' && moduleName === '@react-native-ai/apple') { - return { - filePath: path.join(__dirname, 'mocks/react-native-ai-apple.ts'), - type: 'sourceFile', - }; - } - if (platform === 'web' && moduleName === '@react-native-async-storage/async-storage') { - return { filePath: path.join(__dirname, 'mocks/async-storage.ts'), type: 'sourceFile' }; - } - if (platform === 'web' && moduleName === 'react-native-blob-util') { - return { - filePath: path.join(__dirname, 'mocks/react-native-blob-util.ts'), - type: 'sourceFile', - }; - } - if (platform === 'web' && moduleName === 'expo-sqlite/kv-store') { - return { - filePath: path.join(__dirname, 'mocks/expo-sqlite-kv-store.ts'), - type: 'sourceFile', - }; + if (platform === 'web' && WEB_STUBS[moduleName]) { + return { filePath: path.join(__dirname, WEB_STUBS[moduleName]), type: 'sourceFile' }; } if (originalResolveRequest) return originalResolveRequest(context, moduleName, platform); return context.resolveRequest(context, moduleName, platform); From 18a3248ad44d5c2faf73fea77cb60990e0c8ce81 Mon Sep 17 00:00:00 2001 From: Andrew Bierman <94939237+andrew-bierman@users.noreply.github.com> Date: Thu, 30 Apr 2026 12:56:40 -0600 Subject: [PATCH 06/24] =?UTF-8?q?=F0=9F=90=9B=20fix:=20auth=20logo=20overs?= =?UTF-8?q?izing=20and=20catalog=20schema=20review=20field=20mismatch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add style prop to all 5 auth screen logo Images so they don't render at native PNG size (1080×1080) on web - Make CatalogItemSchema review fields nullable/optional: scraped data uses camelCase (userName) but schema expected snake_case (user_name), causing ZodError on every catalog page load; also relax title/text/date to handle sparse external catalog data --- apps/expo/app/auth/(create-account)/credentials.tsx | 4 ++++ apps/expo/app/auth/(create-account)/index.tsx | 4 ++++ apps/expo/app/auth/(login)/forgot-password.tsx | 4 ++++ apps/expo/app/auth/(login)/index.tsx | 4 ++++ apps/expo/app/auth/(login)/reset-password.tsx | 4 ++++ packages/api/src/schemas/catalog.ts | 10 ++++++---- 6 files changed, 26 insertions(+), 4 deletions(-) diff --git a/apps/expo/app/auth/(create-account)/credentials.tsx b/apps/expo/app/auth/(create-account)/credentials.tsx index aac8667bad..eb76cd297b 100644 --- a/apps/expo/app/auth/(create-account)/credentials.tsx +++ b/apps/expo/app/auth/(create-account)/credentials.tsx @@ -186,6 +186,10 @@ export default function CredentialsScreen() { diff --git a/apps/expo/app/auth/(create-account)/index.tsx b/apps/expo/app/auth/(create-account)/index.tsx index c3afb3f70a..54be7c7c61 100644 --- a/apps/expo/app/auth/(create-account)/index.tsx +++ b/apps/expo/app/auth/(create-account)/index.tsx @@ -66,6 +66,10 @@ export default function InfoScreen() { diff --git a/apps/expo/app/auth/(login)/forgot-password.tsx b/apps/expo/app/auth/(login)/forgot-password.tsx index 0a08340fde..2669c4b45c 100644 --- a/apps/expo/app/auth/(login)/forgot-password.tsx +++ b/apps/expo/app/auth/(login)/forgot-password.tsx @@ -86,6 +86,10 @@ export default function ForgotPasswordScreen() { diff --git a/apps/expo/app/auth/(login)/index.tsx b/apps/expo/app/auth/(login)/index.tsx index 18c0152b43..73d32671a5 100644 --- a/apps/expo/app/auth/(login)/index.tsx +++ b/apps/expo/app/auth/(login)/index.tsx @@ -95,6 +95,10 @@ export default function LoginScreen() { diff --git a/apps/expo/app/auth/(login)/reset-password.tsx b/apps/expo/app/auth/(login)/reset-password.tsx index 5f0b6b236a..078112bfe5 100644 --- a/apps/expo/app/auth/(login)/reset-password.tsx +++ b/apps/expo/app/auth/(login)/reset-password.tsx @@ -177,6 +177,10 @@ export default function ResetPasswordScreen() { diff --git a/packages/api/src/schemas/catalog.ts b/packages/api/src/schemas/catalog.ts index 1efda868b0..e90ea707ec 100644 --- a/packages/api/src/schemas/catalog.ts +++ b/packages/api/src/schemas/catalog.ts @@ -51,14 +51,16 @@ export const CatalogItemSchema = z.object({ reviews: z .array( z.object({ - user_name: z.string(), + user_name: z.string().nullable().optional(), + userName: z.string().nullable().optional(), user_avatar: z.string().nullable().optional(), + userAvatar: z.string().nullable().optional(), context: z.record(z.string(), z.string()).nullable().optional(), recommends: z.boolean().nullable().optional(), rating: z.number(), - title: z.string(), - text: z.string(), - date: z.string(), + title: z.string().nullable().optional(), + text: z.string().nullable().optional(), + date: z.string().nullable().optional(), images: z.array(z.string()).nullable().optional(), upvotes: z.number().nullable().optional(), downvotes: z.number().nullable().optional(), From 2d3d2aeee41c6947c48007ec26a23c4f8480f5c0 Mon Sep 17 00:00:00 2001 From: Andrew Bierman <94939237+andrew-bierman@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:09:50 -0600 Subject: [PATCH 07/24] =?UTF-8?q?=E2=9C=A8=20feat:=20enable=20EXPO=5FUNSTA?= =?UTF-8?q?BLE=5FWEB=5FMODAL=20for=20web=20modal=20presentation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add EXPO_UNSTABLE_WEB_MODAL=1 to start/web scripts so auth modals (login, create-account) render as real overlays on web (SDK 54 alpha) - Add unstable_settings anchor to auth layout for correct deep-link behavior with web modals --- apps/expo/app/auth/_layout.tsx | 4 ++++ apps/expo/package.json | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/expo/app/auth/_layout.tsx b/apps/expo/app/auth/_layout.tsx index 783d96c3dd..dc4a2981df 100644 --- a/apps/expo/app/auth/_layout.tsx +++ b/apps/expo/app/auth/_layout.tsx @@ -3,6 +3,10 @@ import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { router, Stack } from 'expo-router'; import { Platform } from 'react-native'; +export const unstable_settings = { + anchor: 'index', +}; + export default function AuthLayout() { const { t } = useTranslation(); diff --git a/apps/expo/package.json b/apps/expo/package.json index fbcdd64bbd..a460598020 100644 --- a/apps/expo/package.json +++ b/apps/expo/package.json @@ -30,7 +30,7 @@ "format": "biome format --write", "ios": "APP_VARIANT=development expo run:ios", "lint": "biome check --write", - "start": "APP_VARIANT=development expo start", + "start": "APP_VARIANT=development EXPO_UNSTABLE_WEB_MODAL=1 expo start", "submit:android": "eas submit --platform android", "submit:ios": "eas submit --platform ios", "test": "vitest run", @@ -38,7 +38,7 @@ "update:development": "APP_VARIANT=development eas update --branch development --environment development", "update:preview": "APP_VARIANT=preview eas update --branch preview --environment preview", "update:production": "eas update --branch production --environment production", - "web": "expo start --web" + "web": "EXPO_UNSTABLE_WEB_MODAL=1 expo start --web" }, "eslintConfig": { "extends": "universe/native", From 25fea47269fd5de8155ff1f5fb4d0dbb05d27caf Mon Sep 17 00:00:00 2001 From: Andrew Bierman <94939237+andrew-bierman@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:27:56 -0600 Subject: [PATCH 08/24] fix(catalog): normalize Details:[...] array description format from scraped data --- .../catalog/components/CatalogItemCard.tsx | 3 ++- .../features/catalog/lib/normalizeDescription.ts | 15 +++++++++++++++ .../catalog/screens/CatalogItemDetailScreen.tsx | 3 ++- 3 files changed, 19 insertions(+), 2 deletions(-) create mode 100644 apps/expo/features/catalog/lib/normalizeDescription.ts diff --git a/apps/expo/features/catalog/components/CatalogItemCard.tsx b/apps/expo/features/catalog/components/CatalogItemCard.tsx index d1124d0b59..0f87ec374f 100644 --- a/apps/expo/features/catalog/components/CatalogItemCard.tsx +++ b/apps/expo/features/catalog/components/CatalogItemCard.tsx @@ -11,6 +11,7 @@ import { Icon } from 'expo-app/components/Icon'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { TouchableWithoutFeedback, View } from 'react-native'; +import { normalizeDescription } from '../lib/normalizeDescription'; import type { CatalogItem } from '../types'; import { CatalogItemImage } from './CatalogItemImage'; @@ -52,7 +53,7 @@ export function CatalogItemCard({ item, onPress }: CatalogItemCardProps) { {item.brand && {item.brand}} - {item.description} + {normalizeDescription(item.description)} diff --git a/apps/expo/features/catalog/lib/normalizeDescription.ts b/apps/expo/features/catalog/lib/normalizeDescription.ts new file mode 100644 index 0000000000..cb2c43aa8c --- /dev/null +++ b/apps/expo/features/catalog/lib/normalizeDescription.ts @@ -0,0 +1,15 @@ +const DETAILS_ARRAY_RE = /^Details:\s*(\[[\s\S]*\])$/; + +export function normalizeDescription(description: string | null | undefined): string | null { + if (!description) return null; + const match = description.match(DETAILS_ARRAY_RE); + if (match) { + try { + const items = JSON.parse(match[1]) as string[]; + return items.join('. '); + } catch { + // fall through + } + } + return description; +} diff --git a/apps/expo/features/catalog/screens/CatalogItemDetailScreen.tsx b/apps/expo/features/catalog/screens/CatalogItemDetailScreen.tsx index a67061fdab..aa0ee23943 100644 --- a/apps/expo/features/catalog/screens/CatalogItemDetailScreen.tsx +++ b/apps/expo/features/catalog/screens/CatalogItemDetailScreen.tsx @@ -18,6 +18,7 @@ import { Linking, Text as RNText, ScrollView, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { CatalogItemImage } from '../components/CatalogItemImage'; import { useCatalogItemDetails } from '../hooks'; +import { normalizeDescription } from '../lib/normalizeDescription'; export function CatalogItemDetailScreen() { const router = useRouter(); @@ -109,7 +110,7 @@ export function CatalogItemDetailScreen() { )} - {item.description} + {normalizeDescription(item.description)} From c982c54b41fa3ed17a5713fa9a1e4f555d127cd6 Mon Sep 17 00:00:00 2001 From: Andrew Bierman <94939237+andrew-bierman@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:37:32 -0600 Subject: [PATCH 09/24] =?UTF-8?q?fix:=20web=20compatibility=20=E2=80=94=20?= =?UTF-8?q?logout=20crash=20and=20route=20name=20mismatches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Guard ImageCacheManager.clearCache() with Platform.OS !== 'web' to prevent expo-file-system crash on logout - Remove non-existent catalog/index Stack.Screen entry - Fix current-pack and weight-analysis Stack.Screen names to match actual dynamic route files ([id] suffix) --- apps/expo/app/(app)/_layout.tsx | 5 ++--- apps/expo/lib/utils/ImageCacheManager.ts | 2 ++ 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/expo/app/(app)/_layout.tsx b/apps/expo/app/(app)/_layout.tsx index b3897ef208..8edb1ab06c 100644 --- a/apps/expo/app/(app)/_layout.tsx +++ b/apps/expo/app/(app)/_layout.tsx @@ -73,7 +73,6 @@ export default function AppLayout() { options={getCatalogAddToPackItemDetailsOptions(t)} /> - { + if (Platform.OS === 'web') return; const dirInfo = await FileSystem.getInfoAsync(this.cacheDirectory); if (dirInfo.exists) { await FileSystem.deleteAsync(this.cacheDirectory); From bfede613cbd37090dedf3a124f321bf530bf0369 Mon Sep 17 00:00:00 2001 From: Andrew Bierman <94939237+andrew-bierman@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:37:45 -0600 Subject: [PATCH 10/24] chore: remove unused getCatalogListOptions function --- apps/expo/app/(app)/_layout.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/apps/expo/app/(app)/_layout.tsx b/apps/expo/app/(app)/_layout.tsx index 8edb1ab06c..c6b2be62fb 100644 --- a/apps/expo/app/(app)/_layout.tsx +++ b/apps/expo/app/(app)/_layout.tsx @@ -350,12 +350,6 @@ const getItemEditOptions = (t: TranslationFunction) => title: t('common.edit'), }) as const; -const getCatalogListOptions = (t: TranslationFunction) => - ({ - title: t('catalog.itemsCatalog'), - headerLargeTitle: true, - }) as const; - const getCatalogItemDetailOptions = (t: TranslationFunction) => ({ title: t('items.itemDetails'), From bac123b1b9058fca52ff528b27dd48dfbabc86d1 Mon Sep 17 00:00:00 2001 From: Andrew Bierman <94939237+andrew-bierman@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:46:03 -0600 Subject: [PATCH 11/24] =?UTF-8?q?fix:=20web-safe=20token=20atom=20storage?= =?UTF-8?q?=20=E2=80=94=20prevent=20AI=20chat=20401?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SQLite sync API (getItemSync/setItemSync) requires SharedArrayBuffer, which needs COEP/COOP headers not present in Expo's web dev server. Without those headers, atomWithStorage's onMount fires getItemSync → throws → atom wiped to null → AI chat sends 'Bearer null' → 401. Fix: - authAtoms: use a module-level write-through cache on web so getItem() always returns the last value written in the session (sync, no SharedArrayBuffer needed). Native path is unchanged. - useAuthInit: after async token read succeeds on init, hydrate tokenAtom via store.set() so the cache is populated on page refresh too. --- apps/expo/features/auth/atoms/authAtoms.ts | 59 ++++++++++++++------ apps/expo/features/auth/hooks/useAuthInit.ts | 9 ++- 2 files changed, 50 insertions(+), 18 deletions(-) diff --git a/apps/expo/features/auth/atoms/authAtoms.ts b/apps/expo/features/auth/atoms/authAtoms.ts index 2bb10c8d05..737a3e4ba0 100644 --- a/apps/expo/features/auth/atoms/authAtoms.ts +++ b/apps/expo/features/auth/atoms/authAtoms.ts @@ -1,6 +1,7 @@ import Storage from 'expo-sqlite/kv-store'; import { atom } from 'jotai'; import { atomWithStorage } from 'jotai/utils'; +import { Platform } from 'react-native'; // User type definition export type User = { @@ -11,24 +12,48 @@ export type User = { emailVerified: boolean; }; +// On web, SQLite sync methods require SharedArrayBuffer (COEP/COOP headers). +// Without those headers getItemSync/setItemSync throw, which wipes in-memory +// atom state on onMount. We keep a module-level write-through cache on web so +// getItem() always returns the value that was last written in this session. +const webCache = new Map(); + +function makeKvStorage() { + return { + getItem: (_key: string): string | null => { + if (Platform.OS === 'web') return webCache.get(_key) ?? null; + return Storage.getItemSync(_key); + }, + setItem: (_key: string, value: string | null) => { + if (Platform.OS === 'web') { + webCache.set(_key, value); + // Persist async so the value survives a page refresh via Storage.getItem + if (value === null) void Storage.removeItem(_key); + else void Storage.setItem(_key, value); + return; + } + if (value === null) return Storage.removeItemSync(_key); + return Storage.setItemSync(_key, value); + }, + removeItem: (_key: string) => { + if (Platform.OS === 'web') { + webCache.delete(_key); + void Storage.removeItem(_key); + return; + } + return Storage.removeItemSync(_key); + }, + }; +} + // Token storage atom -export const tokenAtom = atomWithStorage('access_token', null, { - getItem: (key) => Storage.getItemSync(key), - setItem: (key, value) => { - if (value === null) return Storage.removeItemSync(key); - return Storage.setItemSync(key, value); - }, - removeItem: (key) => Storage.removeItemSync(key), -}); - -export const refreshTokenAtom = atomWithStorage('refresh_token', null, { - getItem: (key) => Storage.getItemSync(key), - setItem: (key, value) => { - if (value === null) return Storage.removeItemSync(key); - return Storage.setItemSync(key, value); - }, - removeItem: (key) => Storage.removeItemSync(key), -}); +export const tokenAtom = atomWithStorage('access_token', null, makeKvStorage()); + +export const refreshTokenAtom = atomWithStorage( + 'refresh_token', + null, + makeKvStorage(), +); // Loading state atom export const isLoadingAtom = atom(false); diff --git a/apps/expo/features/auth/hooks/useAuthInit.ts b/apps/expo/features/auth/hooks/useAuthInit.ts index 5ec2ba1473..aa790aec35 100644 --- a/apps/expo/features/auth/hooks/useAuthInit.ts +++ b/apps/expo/features/auth/hooks/useAuthInit.ts @@ -1,10 +1,12 @@ import { clientEnvs } from '@packrat/env/expo-client'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { GoogleSignin } from '@react-native-google-signin/google-signin'; +import { store } from 'expo-app/atoms/store'; import { router } from 'expo-router'; import Storage from 'expo-sqlite/kv-store'; import { useEffect, useState } from 'react'; import { Platform } from 'react-native'; +import { tokenAtom } from '../atoms/authAtoms'; import { isAuthed } from '../store'; export function useAuthInit() { @@ -37,7 +39,12 @@ export function useAuthInit() { // If user has session or hasSkippedLogin before, continue to app if (accessToken || hasSkippedLogin === 'true') { - if (accessToken) isAuthed.set(true); + if (accessToken) { + isAuthed.set(true); + // Hydrate tokenAtom so components (e.g. AI chat) get the correct + // token without relying on the sync SQLite read (unavailable on web). + store.set(tokenAtom, accessToken); + } setIsLoading(false); return; } else { From fbfda4b7d65737e72a057840e8cbeaf20029b83e Mon Sep 17 00:00:00 2001 From: Andrew Bierman <94939237+andrew-bierman@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:49:29 -0600 Subject: [PATCH 12/24] fix: use isFunction guard in expo-sqlite-kv-store mock --- apps/expo/mocks/expo-sqlite-kv-store.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/expo/mocks/expo-sqlite-kv-store.ts b/apps/expo/mocks/expo-sqlite-kv-store.ts index 86863cea1e..ea7305a655 100644 --- a/apps/expo/mocks/expo-sqlite-kv-store.ts +++ b/apps/expo/mocks/expo-sqlite-kv-store.ts @@ -1,3 +1,5 @@ +import { isFunction } from '@packrat/guards'; + type UpdateFn = (prevValue: string | null) => string; const PREFIX = '__kv__'; @@ -56,7 +58,7 @@ class LocalStorageStorage { } setItemSync(key: string, value: string | UpdateFn): void { - const v = typeof value === 'function' ? value(rawGet(key)) : value; + const v = isFunction(value) ? value(rawGet(key)) : value; rawSet(key, v); } From b4665068c3ae2100ab62b978ed55cf5b1a2f10a2 Mon Sep 17 00:00:00 2001 From: Andrew Bierman <94939237+andrew-bierman@users.noreply.github.com> Date: Thu, 30 Apr 2026 21:17:25 -0600 Subject: [PATCH 13/24] feat(web): proper platform-specific implementations for native-only libraries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces global mocks with real web alternatives where possible: - lib/persist-plugin.ts/.web.ts: platform abstraction for Legend-State persistence. Native uses observablePersistSqlite (SQLite); web uses observablePersistAsyncStorage backed by our localStorage mock. All 9 store files updated to import from this abstraction. - mocks/react-native-keyboard-controller.tsx: proper web shim — KeyboardAwareScrollView → ScrollView, KeyboardStickyView → View, KeyboardProvider renders children, useReanimatedKeyboardAnimation returns a static shared value (keyboard doesn't overlay on web). - mocks/google-signin.ts: Google Sign-In is native-only; web users sign in with email/password. - mocks/datetimepicker.tsx: native element for @react-native-community/datetimepicker on web. - metro.config.js: wire keyboard-controller, google-signin, and datetimepicker into WEB_STUBS. --- apps/expo/features/auth/store/user.ts | 5 +- .../pack-templates/store/packTemplateItems.ts | 5 +- .../pack-templates/store/packTemplates.ts | 5 +- apps/expo/features/packs/store/packItems.ts | 5 +- .../features/packs/store/packWeightHistory.ts | 5 +- apps/expo/features/packs/store/packingMode.ts | 5 +- apps/expo/features/packs/store/packs.ts | 5 +- .../store/trailConditionReports.ts | 5 +- apps/expo/features/trips/store/trips.ts | 5 +- apps/expo/lib/persist-plugin.ts | 4 ++ apps/expo/lib/persist-plugin.web.ts | 7 +++ apps/expo/metro.config.js | 7 ++- apps/expo/mocks/datetimepicker.tsx | 41 ++++++++++++++++ apps/expo/mocks/google-signin.ts | 20 ++++++++ .../react-native-keyboard-controller.tsx | 47 +++++++++++++++++++ 15 files changed, 143 insertions(+), 28 deletions(-) create mode 100644 apps/expo/lib/persist-plugin.ts create mode 100644 apps/expo/lib/persist-plugin.web.ts create mode 100644 apps/expo/mocks/datetimepicker.tsx create mode 100644 apps/expo/mocks/google-signin.ts create mode 100644 apps/expo/mocks/react-native-keyboard-controller.tsx diff --git a/apps/expo/features/auth/store/user.ts b/apps/expo/features/auth/store/user.ts index 2fd01d5ec8..7991b3f0b3 100644 --- a/apps/expo/features/auth/store/user.ts +++ b/apps/expo/features/auth/store/user.ts @@ -1,9 +1,8 @@ import { observable, syncState } from '@legendapp/state'; -import { observablePersistSqlite } from '@legendapp/state/persist-plugins/expo-sqlite'; import { syncObservable } from '@legendapp/state/sync'; import { syncedCrud } from '@legendapp/state/sync-plugins/crud'; import type { User } from 'expo-app/features/profile/types'; -import Storage from 'expo-sqlite/kv-store'; +import { persistPlugin } from 'expo-app/lib/persist-plugin'; export const userStore = observable(null); @@ -12,7 +11,7 @@ syncObservable( syncedCrud({ persist: { name: 'user', - plugin: observablePersistSqlite(Storage), + plugin: persistPlugin, }, }), ); diff --git a/apps/expo/features/pack-templates/store/packTemplateItems.ts b/apps/expo/features/pack-templates/store/packTemplateItems.ts index 239c556c6c..f942a5e6f4 100644 --- a/apps/expo/features/pack-templates/store/packTemplateItems.ts +++ b/apps/expo/features/pack-templates/store/packTemplateItems.ts @@ -1,5 +1,4 @@ import { observable, syncState } from '@legendapp/state'; -import { observablePersistSqlite } from '@legendapp/state/persist-plugins/expo-sqlite'; import { syncObservable } from '@legendapp/state/sync'; import { syncedCrud } from '@legendapp/state/sync-plugins/crud'; import { @@ -8,7 +7,7 @@ import { } from '@packrat/api/schemas/packTemplates'; import { isAuthed } from 'expo-app/features/auth/store'; import { apiClient } from 'expo-app/lib/api/packrat'; -import Storage from 'expo-sqlite/kv-store'; +import { persistPlugin } from 'expo-app/lib/persist-plugin'; import type { PackTemplateItem } from '../types'; const listAllPackTemplateItems = async (): Promise => { @@ -84,7 +83,7 @@ syncObservable( updatePartial: true, mode: 'merge', persist: { - plugin: observablePersistSqlite(Storage), + plugin: persistPlugin, retrySync: true, name: 'packTemplateItems', }, diff --git a/apps/expo/features/pack-templates/store/packTemplates.ts b/apps/expo/features/pack-templates/store/packTemplates.ts index b2570c76ce..8e6afbf641 100644 --- a/apps/expo/features/pack-templates/store/packTemplates.ts +++ b/apps/expo/features/pack-templates/store/packTemplates.ts @@ -1,5 +1,4 @@ import { observable, syncState } from '@legendapp/state'; -import { observablePersistSqlite } from '@legendapp/state/persist-plugins/expo-sqlite'; import { syncObservable } from '@legendapp/state/sync'; import { syncedCrud } from '@legendapp/state/sync-plugins/crud'; import { @@ -8,7 +7,7 @@ import { } from '@packrat/api/schemas/packTemplates'; import { isAuthed } from 'expo-app/features/auth/store'; import { apiClient } from 'expo-app/lib/api/packrat'; -import Storage from 'expo-sqlite/kv-store'; +import { persistPlugin } from 'expo-app/lib/persist-plugin'; import type { PackTemplate, PackTemplateInStore } from '../types'; const listPackTemplates = async (): Promise => { @@ -73,7 +72,7 @@ syncObservable( fieldDeleted: 'deleted', mode: 'merge', persist: { - plugin: observablePersistSqlite(Storage), + plugin: persistPlugin, retrySync: true, name: 'packTemplates', }, diff --git a/apps/expo/features/packs/store/packItems.ts b/apps/expo/features/packs/store/packItems.ts index 1f4fe4ab96..e8339a567f 100644 --- a/apps/expo/features/packs/store/packItems.ts +++ b/apps/expo/features/packs/store/packItems.ts @@ -1,13 +1,12 @@ import { observable, syncState } from '@legendapp/state'; -import { observablePersistSqlite } from '@legendapp/state/persist-plugins/expo-sqlite'; import { syncObservable } from '@legendapp/state/sync'; import { syncedCrud } from '@legendapp/state/sync-plugins/crud'; import { PackItemSchema, PackWithWeightsSchema } from '@packrat/api/schemas/packs'; import { isRemoteUrl } from '@packrat/guards'; import { isAuthed } from 'expo-app/features/auth/store'; import { apiClient } from 'expo-app/lib/api/packrat'; +import { persistPlugin } from 'expo-app/lib/persist-plugin'; import ImageCacheManager from 'expo-app/lib/utils/ImageCacheManager'; -import Storage from 'expo-sqlite/kv-store'; import type { PackItem } from '../types'; import { uploadImage } from '../utils'; @@ -55,7 +54,7 @@ syncObservable( updatePartial: true, mode: 'merge', persist: { - plugin: observablePersistSqlite(Storage), + plugin: persistPlugin, retrySync: true, name: 'packItems', }, diff --git a/apps/expo/features/packs/store/packWeightHistory.ts b/apps/expo/features/packs/store/packWeightHistory.ts index cbf760e1f5..75dc2f32a1 100644 --- a/apps/expo/features/packs/store/packWeightHistory.ts +++ b/apps/expo/features/packs/store/packWeightHistory.ts @@ -1,12 +1,11 @@ import { observable, syncState } from '@legendapp/state'; -import { observablePersistSqlite } from '@legendapp/state/persist-plugins/expo-sqlite'; import { syncObservable } from '@legendapp/state/sync'; import { syncedCrud } from '@legendapp/state/sync-plugins/crud'; import { PackWeightHistoryResponseSchema } from '@packrat/api/schemas/packs'; import { isAuthed } from 'expo-app/features/auth/store'; import { apiClient } from 'expo-app/lib/api/packrat'; +import { persistPlugin } from 'expo-app/lib/persist-plugin'; import { obs } from 'expo-app/lib/store'; -import Storage from 'expo-sqlite/kv-store'; import { nanoid } from 'nanoid'; import type { PackWeightHistoryEntry } from '../types'; import { computePackWeights } from '../utils'; @@ -40,7 +39,7 @@ syncObservable( fieldCreatedAt: 'createdAt', mode: 'merge', persist: { - plugin: observablePersistSqlite(Storage), + plugin: persistPlugin, retrySync: true, name: 'packWeigthHistory', }, diff --git a/apps/expo/features/packs/store/packingMode.ts b/apps/expo/features/packs/store/packingMode.ts index d7e87a3d36..18ca1cb146 100644 --- a/apps/expo/features/packs/store/packingMode.ts +++ b/apps/expo/features/packs/store/packingMode.ts @@ -1,13 +1,12 @@ import { observable } from '@legendapp/state'; -import { observablePersistSqlite } from '@legendapp/state/persist-plugins/expo-sqlite'; import { syncObservable } from '@legendapp/state/sync'; -import Storage from 'expo-sqlite/kv-store'; +import { persistPlugin } from 'expo-app/lib/persist-plugin'; export const packingModeStore = observable>>({}); syncObservable(packingModeStore, { persist: { - plugin: observablePersistSqlite(Storage), + plugin: persistPlugin, retrySync: true, name: 'packingMode', }, diff --git a/apps/expo/features/packs/store/packs.ts b/apps/expo/features/packs/store/packs.ts index 96d132c06b..4cf21b0b8b 100644 --- a/apps/expo/features/packs/store/packs.ts +++ b/apps/expo/features/packs/store/packs.ts @@ -1,11 +1,10 @@ import { observable, syncState } from '@legendapp/state'; -import { observablePersistSqlite } from '@legendapp/state/persist-plugins/expo-sqlite'; import { syncObservable } from '@legendapp/state/sync'; import { syncedCrud } from '@legendapp/state/sync-plugins/crud'; import { PackWithWeightsSchema } from '@packrat/api/schemas/packs'; import { isAuthed } from 'expo-app/features/auth/store'; import { apiClient } from 'expo-app/lib/api/packrat'; -import Storage from 'expo-sqlite/kv-store'; +import { persistPlugin } from 'expo-app/lib/persist-plugin'; import type { PackInStore } from '../types'; const listPacks = async (): Promise => { @@ -58,7 +57,7 @@ syncObservable( fieldDeleted: 'deleted', mode: 'merge', persist: { - plugin: observablePersistSqlite(Storage), + plugin: persistPlugin, retrySync: true, name: 'packs', }, diff --git a/apps/expo/features/trail-conditions/store/trailConditionReports.ts b/apps/expo/features/trail-conditions/store/trailConditionReports.ts index 9152267dfb..0ddc8d5115 100644 --- a/apps/expo/features/trail-conditions/store/trailConditionReports.ts +++ b/apps/expo/features/trail-conditions/store/trailConditionReports.ts @@ -1,11 +1,10 @@ import { observable, syncState } from '@legendapp/state'; -import { observablePersistSqlite } from '@legendapp/state/persist-plugins/expo-sqlite'; import { syncObservable } from '@legendapp/state/sync'; import { syncedCrud } from '@legendapp/state/sync-plugins/crud'; import { TrailConditionReportSchema } from '@packrat/api/schemas/trailConditions'; import { isAuthed } from 'expo-app/features/auth/store'; import { apiClient } from 'expo-app/lib/api/packrat'; -import Storage from 'expo-sqlite/kv-store'; +import { persistPlugin } from 'expo-app/lib/persist-plugin'; import type { TrailConditionReportInStore } from '../types'; const listMyReports = async (_params: unknown, { lastSync }: { lastSync?: number } = {}) => { @@ -76,7 +75,7 @@ syncObservable( fieldDeleted: 'deleted', mode: 'merge', persist: { - plugin: observablePersistSqlite(Storage), + plugin: persistPlugin, retrySync: true, name: 'trail_condition_reports', }, diff --git a/apps/expo/features/trips/store/trips.ts b/apps/expo/features/trips/store/trips.ts index c375a1497d..6d428457d2 100644 --- a/apps/expo/features/trips/store/trips.ts +++ b/apps/expo/features/trips/store/trips.ts @@ -1,11 +1,10 @@ import { observable, syncState } from '@legendapp/state'; -import { observablePersistSqlite } from '@legendapp/state/persist-plugins/expo-sqlite'; import { syncObservable } from '@legendapp/state/sync'; import { syncedCrud } from '@legendapp/state/sync-plugins/crud'; import { TripSchema } from '@packrat/api/schemas/trips'; import { isAuthed } from 'expo-app/features/auth/store'; import { apiClient } from 'expo-app/lib/api/packrat'; -import Storage from 'expo-sqlite/kv-store'; +import { persistPlugin } from 'expo-app/lib/persist-plugin'; import type { TripInStore } from '../types'; const listTrips = async () => { @@ -61,7 +60,7 @@ syncObservable( fieldDeleted: 'deleted', mode: 'merge', persist: { - plugin: observablePersistSqlite(Storage), + plugin: persistPlugin, retrySync: true, name: 'trips', }, diff --git a/apps/expo/lib/persist-plugin.ts b/apps/expo/lib/persist-plugin.ts new file mode 100644 index 0000000000..322561d52f --- /dev/null +++ b/apps/expo/lib/persist-plugin.ts @@ -0,0 +1,4 @@ +import { observablePersistSqlite } from '@legendapp/state/persist-plugins/expo-sqlite'; +import Storage from 'expo-sqlite/kv-store'; + +export const persistPlugin = observablePersistSqlite(Storage); diff --git a/apps/expo/lib/persist-plugin.web.ts b/apps/expo/lib/persist-plugin.web.ts new file mode 100644 index 0000000000..a92c6d1914 --- /dev/null +++ b/apps/expo/lib/persist-plugin.web.ts @@ -0,0 +1,7 @@ +import { observablePersistAsyncStorage } from '@legendapp/state/persist-plugins/async-storage'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +// On web, the expo-sqlite persist plugin requires SharedArrayBuffer (COEP/COOP +// headers). Use the AsyncStorage plugin instead, which falls through to our +// localStorage-backed mock via the metro web stub. +export const persistPlugin = observablePersistAsyncStorage({ AsyncStorage }); diff --git a/apps/expo/metro.config.js b/apps/expo/metro.config.js index debee2058c..9a876f6363 100644 --- a/apps/expo/metro.config.js +++ b/apps/expo/metro.config.js @@ -16,7 +16,7 @@ config.resolver = { unstable_conditionNames: ['require', 'default', 'react-native', 'browser'], }; -// Native-only packages that need no-op web shims. +// Native-only packages that need web shims. // Add new entries here when a package crashes on web. const WEB_STUBS = { 'react-native-maps': 'mocks/react-native-maps.ts', @@ -26,6 +26,11 @@ const WEB_STUBS = { 'llama.rn': 'mocks/react-native-ai-llama.ts', '@react-native-ai/apple': 'mocks/react-native-ai-apple.ts', 'expo-sqlite/kv-store': 'mocks/expo-sqlite-kv-store.ts', + // Keyboard utilities — on web the software keyboard doesn't overlay content + 'react-native-keyboard-controller': 'mocks/react-native-keyboard-controller.tsx', + // Google Sign-In and date picker are native-only; web uses password auth + '@react-native-google-signin/google-signin': 'mocks/google-signin.ts', + '@react-native-community/datetimepicker': 'mocks/datetimepicker.tsx', }; const originalResolveRequest = config.resolver?.resolveRequest; diff --git a/apps/expo/mocks/datetimepicker.tsx b/apps/expo/mocks/datetimepicker.tsx new file mode 100644 index 0000000000..82c77ac59c --- /dev/null +++ b/apps/expo/mocks/datetimepicker.tsx @@ -0,0 +1,41 @@ +// Web implementation for @react-native-community/datetimepicker. +// Uses a native element which the browser renders natively. +import type React from 'react'; + +type DateTimePickerEvent = { type: string; nativeEvent: { timestamp: number } }; + +type Props = { + value: Date; + mode?: 'date' | 'time' | 'datetime'; + display?: string; + onChange?: (event: DateTimePickerEvent, date?: Date) => void; + minimumDate?: Date; + maximumDate?: Date; +}; + +function toInputValue(date: Date, mode: string): string { + if (mode === 'time') { + return date.toTimeString().slice(0, 5); + } + return date.toISOString().split('T')[0] ?? ''; +} + +export default function DateTimePicker({ value, mode = 'date', onChange }: Props) { + const inputType = mode === 'time' ? 'time' : 'date'; + + function handleChange(e: React.ChangeEvent) { + if (!onChange) return; + const raw = e.target.value; + const date = mode === 'time' ? new Date(`1970-01-01T${raw}`) : new Date(raw); + onChange({ type: 'set', nativeEvent: { timestamp: date.getTime() } }, date); + } + + return ( + + ); +} diff --git a/apps/expo/mocks/google-signin.ts b/apps/expo/mocks/google-signin.ts new file mode 100644 index 0000000000..7401a3af81 --- /dev/null +++ b/apps/expo/mocks/google-signin.ts @@ -0,0 +1,20 @@ +// Web stub for @react-native-google-signin/google-signin. +// Google Sign-In is native-only; web users sign in with email/password. +export const GoogleSignin = { + configure: () => {}, + hasPlayServices: () => Promise.resolve(true), + signIn: () => Promise.reject(new Error('Google Sign-In is not supported on web')), + signOut: () => Promise.resolve(), + getTokens: () => Promise.reject(new Error('Google Sign-In is not supported on web')), + isSignedIn: () => false, + getCurrentUser: () => null, + revokeAccess: () => Promise.resolve(), +}; + +export const GoogleSigninButton = () => null; +export const statusCodes = { + SIGN_IN_CANCELLED: 'SIGN_IN_CANCELLED', + IN_PROGRESS: 'IN_PROGRESS', + PLAY_SERVICES_NOT_AVAILABLE: 'PLAY_SERVICES_NOT_AVAILABLE', + SIGN_IN_REQUIRED: 'SIGN_IN_REQUIRED', +}; diff --git a/apps/expo/mocks/react-native-keyboard-controller.tsx b/apps/expo/mocks/react-native-keyboard-controller.tsx new file mode 100644 index 0000000000..14f5547678 --- /dev/null +++ b/apps/expo/mocks/react-native-keyboard-controller.tsx @@ -0,0 +1,47 @@ +// Web stub for react-native-keyboard-controller. +// On web the software keyboard does not overlay content, so these wrappers +// fall through to their React Native equivalents. +import type React from 'react'; +import { KeyboardAvoidingView, ScrollView, View } from 'react-native'; +import type { SharedValue } from 'react-native-reanimated'; +import { useSharedValue } from 'react-native-reanimated'; + +export { KeyboardAvoidingView }; + +export function KeyboardProvider({ children }: { children: React.ReactNode }) { + return <>{children}; +} + +export function KeyboardAwareScrollView({ + children, + ...props +}: React.ComponentProps) { + return {children}; +} + +export function KeyboardStickyView({ + children, + ...props +}: { + children?: React.ReactNode; + offset?: { opened?: number; closed?: number }; + [key: string]: unknown; +}) { + return {children}; +} + +export function useReanimatedKeyboardAnimation(): { progress: SharedValue } { + const progress = useSharedValue(0); + return { progress }; +} + +export const KeyboardController = { + dismiss: () => {}, + setFocusTo: () => {}, + addListener: () => ({ remove: () => {} }), +}; + +export const AndroidSoftInputModes = {}; +export const KeyboardEvents = { + addListener: () => ({ remove: () => {} }), +}; From cb80fa59cef8cdd0e758054bd4405c4dbb94f953 Mon Sep 17 00:00:00 2001 From: Andrew Bierman <94939237+andrew-bierman@users.noreply.github.com> Date: Thu, 30 Apr 2026 21:33:17 -0600 Subject: [PATCH 14/24] feat(web): react-leaflet maps + expo-file-system web stub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace gray MapView placeholder with react-leaflet (OpenStreetMap tiles, CDN-injected leaflet CSS, Marker with Popup support) - Add expo-file-system/legacy WEB_STUB — prevents UnavailabilityError on all FileSystem calls (getInfoAsync, downloadAsync, uploadAsync, etc.) - Add leaflet, react-leaflet, @types/leaflet dependencies - expo-blur and expo-dev-client left unstubbed — both have native .web.js shims --- apps/expo/metro.config.js | 4 +- apps/expo/mocks/expo-file-system-legacy.ts | 71 +++++++++ apps/expo/mocks/react-native-maps.tsx | 161 +++++++++++++++++++++ apps/expo/package.json | 3 + bun.lock | 11 ++ 5 files changed, 249 insertions(+), 1 deletion(-) create mode 100644 apps/expo/mocks/expo-file-system-legacy.ts create mode 100644 apps/expo/mocks/react-native-maps.tsx diff --git a/apps/expo/metro.config.js b/apps/expo/metro.config.js index 9a876f6363..1276b83e67 100644 --- a/apps/expo/metro.config.js +++ b/apps/expo/metro.config.js @@ -19,7 +19,7 @@ config.resolver = { // Native-only packages that need web shims. // Add new entries here when a package crashes on web. const WEB_STUBS = { - 'react-native-maps': 'mocks/react-native-maps.ts', + 'react-native-maps': 'mocks/react-native-maps.tsx', 'react-native-blob-util': 'mocks/react-native-blob-util.ts', '@react-native-async-storage/async-storage': 'mocks/async-storage.ts', '@react-native-ai/llama': 'mocks/react-native-ai-llama.ts', @@ -31,6 +31,8 @@ const WEB_STUBS = { // Google Sign-In and date picker are native-only; web uses password auth '@react-native-google-signin/google-signin': 'mocks/google-signin.ts', '@react-native-community/datetimepicker': 'mocks/datetimepicker.tsx', + // expo-file-system throws UnavailabilityError on web; stub all ops as no-ops + 'expo-file-system/legacy': 'mocks/expo-file-system-legacy.ts', }; const originalResolveRequest = config.resolver?.resolveRequest; diff --git a/apps/expo/mocks/expo-file-system-legacy.ts b/apps/expo/mocks/expo-file-system-legacy.ts new file mode 100644 index 0000000000..4e9f32bead --- /dev/null +++ b/apps/expo/mocks/expo-file-system-legacy.ts @@ -0,0 +1,71 @@ +// Web stub for expo-file-system/legacy. +// File system APIs are native-only; all operations are no-ops on web. + +export const documentDirectory = ''; +export const cacheDirectory = ''; +export const bundleDirectory = ''; + +export const EncodingType = { + UTF8: 'utf8', + Base64: 'base64', +} as const; + +export const FileSystemUploadType = { + BINARY_CONTENT: 0, + MULTIPART: 1, +} as const; + +export const FileSystemSessionType = { + BACKGROUND: 0, + FOREGROUND: 1, +} as const; + +export async function getInfoAsync(_uri: string) { + return { exists: false, isDirectory: false, uri: _uri, size: 0, modificationTime: 0 }; +} + +export async function readAsStringAsync(_uri: string) { + return ''; +} + +export async function writeAsStringAsync(_uri: string, _contents: string) {} + +export async function deleteAsync(_uri: string) {} + +export async function moveAsync(_options: { from: string; to: string }) {} + +export async function copyAsync(_options: { from: string; to: string }) {} + +export async function makeDirectoryAsync(_uri: string, _options?: { intermediates?: boolean }) {} + +export async function readDirectoryAsync(_uri: string): Promise { + return []; +} + +// biome-ignore lint/complexity/useMaxParams: matches expo-file-system API signature +export async function downloadAsync( + _uri: string, + _fileUri: string, + _options?: object, +): Promise<{ status: number; uri: string; headers: Record; mimeType: string }> { + return { status: 200, uri: _fileUri, headers: {}, mimeType: '' }; +} + +// biome-ignore lint/complexity/useMaxParams: matches expo-file-system API signature +export async function uploadAsync( + _url: string, + _fileUri: string, + _options?: object, +): Promise<{ status: number; body: string; headers: Record }> { + console.warn('FileSystem.uploadAsync is not supported on web'); + return { status: 200, body: '', headers: {} }; +} + +export async function createDownloadResumable() { + return { + downloadAsync: async () => ({ status: 200, uri: '', headers: {}, mimeType: '' }), + pauseAsync: async () => {}, + resumeAsync: async () => {}, + savable: () => ({ url: '', fileUri: '', options: {}, resumeData: '' }), + }; +} diff --git a/apps/expo/mocks/react-native-maps.tsx b/apps/expo/mocks/react-native-maps.tsx new file mode 100644 index 0000000000..fff97a6ea8 --- /dev/null +++ b/apps/expo/mocks/react-native-maps.tsx @@ -0,0 +1,161 @@ +// Web implementation for react-native-maps using react-leaflet. +// Leaflet CSS is injected programmatically to avoid Metro CSS import issues. +import type React from 'react'; +import { View } from 'react-native'; + +// Inject leaflet CSS once via CDN. +if (typeof document !== 'undefined') { + const LEAFLET_CSS_ID = '__leaflet_css__'; + if (!document.getElementById(LEAFLET_CSS_ID)) { + const link = document.createElement('link'); + link.id = LEAFLET_CSS_ID; + link.rel = 'stylesheet'; + link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'; + document.head.appendChild(link); + } +} + +type Region = { + latitude: number; + longitude: number; + latitudeDelta: number; + longitudeDelta: number; +}; + +type Coordinate = { + latitude: number; + longitude: number; +}; + +type MapViewProps = { + style?: object; + initialRegion?: Region; + region?: Region; + children?: React.ReactNode; + onRegionChange?: (region: Region) => void; + onRegionChangeComplete?: (region: Region) => void; + onPress?: (event: { nativeEvent: { coordinate: Coordinate } }) => void; + [key: string]: unknown; +}; + +type MarkerProps = { + coordinate: Coordinate; + title?: string; + description?: string; + children?: React.ReactNode; + onPress?: () => void; + [key: string]: unknown; +}; + +// Lazily import react-leaflet to avoid SSR issues. +let MapContainer: React.ComponentType | null = null; +let TileLayer: React.ComponentType | null = null; +let LeafletMarker: React.ComponentType | null = null; +let Popup: React.ComponentType | null = null; +let leafletLoaded = false; + +function ensureLeaflet() { + if (leafletLoaded) return; + try { + // biome-ignore lint/suspicious/noExplicitAny: dynamic require needed + const rl = require('react-leaflet') as any; + MapContainer = rl.MapContainer; + TileLayer = rl.TileLayer; + LeafletMarker = rl.Marker; + Popup = rl.Popup; + // Fix default marker icons missing in bundlers. + // biome-ignore lint/suspicious/noExplicitAny: dynamic require needed + const L = require('leaflet') as any; + delete L.Icon.Default.prototype._getIconUrl; + L.Icon.Default.mergeOptions({ + iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png', + iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png', + shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png', + }); + leafletLoaded = true; + } catch { + // react-leaflet not installed — fall back to placeholder + } +} + +function MapFallback({ style }: { style?: object }) { + return ( + + ); +} + +function LeafletMap({ style, initialRegion, region, children, ...props }: MapViewProps) { + ensureLeaflet(); + if (!MapContainer || !TileLayer) return ; + + const r = region ?? initialRegion; + const center: [number, number] = r ? [r.latitude, r.longitude] : [20, 0]; + const zoom = r ? Math.round(10 - Math.log2(r.latitudeDelta + 0.001)) : 5; + + const containerStyle = { + flex: 1, + height: '100%', + ...(style as object), + }; + + const MC = MapContainer as React.ComponentType<{ + center: [number, number]; + zoom: number; + style: object; + children?: React.ReactNode; + [key: string]: unknown; + }>; + const TL = TileLayer as React.ComponentType<{ url: string; attribution: string }>; + + return ( + + + {children} + + ); +} + +export function Marker({ coordinate, title, children }: MarkerProps) { + ensureLeaflet(); + if (!LeafletMarker) return null; + + const LM = LeafletMarker as React.ComponentType<{ + position: [number, number]; + children?: React.ReactNode; + }>; + const P = Popup as React.ComponentType<{ children?: React.ReactNode }> | null; + + return ( + + {title && P ?

{title}

: children} +
+ ); +} + +export default LeafletMap; + +// Named export alias for components that import MapView by name. +export { LeafletMap as MapView }; + +// Additional react-native-maps exports used in the codebase. +export const Callout = ({ children }: { children?: React.ReactNode }) => <>{children}; +export const Circle = () => null; +export const Polygon = () => null; +export const Polyline = () => null; +export const Overlay = () => null; +export const UrlTile = () => null; +export const PROVIDER_GOOGLE = 'google'; +export const PROVIDER_DEFAULT = undefined; diff --git a/apps/expo/package.json b/apps/expo/package.json index a460598020..ef6d07dcb2 100644 --- a/apps/expo/package.json +++ b/apps/expo/package.json @@ -122,8 +122,10 @@ "nanoid": "^5.1.9", "nativewind": "^4.2.3", "radash": "catalog:", + "leaflet": "^1.9.4", "react": "catalog:", "react-dom": "catalog:", + "react-leaflet": "^4.2.1", "react-i18next": "^17.0.4", "react-native": "0.81.5", "react-native-blob-util": "^0.24.5", @@ -150,6 +152,7 @@ "@babel/core": "^7.20.0", "@biomejs/biome": "2.4.6", "@types/he": "^1.2.3", + "@types/leaflet": "^1.9.16", "@types/react": "~19.1.10", "@types/ungap__structured-clone": "^1.2.0", "@typescript-eslint/eslint-plugin": "^7.7.0", diff --git a/bun.lock b/bun.lock index f8749fdf22..77cc5d4345 100644 --- a/bun.lock +++ b/bun.lock @@ -137,6 +137,7 @@ "i": "^0.3.7", "i18next": "^25.8.18", "jotai": "^2.12.2", + "leaflet": "^1.9.4", "llama.rn": "0.10.1", "nanoid": "^5.1.9", "nativewind": "^4.2.3", @@ -144,6 +145,7 @@ "react": "catalog:", "react-dom": "catalog:", "react-i18next": "^17.0.4", + "react-leaflet": "^4.2.1", "react-native": "0.81.5", "react-native-blob-util": "^0.24.5", "react-native-css-interop": "^0.2.3", @@ -169,6 +171,7 @@ "@babel/core": "^7.20.0", "@biomejs/biome": "2.4.6", "@types/he": "^1.2.3", + "@types/leaflet": "^1.9.16", "@types/react": "~19.1.10", "@types/ungap__structured-clone": "^1.2.0", "@typescript-eslint/eslint-plugin": "^7.7.0", @@ -1427,6 +1430,8 @@ "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], + "@react-leaflet/core": ["@react-leaflet/core@2.1.0", "", { "peerDependencies": { "leaflet": "^1.9.0", "react": "^18.0.0", "react-dom": "^18.0.0" } }, "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg=="], + "@react-native-ai/apple": ["@react-native-ai/apple@0.10.0", "", { "dependencies": { "@ai-sdk/provider": "^2.0.0", "@ai-sdk/provider-utils": "^3.0.10", "zod": "^4.0.0" }, "peerDependencies": { "react-native": ">=0.76.0" } }, "sha512-VhtMvzsDnaiU9FLBAstJUYIkQgy/Ce0ll6a/tkx0/uzQF0cChDluft0hXF3/w0p5ZOGIGNrZicvjIiVjrmoA1w=="], "@react-native-ai/llama": ["@react-native-ai/llama@0.10.0", "", { "dependencies": { "@ai-sdk/provider": "^2.0.0", "@ai-sdk/provider-utils": "^3.0.10", "react-native-blob-util": "^0.24.5", "zod": "^4.0.0" }, "peerDependencies": { "llama.rn": "^0.10.0-rc.0", "react-native": ">=0.76.0" } }, "sha512-BlRd+G5xoA/9mpyOLTAUIYtS3tJ3GkTo5z64qJ4jR76f0YpTjz7V2Ky1wHop9GBZWA5dJRke0Yj/EG4J5TsIQg=="], @@ -1837,6 +1842,8 @@ "@types/jsonfile": ["@types/jsonfile@6.1.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ=="], + "@types/leaflet": ["@types/leaflet@1.9.21", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w=="], + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], "@types/mdx": ["@types/mdx@2.0.13", "", {}, "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw=="], @@ -2913,6 +2920,8 @@ "lan-network": ["lan-network@0.1.7", "", { "bin": { "lan-network": "dist/lan-network-cli.js" } }, "sha512-mnIlAEMu4OyEvUNdzco9xpuB9YVcPkQec+QsgycBCtPZvEqWPCDPfbAE4OJMdBBWpZWtpCn1xw9jJYlwjWI5zQ=="], + "leaflet": ["leaflet@1.9.4", "", {}, "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA=="], + "lefthook": ["lefthook@1.13.6", "", { "optionalDependencies": { "lefthook-darwin-arm64": "1.13.6", "lefthook-darwin-x64": "1.13.6", "lefthook-freebsd-arm64": "1.13.6", "lefthook-freebsd-x64": "1.13.6", "lefthook-linux-arm64": "1.13.6", "lefthook-linux-x64": "1.13.6", "lefthook-openbsd-arm64": "1.13.6", "lefthook-openbsd-x64": "1.13.6", "lefthook-windows-arm64": "1.13.6", "lefthook-windows-x64": "1.13.6" }, "bin": { "lefthook": "bin/index.js" } }, "sha512-ojj4/4IJ29Xn4drd5emqVgilegAPN3Kf0FQM2p/9+lwSTpU+SZ1v4Ig++NF+9MOa99UKY8bElmVrLhnUUNFh5g=="], "lefthook-darwin-arm64": ["lefthook-darwin-arm64@1.13.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-m6Lb77VGc84/Qo21Lhq576pEvcgFCnvloEiP02HbAHcIXD0RTLy9u2yAInrixqZeaz13HYtdDaI7OBYAAdVt8A=="], @@ -3443,6 +3452,8 @@ "react-is": ["react-is@19.2.5", "", {}, "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ=="], + "react-leaflet": ["react-leaflet@4.2.1", "", { "dependencies": { "@react-leaflet/core": "^2.1.0" }, "peerDependencies": { "leaflet": "^1.9.0", "react": "^18.0.0", "react-dom": "^18.0.0" } }, "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q=="], + "react-native": ["react-native@0.81.5", "", { "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/assets-registry": "0.81.5", "@react-native/codegen": "0.81.5", "@react-native/community-cli-plugin": "0.81.5", "@react-native/gradle-plugin": "0.81.5", "@react-native/js-polyfills": "0.81.5", "@react-native/normalize-colors": "0.81.5", "@react-native/virtualized-lists": "0.81.5", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-jest": "^29.7.0", "babel-plugin-syntax-hermes-parser": "0.29.1", "base64-js": "^1.5.1", "commander": "^12.0.0", "flow-enums-runtime": "^0.0.6", "glob": "^7.1.1", "invariant": "^2.2.4", "jest-environment-node": "^29.7.0", "memoize-one": "^5.0.0", "metro-runtime": "^0.83.1", "metro-source-map": "^0.83.1", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.5", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.26.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0", "ws": "^6.2.3", "yargs": "^17.6.2" }, "peerDependencies": { "@types/react": "^19.1.0", "react": "^19.1.0" }, "optionalPeers": ["@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-1w+/oSjEXZjMqsIvmkCRsOc8UBYv163bTWKTI8+1mxztvQPhCRYGTvZ/PL1w16xXHneIj/SLGfxWg2GWN2uexw=="], "react-native-blob-util": ["react-native-blob-util@0.24.7", "", { "dependencies": { "base-64": "0.1.0", "glob": "13.0.1" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-3vgn3hblfJh0+LIoqEhYRqCtwKh1xID2LtXHdTrUml3rYh4xj69eN+lvWU235AL0FRbX5uKrS1c4lIYexSgtWQ=="], From 593d502c737566fbb63a7ed8464d887bc69a316d Mon Sep 17 00:00:00 2001 From: Andrew Bierman <94939237+andrew-bierman@users.noreply.github.com> Date: Thu, 30 Apr 2026 21:33:44 -0600 Subject: [PATCH 15/24] chore: sort package.json deps alphabetically --- apps/expo/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/expo/package.json b/apps/expo/package.json index ef6d07dcb2..08ef5379c7 100644 --- a/apps/expo/package.json +++ b/apps/expo/package.json @@ -118,15 +118,15 @@ "i": "^0.3.7", "i18next": "^25.8.18", "jotai": "^2.12.2", + "leaflet": "^1.9.4", "llama.rn": "0.10.1", "nanoid": "^5.1.9", "nativewind": "^4.2.3", "radash": "catalog:", - "leaflet": "^1.9.4", "react": "catalog:", "react-dom": "catalog:", - "react-leaflet": "^4.2.1", "react-i18next": "^17.0.4", + "react-leaflet": "^4.2.1", "react-native": "0.81.5", "react-native-blob-util": "^0.24.5", "react-native-css-interop": "^0.2.3", From daf1541a0cc520b8daa75b8f73c272eb3b3c1fde Mon Sep 17 00:00:00 2001 From: Andrew Bierman <94939237+andrew-bierman@users.noreply.github.com> Date: Thu, 30 Apr 2026 21:47:13 -0600 Subject: [PATCH 16/24] refactor(web): remove redundant stubs and Platform.select style overrides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop async-storage from WEB_STUBS — library already uses window.localStorage on web - react-native-maps mock: use static imports instead of dynamic require (no more biome-ignores) - expo-file-system-legacy stub: drop unused _options params (Biome useMaxParams clean) - Auth screens: remove Platform.select style on logo Image — NativeWind ios: class prefix is sufficient --- .../app/auth/(create-account)/credentials.tsx | 4 - apps/expo/app/auth/(create-account)/index.tsx | 4 - .../expo/app/auth/(login)/forgot-password.tsx | 4 - apps/expo/app/auth/(login)/index.tsx | 4 - apps/expo/app/auth/(login)/reset-password.tsx | 4 - apps/expo/app/auth/index.tsx | 4 - apps/expo/metro.config.js | 1 - apps/expo/mocks/expo-file-system-legacy.ts | 5 - apps/expo/mocks/react-native-maps.tsx | 112 ++++-------------- 9 files changed, 25 insertions(+), 117 deletions(-) diff --git a/apps/expo/app/auth/(create-account)/credentials.tsx b/apps/expo/app/auth/(create-account)/credentials.tsx index eb76cd297b..aac8667bad 100644 --- a/apps/expo/app/auth/(create-account)/credentials.tsx +++ b/apps/expo/app/auth/(create-account)/credentials.tsx @@ -186,10 +186,6 @@ export default function CredentialsScreen() { diff --git a/apps/expo/app/auth/(create-account)/index.tsx b/apps/expo/app/auth/(create-account)/index.tsx index 54be7c7c61..c3afb3f70a 100644 --- a/apps/expo/app/auth/(create-account)/index.tsx +++ b/apps/expo/app/auth/(create-account)/index.tsx @@ -66,10 +66,6 @@ export default function InfoScreen() { diff --git a/apps/expo/app/auth/(login)/forgot-password.tsx b/apps/expo/app/auth/(login)/forgot-password.tsx index 2669c4b45c..0a08340fde 100644 --- a/apps/expo/app/auth/(login)/forgot-password.tsx +++ b/apps/expo/app/auth/(login)/forgot-password.tsx @@ -86,10 +86,6 @@ export default function ForgotPasswordScreen() { diff --git a/apps/expo/app/auth/(login)/index.tsx b/apps/expo/app/auth/(login)/index.tsx index 73d32671a5..18c0152b43 100644 --- a/apps/expo/app/auth/(login)/index.tsx +++ b/apps/expo/app/auth/(login)/index.tsx @@ -95,10 +95,6 @@ export default function LoginScreen() { diff --git a/apps/expo/app/auth/(login)/reset-password.tsx b/apps/expo/app/auth/(login)/reset-password.tsx index 078112bfe5..5f0b6b236a 100644 --- a/apps/expo/app/auth/(login)/reset-password.tsx +++ b/apps/expo/app/auth/(login)/reset-password.tsx @@ -177,10 +177,6 @@ export default function ResetPasswordScreen() { diff --git a/apps/expo/app/auth/index.tsx b/apps/expo/app/auth/index.tsx index 5c4a816f6c..7026ac86a5 100644 --- a/apps/expo/app/auth/index.tsx +++ b/apps/expo/app/auth/index.tsx @@ -62,10 +62,6 @@ export default function AuthIndexScreen() {
diff --git a/apps/expo/metro.config.js b/apps/expo/metro.config.js index 1276b83e67..2613f966d0 100644 --- a/apps/expo/metro.config.js +++ b/apps/expo/metro.config.js @@ -21,7 +21,6 @@ config.resolver = { const WEB_STUBS = { 'react-native-maps': 'mocks/react-native-maps.tsx', 'react-native-blob-util': 'mocks/react-native-blob-util.ts', - '@react-native-async-storage/async-storage': 'mocks/async-storage.ts', '@react-native-ai/llama': 'mocks/react-native-ai-llama.ts', 'llama.rn': 'mocks/react-native-ai-llama.ts', '@react-native-ai/apple': 'mocks/react-native-ai-apple.ts', diff --git a/apps/expo/mocks/expo-file-system-legacy.ts b/apps/expo/mocks/expo-file-system-legacy.ts index 4e9f32bead..b4e0329b9d 100644 --- a/apps/expo/mocks/expo-file-system-legacy.ts +++ b/apps/expo/mocks/expo-file-system-legacy.ts @@ -42,22 +42,17 @@ export async function readDirectoryAsync(_uri: string): Promise { return []; } -// biome-ignore lint/complexity/useMaxParams: matches expo-file-system API signature export async function downloadAsync( _uri: string, _fileUri: string, - _options?: object, ): Promise<{ status: number; uri: string; headers: Record; mimeType: string }> { return { status: 200, uri: _fileUri, headers: {}, mimeType: '' }; } -// biome-ignore lint/complexity/useMaxParams: matches expo-file-system API signature export async function uploadAsync( _url: string, _fileUri: string, - _options?: object, ): Promise<{ status: number; body: string; headers: Record }> { - console.warn('FileSystem.uploadAsync is not supported on web'); return { status: 200, body: '', headers: {} }; } diff --git a/apps/expo/mocks/react-native-maps.tsx b/apps/expo/mocks/react-native-maps.tsx index fff97a6ea8..07f295c9fe 100644 --- a/apps/expo/mocks/react-native-maps.tsx +++ b/apps/expo/mocks/react-native-maps.tsx @@ -1,9 +1,12 @@ // Web implementation for react-native-maps using react-leaflet. -// Leaflet CSS is injected programmatically to avoid Metro CSS import issues. +// This file is only bundled on web via the metro WEB_STUBS resolver. +// Leaflet CSS is injected once via CDN to avoid needing a Metro CSS import. + +import type { LatLngExpression } from 'leaflet'; +import L, { type Icon } from 'leaflet'; import type React from 'react'; -import { View } from 'react-native'; +import { Marker as LeafletMarker, MapContainer, Popup, TileLayer } from 'react-leaflet'; -// Inject leaflet CSS once via CDN. if (typeof document !== 'undefined') { const LEAFLET_CSS_ID = '__leaflet_css__'; if (!document.getElementById(LEAFLET_CSS_ID)) { @@ -13,6 +16,13 @@ if (typeof document !== 'undefined') { link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'; document.head.appendChild(link); } + // Fix default marker icon paths broken by module bundlers. + delete (L.Icon.Default.prototype as Icon.Default & { _getIconUrl?: unknown })._getIconUrl; + L.Icon.Default.mergeOptions({ + iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png', + iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png', + shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png', + }); } type Region = { @@ -47,110 +57,38 @@ type MarkerProps = { [key: string]: unknown; }; -// Lazily import react-leaflet to avoid SSR issues. -let MapContainer: React.ComponentType | null = null; -let TileLayer: React.ComponentType | null = null; -let LeafletMarker: React.ComponentType | null = null; -let Popup: React.ComponentType | null = null; -let leafletLoaded = false; - -function ensureLeaflet() { - if (leafletLoaded) return; - try { - // biome-ignore lint/suspicious/noExplicitAny: dynamic require needed - const rl = require('react-leaflet') as any; - MapContainer = rl.MapContainer; - TileLayer = rl.TileLayer; - LeafletMarker = rl.Marker; - Popup = rl.Popup; - // Fix default marker icons missing in bundlers. - // biome-ignore lint/suspicious/noExplicitAny: dynamic require needed - const L = require('leaflet') as any; - delete L.Icon.Default.prototype._getIconUrl; - L.Icon.Default.mergeOptions({ - iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png', - iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png', - shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png', - }); - leafletLoaded = true; - } catch { - // react-leaflet not installed — fall back to placeholder - } -} - -function MapFallback({ style }: { style?: object }) { - return ( - - ); -} - function LeafletMap({ style, initialRegion, region, children, ...props }: MapViewProps) { - ensureLeaflet(); - if (!MapContainer || !TileLayer) return ; - const r = region ?? initialRegion; - const center: [number, number] = r ? [r.latitude, r.longitude] : [20, 0]; + const center: LatLngExpression = r ? [r.latitude, r.longitude] : [20, 0]; const zoom = r ? Math.round(10 - Math.log2(r.latitudeDelta + 0.001)) : 5; - const containerStyle = { - flex: 1, - height: '100%', - ...(style as object), - }; - - const MC = MapContainer as React.ComponentType<{ - center: [number, number]; - zoom: number; - style: object; - children?: React.ReactNode; - [key: string]: unknown; - }>; - const TL = TileLayer as React.ComponentType<{ url: string; attribution: string }>; - return ( - - + {children} - + ); } export function Marker({ coordinate, title, children }: MarkerProps) { - ensureLeaflet(); - if (!LeafletMarker) return null; - - const LM = LeafletMarker as React.ComponentType<{ - position: [number, number]; - children?: React.ReactNode; - }>; - const P = Popup as React.ComponentType<{ children?: React.ReactNode }> | null; - return ( - - {title && P ?

{title}

: children} -
+ + {title ? {title} : children} + ); } export default LeafletMap; - -// Named export alias for components that import MapView by name. export { LeafletMap as MapView }; -// Additional react-native-maps exports used in the codebase. export const Callout = ({ children }: { children?: React.ReactNode }) => <>{children}; export const Circle = () => null; export const Polygon = () => null; From ffeb9f6d5d9dcd6192cd1b8be9d9a8c9f275fca4 Mon Sep 17 00:00:00 2001 From: Andrew Bierman <94939237+andrew-bierman@users.noreply.github.com> Date: Thu, 30 Apr 2026 21:56:23 -0600 Subject: [PATCH 17/24] fix: add height SharedValue to keyboard mock + web logo sizing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `useReanimatedKeyboardAnimation` now returns both `height` and `progress` SharedValues — the Alert component from @packrat-ai/nativewindui destructures `height` and would crash on web without it - Add `web:h-8 web:w-8` to logo Image classNames in all 6 auth screens so the logo renders at a sensible size on web without disturbing the existing iOS sizing; replaces the removed Platform.select({ ios: { height: 48 } }) style --- apps/expo/app/auth/(create-account)/credentials.tsx | 2 +- apps/expo/app/auth/(create-account)/index.tsx | 2 +- apps/expo/app/auth/(login)/forgot-password.tsx | 2 +- apps/expo/app/auth/(login)/index.tsx | 2 +- apps/expo/app/auth/(login)/reset-password.tsx | 2 +- apps/expo/app/auth/index.tsx | 2 +- apps/expo/mocks/react-native-keyboard-controller.tsx | 8 ++++++-- 7 files changed, 12 insertions(+), 8 deletions(-) diff --git a/apps/expo/app/auth/(create-account)/credentials.tsx b/apps/expo/app/auth/(create-account)/credentials.tsx index aac8667bad..0c8ca8ebef 100644 --- a/apps/expo/app/auth/(create-account)/credentials.tsx +++ b/apps/expo/app/auth/(create-account)/credentials.tsx @@ -185,7 +185,7 @@ export default function CredentialsScreen() { diff --git a/apps/expo/app/auth/(create-account)/index.tsx b/apps/expo/app/auth/(create-account)/index.tsx index c3afb3f70a..912b5e1654 100644 --- a/apps/expo/app/auth/(create-account)/index.tsx +++ b/apps/expo/app/auth/(create-account)/index.tsx @@ -65,7 +65,7 @@ export default function InfoScreen() { diff --git a/apps/expo/app/auth/(login)/forgot-password.tsx b/apps/expo/app/auth/(login)/forgot-password.tsx index 0a08340fde..b0360ec93b 100644 --- a/apps/expo/app/auth/(login)/forgot-password.tsx +++ b/apps/expo/app/auth/(login)/forgot-password.tsx @@ -85,7 +85,7 @@ export default function ForgotPasswordScreen() { diff --git a/apps/expo/app/auth/(login)/index.tsx b/apps/expo/app/auth/(login)/index.tsx index 18c0152b43..f025daf6c0 100644 --- a/apps/expo/app/auth/(login)/index.tsx +++ b/apps/expo/app/auth/(login)/index.tsx @@ -94,7 +94,7 @@ export default function LoginScreen() { diff --git a/apps/expo/app/auth/(login)/reset-password.tsx b/apps/expo/app/auth/(login)/reset-password.tsx index 5f0b6b236a..dc553a4d91 100644 --- a/apps/expo/app/auth/(login)/reset-password.tsx +++ b/apps/expo/app/auth/(login)/reset-password.tsx @@ -176,7 +176,7 @@ export default function ResetPasswordScreen() { diff --git a/apps/expo/app/auth/index.tsx b/apps/expo/app/auth/index.tsx index 7026ac86a5..1517058e72 100644 --- a/apps/expo/app/auth/index.tsx +++ b/apps/expo/app/auth/index.tsx @@ -61,7 +61,7 @@ export default function AuthIndexScreen() { diff --git a/apps/expo/mocks/react-native-keyboard-controller.tsx b/apps/expo/mocks/react-native-keyboard-controller.tsx index 14f5547678..e4c4814ccf 100644 --- a/apps/expo/mocks/react-native-keyboard-controller.tsx +++ b/apps/expo/mocks/react-native-keyboard-controller.tsx @@ -30,9 +30,13 @@ export function KeyboardStickyView({ return {children}; } -export function useReanimatedKeyboardAnimation(): { progress: SharedValue } { +export function useReanimatedKeyboardAnimation(): { + height: SharedValue; + progress: SharedValue; +} { + const height = useSharedValue(0); const progress = useSharedValue(0); - return { progress }; + return { height, progress }; } export const KeyboardController = { From 3993535c060427ecdeee298e3a499d06bf2b6d49 Mon Sep 17 00:00:00 2001 From: Andrew Bierman <94939237+andrew-bierman@users.noreply.github.com> Date: Thu, 30 Apr 2026 22:05:45 -0600 Subject: [PATCH 18/24] fix(web): address PR review comments in web stubs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove orphaned mocks/react-native-maps.ts (Metro WEB_STUBS already points to .tsx; the .ts file was never loaded) - Wrap JSON.parse calls in mergeItem/multiMerge with try/catch — falls back to overwriting with new value if stored data is non-JSON - Fix react-native-blob-util config().fetch() stub to return a thenable with no-op .progress()/.cancel() methods, preventing crashes when localModelManager.ts chains those methods on the result - Bump react-leaflet from ^4.2.1 to ^5.0.0 (React 19 peer support) --- apps/expo/mocks/async-storage.ts | 20 ++++++++++++++------ apps/expo/mocks/react-native-blob-util.ts | 12 +++++++++++- apps/expo/mocks/react-native-maps.ts | 22 ---------------------- apps/expo/package.json | 2 +- bun.lock | 6 +++--- 5 files changed, 29 insertions(+), 33 deletions(-) delete mode 100644 apps/expo/mocks/react-native-maps.ts diff --git a/apps/expo/mocks/async-storage.ts b/apps/expo/mocks/async-storage.ts index 0e4893a3ed..13e06f931f 100644 --- a/apps/expo/mocks/async-storage.ts +++ b/apps/expo/mocks/async-storage.ts @@ -19,10 +19,14 @@ const storage: typeof import('@react-native-async-storage/async-storage').defaul mergeItem: (key, value) => { if (!isClient) return Promise.resolve(); const existing = window.localStorage.getItem(key); - const merged = existing - ? JSON.stringify({ ...JSON.parse(existing), ...JSON.parse(value) }) - : value; - window.localStorage.setItem(key, merged); + try { + const merged = existing + ? JSON.stringify({ ...JSON.parse(existing), ...JSON.parse(value) }) + : value; + window.localStorage.setItem(key, merged); + } catch { + window.localStorage.setItem(key, value); + } return Promise.resolve(); }, clear: () => { @@ -52,8 +56,12 @@ const storage: typeof import('@react-native-async-storage/async-storage').defaul if (!isClient) return Promise.resolve(); for (const [k, v] of pairs) { const existing = window.localStorage.getItem(k); - const merged = existing ? JSON.stringify({ ...JSON.parse(existing), ...JSON.parse(v) }) : v; - window.localStorage.setItem(k, merged); + try { + const merged = existing ? JSON.stringify({ ...JSON.parse(existing), ...JSON.parse(v) }) : v; + window.localStorage.setItem(k, merged); + } catch { + window.localStorage.setItem(k, v); + } } return Promise.resolve(); }, diff --git a/apps/expo/mocks/react-native-blob-util.ts b/apps/expo/mocks/react-native-blob-util.ts index e5f8bbbfa6..b4977812f7 100644 --- a/apps/expo/mocks/react-native-blob-util.ts +++ b/apps/expo/mocks/react-native-blob-util.ts @@ -24,7 +24,17 @@ const RNBlobUtil = { ls: () => Promise.resolve([]), }, config: () => ({ - fetch: () => Promise.resolve(null), + fetch: () => { + // Return a thenable with .progress()/.cancel() so callers like + // localModelManager.ts don't throw when chaining those methods. + const promise = Promise.resolve(null) as Promise & { + progress: (cb: unknown) => unknown; + cancel: () => void; + }; + promise.progress = () => promise; + promise.cancel = () => {}; + return promise; + }, }), }; diff --git a/apps/expo/mocks/react-native-maps.ts b/apps/expo/mocks/react-native-maps.ts deleted file mode 100644 index ed35b25414..0000000000 --- a/apps/expo/mocks/react-native-maps.ts +++ /dev/null @@ -1,22 +0,0 @@ -import React from 'react'; -import { View } from 'react-native'; - -const MapView = ({ - children, - style, - ...props -}: { - children?: React.ReactNode; - style?: unknown; - [key: string]: unknown; -}) => - React.createElement( - View, - { style: [{ backgroundColor: '#e0e0e0', flex: 1 }, style], ...props }, - children, - ); - -const Marker = () => null; - -export default MapView; -export { Marker }; diff --git a/apps/expo/package.json b/apps/expo/package.json index 08ef5379c7..d1b2650972 100644 --- a/apps/expo/package.json +++ b/apps/expo/package.json @@ -126,7 +126,7 @@ "react": "catalog:", "react-dom": "catalog:", "react-i18next": "^17.0.4", - "react-leaflet": "^4.2.1", + "react-leaflet": "^5.0.0", "react-native": "0.81.5", "react-native-blob-util": "^0.24.5", "react-native-css-interop": "^0.2.3", diff --git a/bun.lock b/bun.lock index 77cc5d4345..cbf7bdd6b8 100644 --- a/bun.lock +++ b/bun.lock @@ -145,7 +145,7 @@ "react": "catalog:", "react-dom": "catalog:", "react-i18next": "^17.0.4", - "react-leaflet": "^4.2.1", + "react-leaflet": "^5.0.0", "react-native": "0.81.5", "react-native-blob-util": "^0.24.5", "react-native-css-interop": "^0.2.3", @@ -1430,7 +1430,7 @@ "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], - "@react-leaflet/core": ["@react-leaflet/core@2.1.0", "", { "peerDependencies": { "leaflet": "^1.9.0", "react": "^18.0.0", "react-dom": "^18.0.0" } }, "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg=="], + "@react-leaflet/core": ["@react-leaflet/core@3.0.0", "", { "peerDependencies": { "leaflet": "^1.9.0", "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ=="], "@react-native-ai/apple": ["@react-native-ai/apple@0.10.0", "", { "dependencies": { "@ai-sdk/provider": "^2.0.0", "@ai-sdk/provider-utils": "^3.0.10", "zod": "^4.0.0" }, "peerDependencies": { "react-native": ">=0.76.0" } }, "sha512-VhtMvzsDnaiU9FLBAstJUYIkQgy/Ce0ll6a/tkx0/uzQF0cChDluft0hXF3/w0p5ZOGIGNrZicvjIiVjrmoA1w=="], @@ -3452,7 +3452,7 @@ "react-is": ["react-is@19.2.5", "", {}, "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ=="], - "react-leaflet": ["react-leaflet@4.2.1", "", { "dependencies": { "@react-leaflet/core": "^2.1.0" }, "peerDependencies": { "leaflet": "^1.9.0", "react": "^18.0.0", "react-dom": "^18.0.0" } }, "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q=="], + "react-leaflet": ["react-leaflet@5.0.0", "", { "dependencies": { "@react-leaflet/core": "^3.0.0" }, "peerDependencies": { "leaflet": "^1.9.0", "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw=="], "react-native": ["react-native@0.81.5", "", { "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/assets-registry": "0.81.5", "@react-native/codegen": "0.81.5", "@react-native/community-cli-plugin": "0.81.5", "@react-native/gradle-plugin": "0.81.5", "@react-native/js-polyfills": "0.81.5", "@react-native/normalize-colors": "0.81.5", "@react-native/virtualized-lists": "0.81.5", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-jest": "^29.7.0", "babel-plugin-syntax-hermes-parser": "0.29.1", "base64-js": "^1.5.1", "commander": "^12.0.0", "flow-enums-runtime": "^0.0.6", "glob": "^7.1.1", "invariant": "^2.2.4", "jest-environment-node": "^29.7.0", "memoize-one": "^5.0.0", "metro-runtime": "^0.83.1", "metro-source-map": "^0.83.1", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.5", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.26.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0", "ws": "^6.2.3", "yargs": "^17.6.2" }, "peerDependencies": { "@types/react": "^19.1.0", "react": "^19.1.0" }, "optionalPeers": ["@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-1w+/oSjEXZjMqsIvmkCRsOc8UBYv163bTWKTI8+1mxztvQPhCRYGTvZ/PL1w16xXHneIj/SLGfxWg2GWN2uexw=="], From f102dad654332fac4a2e88b2857edcb48630efe4 Mon Sep 17 00:00:00 2001 From: Andrew Bierman <94939237+andrew-bierman@users.noreply.github.com> Date: Thu, 30 Apr 2026 22:08:46 -0600 Subject: [PATCH 19/24] refactor(auth): replace makeKvStorage with platform-specific kvStorage files Native uses expo-sqlite/kv-store sync API, web uses AsyncStorage (which falls through to localStorage via @react-native-async-storage web support). Mirrors the existing persist-plugin / persist-plugin.web pattern. --- apps/expo/features/auth/atoms/authAtoms.ts | 45 ++-------------------- apps/expo/lib/kvStorage.ts | 10 +++++ apps/expo/lib/kvStorage.web.ts | 10 +++++ 3 files changed, 23 insertions(+), 42 deletions(-) create mode 100644 apps/expo/lib/kvStorage.ts create mode 100644 apps/expo/lib/kvStorage.web.ts diff --git a/apps/expo/features/auth/atoms/authAtoms.ts b/apps/expo/features/auth/atoms/authAtoms.ts index 737a3e4ba0..0eb302de7f 100644 --- a/apps/expo/features/auth/atoms/authAtoms.ts +++ b/apps/expo/features/auth/atoms/authAtoms.ts @@ -1,7 +1,6 @@ -import Storage from 'expo-sqlite/kv-store'; +import kvStorage from 'expo-app/lib/kvStorage'; import { atom } from 'jotai'; import { atomWithStorage } from 'jotai/utils'; -import { Platform } from 'react-native'; // User type definition export type User = { @@ -12,48 +11,10 @@ export type User = { emailVerified: boolean; }; -// On web, SQLite sync methods require SharedArrayBuffer (COEP/COOP headers). -// Without those headers getItemSync/setItemSync throw, which wipes in-memory -// atom state on onMount. We keep a module-level write-through cache on web so -// getItem() always returns the value that was last written in this session. -const webCache = new Map(); - -function makeKvStorage() { - return { - getItem: (_key: string): string | null => { - if (Platform.OS === 'web') return webCache.get(_key) ?? null; - return Storage.getItemSync(_key); - }, - setItem: (_key: string, value: string | null) => { - if (Platform.OS === 'web') { - webCache.set(_key, value); - // Persist async so the value survives a page refresh via Storage.getItem - if (value === null) void Storage.removeItem(_key); - else void Storage.setItem(_key, value); - return; - } - if (value === null) return Storage.removeItemSync(_key); - return Storage.setItemSync(_key, value); - }, - removeItem: (_key: string) => { - if (Platform.OS === 'web') { - webCache.delete(_key); - void Storage.removeItem(_key); - return; - } - return Storage.removeItemSync(_key); - }, - }; -} - // Token storage atom -export const tokenAtom = atomWithStorage('access_token', null, makeKvStorage()); +export const tokenAtom = atomWithStorage('access_token', null, kvStorage); -export const refreshTokenAtom = atomWithStorage( - 'refresh_token', - null, - makeKvStorage(), -); +export const refreshTokenAtom = atomWithStorage('refresh_token', null, kvStorage); // Loading state atom export const isLoadingAtom = atom(false); diff --git a/apps/expo/lib/kvStorage.ts b/apps/expo/lib/kvStorage.ts new file mode 100644 index 0000000000..d21ac4e6e4 --- /dev/null +++ b/apps/expo/lib/kvStorage.ts @@ -0,0 +1,10 @@ +import Storage from 'expo-sqlite/kv-store'; + +export default { + getItem: (key: string): string | null => Storage.getItemSync(key), + setItem: (key: string, value: string | null) => { + if (value === null) Storage.removeItemSync(key); + else Storage.setItemSync(key, value); + }, + removeItem: (key: string) => Storage.removeItemSync(key), +}; diff --git a/apps/expo/lib/kvStorage.web.ts b/apps/expo/lib/kvStorage.web.ts new file mode 100644 index 0000000000..c9c9b9db28 --- /dev/null +++ b/apps/expo/lib/kvStorage.web.ts @@ -0,0 +1,10 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +export default { + getItem: (key: string) => AsyncStorage.getItem(key), + setItem: (key: string, value: string | null) => { + if (value === null) return AsyncStorage.removeItem(key); + return AsyncStorage.setItem(key, value); + }, + removeItem: (key: string) => AsyncStorage.removeItem(key), +}; From 4dcc57b1875172424f081f5fef6c28dc537a779a Mon Sep 17 00:00:00 2001 From: Andrew Bierman <94939237+andrew-bierman@users.noreply.github.com> Date: Thu, 30 Apr 2026 22:15:00 -0600 Subject: [PATCH 20/24] fix: bump @types/leaflet to ^1.9.21 to match apps/admin version --- apps/expo/package.json | 2 +- bun.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/expo/package.json b/apps/expo/package.json index d78b91ca04..cbf86a55d9 100644 --- a/apps/expo/package.json +++ b/apps/expo/package.json @@ -151,7 +151,7 @@ "@babel/core": "^7.20.0", "@biomejs/biome": "2.4.6", "@types/he": "^1.2.3", - "@types/leaflet": "^1.9.16", + "@types/leaflet": "^1.9.21", "@types/react": "~19.2.10", "@types/ungap__structured-clone": "^1.2.0", "@typescript-eslint/eslint-plugin": "^7.7.0", diff --git a/bun.lock b/bun.lock index 7f1c7c6bd5..e9e4f49cab 100644 --- a/bun.lock +++ b/bun.lock @@ -173,7 +173,7 @@ "@babel/core": "^7.20.0", "@biomejs/biome": "2.4.6", "@types/he": "^1.2.3", - "@types/leaflet": "^1.9.16", + "@types/leaflet": "^1.9.21", "@types/react": "~19.2.10", "@types/ungap__structured-clone": "^1.2.0", "@typescript-eslint/eslint-plugin": "^7.7.0", From a3b9d1238bb181d522c262acf0f61792f9511c98 Mon Sep 17 00:00:00 2001 From: Andrew Bierman <94939237+andrew-bierman@users.noreply.github.com> Date: Thu, 30 Apr 2026 22:20:42 -0600 Subject: [PATCH 21/24] =?UTF-8?q?refactor(catalog):=20drop=20userName=20al?= =?UTF-8?q?ias=20=E2=80=94=20not=20in=20DB=20schema,=20keep=20nullable=20t?= =?UTF-8?q?itle/text/date?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/api/src/schemas/catalog.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/api/src/schemas/catalog.ts b/packages/api/src/schemas/catalog.ts index 91e7a06e8f..7bb337f431 100644 --- a/packages/api/src/schemas/catalog.ts +++ b/packages/api/src/schemas/catalog.ts @@ -52,7 +52,6 @@ export const CatalogItemSchema = z.object({ .array( z.object({ user_name: z.string().nullable().optional(), - userName: z.string().nullable().optional(), user_avatar: z.string().nullable().optional(), userAvatar: z.string().nullable().optional(), context: z.record(z.string(), z.string()).nullable().optional(), From 912906b7eacc0d783bba85d1ee6ea49c0295ea90 Mon Sep 17 00:00:00 2001 From: Andrew Bierman <94939237+andrew-bierman@users.noreply.github.com> Date: Thu, 30 Apr 2026 22:27:56 -0600 Subject: [PATCH 22/24] chore: remove camelCase userAvatar duplicate from CatalogItemSchema reviews Only user_avatar (snake_case) is present in the DB JSONB type; userAvatar was a stale alias with no backing data. --- packages/api/src/schemas/catalog.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/api/src/schemas/catalog.ts b/packages/api/src/schemas/catalog.ts index 7bb337f431..76f3784ff7 100644 --- a/packages/api/src/schemas/catalog.ts +++ b/packages/api/src/schemas/catalog.ts @@ -53,7 +53,6 @@ export const CatalogItemSchema = z.object({ z.object({ user_name: z.string().nullable().optional(), user_avatar: z.string().nullable().optional(), - userAvatar: z.string().nullable().optional(), context: z.record(z.string(), z.string()).nullable().optional(), recommends: z.boolean().nullable().optional(), rating: z.number(), From ea8ad30a723a6e3a897d575da013c3424d5c6ca8 Mon Sep 17 00:00:00 2001 From: Andrew Bierman <94939237+andrew-bierman@users.noreply.github.com> Date: Thu, 30 Apr 2026 22:35:20 -0600 Subject: [PATCH 23/24] fix(ci): fix TypeScript error and Vitest parse failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - normalizeDescription: guard match[1] explicitly so TypeScript is satisfied without non-null assertion - ImageCacheManager: replace Platform.OS check with typeof document check to remove the react-native import — react-native/index.js uses Flow syntax that Rollup can't parse in unit tests --- apps/expo/features/catalog/lib/normalizeDescription.ts | 2 +- apps/expo/lib/utils/ImageCacheManager.ts | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/expo/features/catalog/lib/normalizeDescription.ts b/apps/expo/features/catalog/lib/normalizeDescription.ts index cb2c43aa8c..bfeed22434 100644 --- a/apps/expo/features/catalog/lib/normalizeDescription.ts +++ b/apps/expo/features/catalog/lib/normalizeDescription.ts @@ -3,7 +3,7 @@ const DETAILS_ARRAY_RE = /^Details:\s*(\[[\s\S]*\])$/; export function normalizeDescription(description: string | null | undefined): string | null { if (!description) return null; const match = description.match(DETAILS_ARRAY_RE); - if (match) { + if (match && match[1]) { try { const items = JSON.parse(match[1]) as string[]; return items.join('. '); diff --git a/apps/expo/lib/utils/ImageCacheManager.ts b/apps/expo/lib/utils/ImageCacheManager.ts index 42293c50a5..ac4972caed 100644 --- a/apps/expo/lib/utils/ImageCacheManager.ts +++ b/apps/expo/lib/utils/ImageCacheManager.ts @@ -1,5 +1,4 @@ import * as FileSystem from 'expo-file-system/legacy'; -import { Platform } from 'react-native'; import { IMAGES_DIR } from '../constants'; export class ImageCacheManager { @@ -93,7 +92,7 @@ export class ImageCacheManager { * Clear all cached images */ public async clearCache(): Promise { - if (Platform.OS === 'web') return; + if (typeof globalThis.document !== 'undefined') return; const dirInfo = await FileSystem.getInfoAsync(this.cacheDirectory); if (dirInfo.exists) { await FileSystem.deleteAsync(this.cacheDirectory); From ad152224ea8d616def6687b000c64efbc0f74fa8 Mon Sep 17 00:00:00 2001 From: Andrew Bierman <94939237+andrew-bierman@users.noreply.github.com> Date: Thu, 30 Apr 2026 22:36:03 -0600 Subject: [PATCH 24/24] fix: use 'in' operator for web detection instead of typeof --- apps/expo/lib/utils/ImageCacheManager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/expo/lib/utils/ImageCacheManager.ts b/apps/expo/lib/utils/ImageCacheManager.ts index ac4972caed..210066b5b9 100644 --- a/apps/expo/lib/utils/ImageCacheManager.ts +++ b/apps/expo/lib/utils/ImageCacheManager.ts @@ -92,7 +92,7 @@ export class ImageCacheManager { * Clear all cached images */ public async clearCache(): Promise { - if (typeof globalThis.document !== 'undefined') return; + if ('document' in globalThis) return; const dirInfo = await FileSystem.getInfoAsync(this.cacheDirectory); if (dirInfo.exists) { await FileSystem.deleteAsync(this.cacheDirectory);