Skip to content

Commit 96cbdb5

Browse files
kevin-dpclaudeautofix-ci[bot]
authored
feat(examples): add op-sqlite persistence to React Native offline-transactions demo (#1351)
* feat(examples): add op-sqlite persistence to react-native offline-transactions demo Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Modify RN offline TX demo to also use local SQLite persistence * ci: apply automated fixes * fix(react-native): align react version with monorepo (^19.2.4) react-native 0.79.6 accepts react@^19.0.0 as a peer dependency, so upgrading from the pinned 19.0.0 to ^19.2.4 is safe and fixes the sheriff version consistency check. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(react-native): revert react to 19.0.0 and exclude from sherif react-native 0.79.6 bundles a hardcoded react-native-renderer@19.0.0 which must exactly match the react version at runtime. Exclude the RN example from sherif's version consistency check instead. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix virtual props in tests * Fix loading UX regression in offline-transactions example Show a loading indicator instead of the empty state while the query is still hydrating, preventing a brief "No todos yet" flash on cold start when existing data needs to be fetched. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add .gitignore for server runtime data in offline-transactions example The demo server persists todos to server/todos.json at runtime, which would dirty the working tree without an ignore rule. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Remove notifier as it is already in the offline executor --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent e0a59cd commit 96cbdb5

File tree

15 files changed

+2493
-1572
lines changed

15 files changed

+2493
-1572
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
server/todos.json

examples/react-native/offline-transactions/android/app/build.gradle

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,9 @@ android {
8787
buildToolsVersion rootProject.ext.buildToolsVersion
8888
compileSdk rootProject.ext.compileSdkVersion
8989

90-
namespace "com.offlinetransactionsdemo"
90+
namespace "com.tanstack.offlinetransactions"
9191
defaultConfig {
92-
applicationId "com.offlinetransactionsdemo"
92+
applicationId "com.tanstack.offlinetransactions"
9393
minSdkVersion rootProject.ext.minSdkVersion
9494
targetSdkVersion rootProject.ext.targetSdkVersion
9595
versionCode 1

examples/react-native/offline-transactions/app/_layout.tsx

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,21 @@
22
import '../src/polyfills'
33

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

108
export default function RootLayout() {
119
return (
1210
<SafeAreaProvider>
13-
<QueryClientProvider client={queryClient}>
14-
<StatusBar style="auto" />
15-
<Stack>
16-
<Stack.Screen
17-
name="index"
18-
options={{
19-
title: `Offline Transactions`,
20-
}}
21-
/>
22-
</Stack>
23-
</QueryClientProvider>
11+
<StatusBar style="auto" />
12+
<Stack>
13+
<Stack.Screen
14+
name="index"
15+
options={{
16+
title: `Offline Transactions + SQLite`,
17+
}}
18+
/>
19+
</Stack>
2420
</SafeAreaProvider>
2521
)
2622
}
Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,102 @@
1+
import React, { useEffect, useState } from 'react'
2+
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'
13
import { SafeAreaView } from 'react-native-safe-area-context'
24
import { TodoList } from '../src/components/TodoList'
5+
import { createTodos } from '../src/db/todos'
6+
import type { TodosHandle } from '../src/db/todos'
37

48
export default function HomeScreen() {
9+
const [handle, setHandle] = useState<TodosHandle | null>(null)
10+
const [error, setError] = useState<string | null>(null)
11+
12+
useEffect(() => {
13+
let disposed = false
14+
let currentHandle: TodosHandle | null = null
15+
16+
try {
17+
const h = createTodos()
18+
if (disposed as boolean) {
19+
h.close()
20+
return
21+
}
22+
currentHandle = h
23+
setHandle(h)
24+
} catch (err) {
25+
if (!(disposed as boolean)) {
26+
console.error(`Failed to initialize:`, err)
27+
setError(err instanceof Error ? err.message : `Failed to initialize`)
28+
}
29+
}
30+
31+
return () => {
32+
disposed = true
33+
currentHandle?.close()
34+
}
35+
}, [])
36+
37+
if (error) {
38+
return (
39+
<SafeAreaView style={{ flex: 1 }} edges={[`bottom`]}>
40+
<View style={styles.errorContainer}>
41+
<Text style={styles.errorTitle}>Initialization Error</Text>
42+
<View style={styles.errorBox}>
43+
<Text style={styles.errorText}>{error}</Text>
44+
</View>
45+
</View>
46+
</SafeAreaView>
47+
)
48+
}
49+
50+
if (!handle) {
51+
return (
52+
<SafeAreaView style={{ flex: 1 }} edges={[`bottom`]}>
53+
<View style={styles.loadingContainer}>
54+
<ActivityIndicator size="large" color="#3b82f6" />
55+
<Text style={styles.loadingText}>Initializing...</Text>
56+
</View>
57+
</SafeAreaView>
58+
)
59+
}
60+
561
return (
662
<SafeAreaView style={{ flex: 1 }} edges={[`bottom`]}>
7-
<TodoList />
63+
<TodoList collection={handle.collection} executor={handle.executor} />
864
</SafeAreaView>
965
)
1066
}
67+
68+
const styles = StyleSheet.create({
69+
errorContainer: {
70+
flex: 1,
71+
padding: 16,
72+
backgroundColor: `#f5f5f5`,
73+
},
74+
errorTitle: {
75+
fontSize: 24,
76+
fontWeight: `bold`,
77+
color: `#111`,
78+
marginBottom: 16,
79+
},
80+
errorBox: {
81+
backgroundColor: `#fee2e2`,
82+
borderWidth: 1,
83+
borderColor: `#fca5a5`,
84+
borderRadius: 8,
85+
padding: 12,
86+
},
87+
errorText: {
88+
color: `#dc2626`,
89+
fontSize: 14,
90+
},
91+
loadingContainer: {
92+
flex: 1,
93+
justifyContent: `center`,
94+
alignItems: `center`,
95+
gap: 12,
96+
backgroundColor: `#f5f5f5`,
97+
},
98+
loadingText: {
99+
color: `#666`,
100+
fontSize: 14,
101+
},
102+
})

examples/react-native/offline-transactions/metro.config.js

Lines changed: 52 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -11,39 +11,60 @@ config.watchFolders = [monorepoRoot]
1111

1212
// Ensure symlinks are followed (important for pnpm)
1313
config.resolver.unstable_enableSymlinks = true
14+
config.resolver.unstable_enablePackageExports = true
1415

15-
// Force all React-related packages to resolve from THIS project's node_modules
16-
// This prevents the "multiple copies of React" error
1716
const localNodeModules = path.resolve(projectRoot, 'node_modules')
18-
config.resolver.extraNodeModules = new Proxy(
19-
{
20-
react: path.resolve(localNodeModules, 'react'),
21-
'react-native': path.resolve(localNodeModules, 'react-native'),
22-
'react/jsx-runtime': path.resolve(localNodeModules, 'react/jsx-runtime'),
23-
'react/jsx-dev-runtime': path.resolve(
24-
localNodeModules,
25-
'react/jsx-dev-runtime',
26-
),
27-
},
28-
{
29-
get: (target, name) => {
30-
if (target[name]) {
31-
return target[name]
32-
}
33-
// Fall back to normal resolution for other modules
34-
return path.resolve(localNodeModules, name)
35-
},
17+
18+
// Singleton packages that must resolve to exactly one copy.
19+
// In a pnpm monorepo, workspace packages may resolve these to a different
20+
// version in the .pnpm store. This custom resolveRequest forces every import
21+
// of these packages (from anywhere) to the app's local node_modules copy.
22+
const singletonPackages = ['react', 'react-native']
23+
const singletonPaths = {}
24+
for (const pkg of singletonPackages) {
25+
singletonPaths[pkg] = path.resolve(localNodeModules, pkg)
26+
}
27+
28+
const defaultResolveRequest = config.resolver.resolveRequest
29+
config.resolver.resolveRequest = (context, moduleName, platform) => {
30+
// Force singleton packages to resolve from the app's local node_modules,
31+
// regardless of where the import originates. This prevents workspace
32+
// packages (e.g. react-db) from pulling in their own copy of React.
33+
for (const pkg of singletonPackages) {
34+
if (moduleName === pkg || moduleName.startsWith(pkg + '/')) {
35+
try {
36+
const filePath = require.resolve(moduleName, {
37+
paths: [projectRoot],
38+
})
39+
return { type: 'sourceFile', filePath }
40+
} catch {}
41+
}
42+
}
43+
44+
if (defaultResolveRequest) {
45+
return defaultResolveRequest(context, moduleName, platform)
46+
}
47+
return context.resolveRequest(
48+
{ ...context, resolveRequest: undefined },
49+
moduleName,
50+
platform,
51+
)
52+
}
53+
54+
// Force singleton packages to resolve from the app's local node_modules
55+
config.resolver.extraNodeModules = new Proxy(singletonPaths, {
56+
get: (target, name) => {
57+
if (target[name]) {
58+
return target[name]
59+
}
60+
return path.resolve(localNodeModules, name)
3661
},
37-
)
62+
})
3863

3964
// Block react-native 0.83 from root node_modules
65+
const escMonorepoRoot = monorepoRoot.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
4066
config.resolver.blockList = [
41-
new RegExp(
42-
`${monorepoRoot.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/node_modules/\\.pnpm/react-native@0\\.83.*`,
43-
),
44-
new RegExp(
45-
`${monorepoRoot.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/node_modules/\\.pnpm/react@(?!19\\.0\\.0).*`,
46-
),
67+
new RegExp(`${escMonorepoRoot}/node_modules/\\.pnpm/react-native@0\\.83.*`),
4768
]
4869

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

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

examples/react-native/offline-transactions/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,11 @@
1212
},
1313
"dependencies": {
1414
"@expo/metro-runtime": "~5.0.5",
15+
"@op-engineering/op-sqlite": "^15.2.5",
1516
"@react-native-async-storage/async-storage": "2.1.2",
1617
"@react-native-community/netinfo": "11.4.1",
18+
"@tanstack/db": "workspace:*",
19+
"@tanstack/db-react-native-sqlite-persisted-collection": "workspace:*",
1720
"@tanstack/offline-transactions": "^1.0.24",
1821
"@tanstack/query-db-collection": "^1.0.30",
1922
"@tanstack/react-db": "^0.1.77",
@@ -24,7 +27,7 @@
2427
"expo-router": "~5.1.11",
2528
"expo-status-bar": "~2.2.0",
2629
"metro": "0.82.5",
27-
"react": "^19.2.4",
30+
"react": "19.0.0",
2831
"react-native": "0.79.6",
2932
"react-native-safe-area-context": "5.4.0",
3033
"react-native-screens": "~4.11.1",

examples/react-native/offline-transactions/server/index.ts

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1-
import express from 'express'
1+
import { readFileSync, writeFileSync } from 'node:fs'
2+
import { dirname, join } from 'node:path'
3+
import { fileURLToPath } from 'node:url'
24
import cors from 'cors'
5+
import express from 'express'
36

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

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

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

34-
initialTodos.forEach((todo) => {
35-
const now = new Date().toISOString()
36-
todosStore.set(todo.id, {
37-
...todo,
38-
createdAt: now,
39-
updatedAt: now,
40-
})
41-
})
43+
function saveData() {
44+
writeFileSync(
45+
DATA_FILE,
46+
JSON.stringify(Array.from(todosStore.values()), null, 2),
47+
)
48+
}
49+
50+
loadData()
4251

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

61-
const { text, completed } = req.body
70+
const { id, text, completed } = req.body
6271
if (!text || text.trim() === '') {
6372
return res.status(400).json({ error: 'Todo text is required' })
6473
}
6574

6675
const now = new Date().toISOString()
6776
const todo: Todo = {
68-
id: generateId(),
77+
id: id || generateId(),
6978
text,
7079
completed: completed ?? false,
7180
createdAt: now,
7281
updatedAt: now,
7382
}
7483
todosStore.set(todo.id, todo)
84+
saveData()
7585
res.status(201).json(todo)
7686
})
7787

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

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

0 commit comments

Comments
 (0)