Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions examples/react-native/offline-transactions/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
server/todos.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,9 @@ android {
buildToolsVersion rootProject.ext.buildToolsVersion
compileSdk rootProject.ext.compileSdkVersion

namespace "com.offlinetransactionsdemo"
namespace "com.tanstack.offlinetransactions"
defaultConfig {
applicationId "com.offlinetransactionsdemo"
applicationId "com.tanstack.offlinetransactions"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
Expand Down
22 changes: 9 additions & 13 deletions examples/react-native/offline-transactions/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,21 @@
import '../src/polyfills'

import { Stack } from 'expo-router'
import { QueryClientProvider } from '@tanstack/react-query'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import { StatusBar } from 'expo-status-bar'
import { queryClient } from '../src/utils/queryClient'

export default function RootLayout() {
return (
<SafeAreaProvider>
<QueryClientProvider client={queryClient}>
<StatusBar style="auto" />
<Stack>
<Stack.Screen
name="index"
options={{
title: `Offline Transactions`,
}}
/>
</Stack>
</QueryClientProvider>
<StatusBar style="auto" />
<Stack>
<Stack.Screen
name="index"
options={{
title: `Offline Transactions + SQLite`,
}}
/>
</Stack>
</SafeAreaProvider>
)
}
94 changes: 93 additions & 1 deletion examples/react-native/offline-transactions/app/index.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,102 @@
import React, { useEffect, useState } from 'react'
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { TodoList } from '../src/components/TodoList'
import { createTodos } from '../src/db/todos'
import type { TodosHandle } from '../src/db/todos'

export default function HomeScreen() {
const [handle, setHandle] = useState<TodosHandle | null>(null)
const [error, setError] = useState<string | null>(null)

useEffect(() => {
let disposed = false
let currentHandle: TodosHandle | null = null

try {
const h = createTodos()
if (disposed as boolean) {
h.close()
return
}
currentHandle = h
setHandle(h)
} catch (err) {
if (!(disposed as boolean)) {
console.error(`Failed to initialize:`, err)
setError(err instanceof Error ? err.message : `Failed to initialize`)
}
}

return () => {
disposed = true
currentHandle?.close()
}
}, [])

if (error) {
return (
<SafeAreaView style={{ flex: 1 }} edges={[`bottom`]}>
<View style={styles.errorContainer}>
<Text style={styles.errorTitle}>Initialization Error</Text>
<View style={styles.errorBox}>
<Text style={styles.errorText}>{error}</Text>
</View>
</View>
</SafeAreaView>
)
}

if (!handle) {
return (
<SafeAreaView style={{ flex: 1 }} edges={[`bottom`]}>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#3b82f6" />
<Text style={styles.loadingText}>Initializing...</Text>
</View>
</SafeAreaView>
)
}

return (
<SafeAreaView style={{ flex: 1 }} edges={[`bottom`]}>
<TodoList />
<TodoList collection={handle.collection} executor={handle.executor} />
</SafeAreaView>
)
}

const styles = StyleSheet.create({
errorContainer: {
flex: 1,
padding: 16,
backgroundColor: `#f5f5f5`,
},
errorTitle: {
fontSize: 24,
fontWeight: `bold`,
color: `#111`,
marginBottom: 16,
},
errorBox: {
backgroundColor: `#fee2e2`,
borderWidth: 1,
borderColor: `#fca5a5`,
borderRadius: 8,
padding: 12,
},
errorText: {
color: `#dc2626`,
fontSize: 14,
},
loadingContainer: {
flex: 1,
justifyContent: `center`,
alignItems: `center`,
gap: 12,
backgroundColor: `#f5f5f5`,
},
loadingText: {
color: `#666`,
fontSize: 14,
},
})
79 changes: 52 additions & 27 deletions examples/react-native/offline-transactions/metro.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,39 +11,60 @@ config.watchFolders = [monorepoRoot]

// Ensure symlinks are followed (important for pnpm)
config.resolver.unstable_enableSymlinks = true
config.resolver.unstable_enablePackageExports = true

// Force all React-related packages to resolve from THIS project's node_modules
// This prevents the "multiple copies of React" error
const localNodeModules = path.resolve(projectRoot, 'node_modules')
config.resolver.extraNodeModules = new Proxy(
{
react: path.resolve(localNodeModules, 'react'),
'react-native': path.resolve(localNodeModules, 'react-native'),
'react/jsx-runtime': path.resolve(localNodeModules, 'react/jsx-runtime'),
'react/jsx-dev-runtime': path.resolve(
localNodeModules,
'react/jsx-dev-runtime',
),
},
{
get: (target, name) => {
if (target[name]) {
return target[name]
}
// Fall back to normal resolution for other modules
return path.resolve(localNodeModules, name)
},

// Singleton packages that must resolve to exactly one copy.
// In a pnpm monorepo, workspace packages may resolve these to a different
// version in the .pnpm store. This custom resolveRequest forces every import
// of these packages (from anywhere) to the app's local node_modules copy.
const singletonPackages = ['react', 'react-native']
const singletonPaths = {}
for (const pkg of singletonPackages) {
singletonPaths[pkg] = path.resolve(localNodeModules, pkg)
}

const defaultResolveRequest = config.resolver.resolveRequest
config.resolver.resolveRequest = (context, moduleName, platform) => {
// Force singleton packages to resolve from the app's local node_modules,
// regardless of where the import originates. This prevents workspace
// packages (e.g. react-db) from pulling in their own copy of React.
for (const pkg of singletonPackages) {
if (moduleName === pkg || moduleName.startsWith(pkg + '/')) {
try {
const filePath = require.resolve(moduleName, {
paths: [projectRoot],
})
return { type: 'sourceFile', filePath }
} catch {}
}
}

if (defaultResolveRequest) {
return defaultResolveRequest(context, moduleName, platform)
}
return context.resolveRequest(
{ ...context, resolveRequest: undefined },
moduleName,
platform,
)
}

// Force singleton packages to resolve from the app's local node_modules
config.resolver.extraNodeModules = new Proxy(singletonPaths, {
get: (target, name) => {
if (target[name]) {
return target[name]
}
return path.resolve(localNodeModules, name)
},
)
})

// Block react-native 0.83 from root node_modules
const escMonorepoRoot = monorepoRoot.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
config.resolver.blockList = [
new RegExp(
`${monorepoRoot.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/node_modules/\\.pnpm/react-native@0\\.83.*`,
),
new RegExp(
`${monorepoRoot.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/node_modules/\\.pnpm/react@(?!19\\.0\\.0).*`,
),
new RegExp(`${escMonorepoRoot}/node_modules/\\.pnpm/react-native@0\\.83.*`),
]

// Let Metro know where to resolve packages from (local first, then root)
Expand All @@ -52,4 +73,8 @@ config.resolver.nodeModulesPaths = [
path.resolve(monorepoRoot, 'node_modules'),
]

// Allow dynamic imports with non-literal arguments (used by workspace packages
// for optional Node.js-only code paths that are never reached on React Native)
config.transformer.dynamicDepsInPackages = 'throwAtRuntime'

module.exports = config
5 changes: 4 additions & 1 deletion examples/react-native/offline-transactions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@
},
"dependencies": {
"@expo/metro-runtime": "~5.0.5",
"@op-engineering/op-sqlite": "^15.2.5",
"@react-native-async-storage/async-storage": "2.1.2",
"@react-native-community/netinfo": "11.4.1",
"@tanstack/db": "workspace:*",
"@tanstack/db-react-native-sqlite-persisted-collection": "workspace:*",
"@tanstack/offline-transactions": "^1.0.24",
"@tanstack/query-db-collection": "^1.0.30",
"@tanstack/react-db": "^0.1.77",
Expand All @@ -24,7 +27,7 @@
"expo-router": "~5.1.11",
"expo-status-bar": "~2.2.0",
"metro": "0.82.5",
"react": "^19.2.4",
"react": "19.0.0",
"react-native": "0.79.6",
"react-native-safe-area-context": "5.4.0",
"react-native-screens": "~4.11.1",
Expand Down
46 changes: 29 additions & 17 deletions examples/react-native/offline-transactions/server/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import express from 'express'
import { readFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import cors from 'cors'
import express from 'express'

const app = express()
const PORT = 3001
const DATA_FILE = join(dirname(fileURLToPath(import.meta.url)), 'todos.json')

app.use(cors())
app.use(express.json())
Expand All @@ -24,21 +28,26 @@ function generateId(): string {
return Math.random().toString(36).substring(2) + Date.now().toString(36)
}

// Add some initial data
const initialTodos = [
{ id: '1', text: 'Learn TanStack DB', completed: false },
{ id: '2', text: 'Build offline-first app', completed: false },
{ id: '3', text: 'Test on React Native', completed: true },
]
// Load persisted data or seed with initial data
function loadData() {
try {
const raw = readFileSync(DATA_FILE, 'utf-8')
const todos: Array<Todo> = JSON.parse(raw)
todos.forEach((todo) => todosStore.set(todo.id, todo))
console.log(`Loaded ${todos.length} todos from ${DATA_FILE}`)
} catch {
console.log(`No existing data file, starting empty`)
}
}

initialTodos.forEach((todo) => {
const now = new Date().toISOString()
todosStore.set(todo.id, {
...todo,
createdAt: now,
updatedAt: now,
})
})
function saveData() {
writeFileSync(
DATA_FILE,
JSON.stringify(Array.from(todosStore.values()), null, 2),
)
}

loadData()

// Simulate network delay
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
Expand All @@ -58,20 +67,21 @@ app.post('/api/todos', async (req, res) => {
console.log('POST /api/todos', req.body)
await delay(200)

const { text, completed } = req.body
const { id, text, completed } = req.body
if (!text || text.trim() === '') {
return res.status(400).json({ error: 'Todo text is required' })
}

const now = new Date().toISOString()
const todo: Todo = {
id: generateId(),
id: id || generateId(),
text,
completed: completed ?? false,
createdAt: now,
updatedAt: now,
}
todosStore.set(todo.id, todo)
saveData()
res.status(201).json(todo)
})

Expand All @@ -91,6 +101,7 @@ app.put('/api/todos/:id', async (req, res) => {
updatedAt: new Date().toISOString(),
}
todosStore.set(req.params.id, updated)
saveData()
res.json(updated)
})

Expand All @@ -102,6 +113,7 @@ app.delete('/api/todos/:id', async (req, res) => {
if (!todosStore.delete(req.params.id)) {
return res.status(404).json({ error: 'Todo not found' })
}
saveData()
res.json({ success: true })
})

Expand Down
Loading
Loading