diff --git a/.gitignore b/.gitignore index 1cbcf6ed2..4ad9ee25d 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ stats.html .tsup .svelte-kit .expo +examples/react-native/shopping-list/ios/ vite.config.js.timestamp-* vite.config.ts.timestamp-* diff --git a/examples/react-native/shopping-list/README.md b/examples/react-native/shopping-list/README.md new file mode 100644 index 000000000..b70a14b60 --- /dev/null +++ b/examples/react-native/shopping-list/README.md @@ -0,0 +1,41 @@ +# React Native Shopping List (Electric + Persistence + Offline Queue) + +This example uses: + +- `electricCollectionOptions` for realtime sync from Electric shape streams +- `persistedCollectionOptions` with React Native SQLite persistence +- `@tanstack/offline-transactions` for queued optimistic mutations and retry +- A local Express + Postgres API that returns `txid` values for Electric mutation matching +- Dedicated API shape proxy endpoints (`/api/shapes/*`) so Electric is not exposed directly to clients +- In-app `Simulate offline` toggle to demo offline queue + persistence behavior without disabling device network + +## Run + +From `examples/react-native/shopping-list`: + +1. Start Docker Desktop (required for Postgres + Electric). +2. Start Postgres + Electric: + - `pnpm db:up` +3. Start the API server in a separate terminal: + - `pnpm server` +4. Start Expo in another terminal: + - `pnpm start` +5. Launch iOS simulator: + - `open -a Simulator` + - then press `i` in the Expo terminal (or run `pnpm ios`) +6. Launch Android emulator: + - start an AVD from Android Studio Device Manager + - then press `a` in the Expo terminal (or run `pnpm android`) + +## Troubleshooting + +- If the server exits at startup, ensure Docker services are running and re-run `pnpm db:up`. +- Android emulator uses `10.0.2.2` for local host mapping. +- iOS simulator uses `localhost`. + +## Verification checklist + +- Add list and items while online: changes should sync and persist. +- Restart app: local data should load from SQLite immediately. +- Restart API/Electric: app should recover and continue syncing. +- Confirm there are no `Date value out of bounds` errors in shape sync logs. diff --git a/examples/react-native/shopping-list/android/.gitignore b/examples/react-native/shopping-list/android/.gitignore new file mode 100644 index 000000000..8a6be0771 --- /dev/null +++ b/examples/react-native/shopping-list/android/.gitignore @@ -0,0 +1,16 @@ +# OSX +# +.DS_Store + +# Android/IntelliJ +# +build/ +.idea +.gradle +local.properties +*.iml +*.hprof +.cxx/ + +# Bundle artifacts +*.jsbundle diff --git a/examples/react-native/shopping-list/android/app/build.gradle b/examples/react-native/shopping-list/android/app/build.gradle new file mode 100644 index 000000000..3c9049e63 --- /dev/null +++ b/examples/react-native/shopping-list/android/app/build.gradle @@ -0,0 +1,177 @@ +apply plugin: "com.android.application" +apply plugin: "org.jetbrains.kotlin.android" +apply plugin: "com.facebook.react" + +def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath() + +/** + * This is the configuration block to customize your React Native Android app. + * By default you don't need to apply any configuration, just uncomment the lines you need. + */ +react { + entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim()) + reactNativeDir = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile() + hermesCommand = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/sdks/hermesc/%OS-BIN%/hermesc" + codegenDir = new File(["node", "--print", "require.resolve('@react-native/codegen/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile() + + enableBundleCompression = (findProperty('android.enableBundleCompression') ?: false).toBoolean() + // Use Expo CLI to bundle the app, this ensures the Metro config + // works correctly with Expo projects. + cliFile = new File(["node", "--print", "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })"].execute(null, rootDir).text.trim()) + bundleCommand = "export:embed" + + /* Folders */ + // The root of your project, i.e. where "package.json" lives. Default is '../..' + // root = file("../../") + // The folder where the react-native NPM package is. Default is ../../node_modules/react-native + // reactNativeDir = file("../../node_modules/react-native") + // The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen + // codegenDir = file("../../node_modules/@react-native/codegen") + + /* Variants */ + // The list of variants to that are debuggable. For those we're going to + // skip the bundling of the JS bundle and the assets. By default is just 'debug'. + // If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants. + // debuggableVariants = ["liteDebug", "prodDebug"] + + /* Bundling */ + // A list containing the node command and its flags. Default is just 'node'. + // nodeExecutableAndArgs = ["node"] + + // + // The path to the CLI configuration file. Default is empty. + // bundleConfig = file(../rn-cli.config.js) + // + // The name of the generated asset file containing your JS bundle + // bundleAssetName = "MyApplication.android.bundle" + // + // The entry file for bundle generation. Default is 'index.android.js' or 'index.js' + // entryFile = file("../js/MyApplication.android.js") + // + // A list of extra flags to pass to the 'bundle' commands. + // See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle + // extraPackagerArgs = [] + + /* Hermes Commands */ + // The hermes compiler command to run. By default it is 'hermesc' + // hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc" + // + // The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map" + // hermesFlags = ["-O", "-output-source-map"] + + /* Autolinking */ + autolinkLibrariesWithApp() +} + +/** + * Set this to true to Run Proguard on Release builds to minify the Java bytecode. + */ +def enableProguardInReleaseBuilds = (findProperty('android.enableProguardInReleaseBuilds') ?: false).toBoolean() + +/** + * The preferred build flavor of JavaScriptCore (JSC) + * + * For example, to use the international variant, you can use: + * `def jscFlavor = 'org.webkit:android-jsc-intl:+'` + * + * The international variant includes ICU i18n library and necessary data + * allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that + * give correct results when using with locales other than en-US. Note that + * this variant is about 6MiB larger per architecture than default. + */ +def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+' + +android { + ndkVersion rootProject.ext.ndkVersion + + buildToolsVersion rootProject.ext.buildToolsVersion + compileSdk rootProject.ext.compileSdkVersion + + namespace 'com.tanstack.shoppinglist' + defaultConfig { + applicationId 'com.tanstack.shoppinglist' + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + versionCode 1 + versionName "1.0.0" + } + signingConfigs { + debug { + storeFile file('debug.keystore') + storePassword 'android' + keyAlias 'androiddebugkey' + keyPassword 'android' + } + } + buildTypes { + debug { + signingConfig signingConfigs.debug + } + release { + // Caution! In production, you need to generate your own keystore file. + // see https://reactnative.dev/docs/signed-apk-android. + signingConfig signingConfigs.debug + shrinkResources (findProperty('android.enableShrinkResourcesInReleaseBuilds')?.toBoolean() ?: false) + minifyEnabled enableProguardInReleaseBuilds + proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" + crunchPngs (findProperty('android.enablePngCrunchInReleaseBuilds')?.toBoolean() ?: true) + } + } + packagingOptions { + jniLibs { + useLegacyPackaging (findProperty('expo.useLegacyPackaging')?.toBoolean() ?: false) + } + } + androidResources { + ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~' + } +} + +// Apply static values from `gradle.properties` to the `android.packagingOptions` +// Accepts values in comma delimited lists, example: +// android.packagingOptions.pickFirsts=/LICENSE,**/picasa.ini +["pickFirsts", "excludes", "merges", "doNotStrip"].each { prop -> + // Split option: 'foo,bar' -> ['foo', 'bar'] + def options = (findProperty("android.packagingOptions.$prop") ?: "").split(","); + // Trim all elements in place. + for (i in 0.. 0) { + println "android.packagingOptions.$prop += $options ($options.length)" + // Ex: android.packagingOptions.pickFirsts += '**/SCCS/**' + options.each { + android.packagingOptions[prop] += it + } + } +} + +dependencies { + // The version of react-native is set by the React Native Gradle Plugin + implementation("com.facebook.react:react-android") + + def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true"; + def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true"; + def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true"; + + if (isGifEnabled) { + // For animated gif support + implementation("com.facebook.fresco:animated-gif:${expoLibs.versions.fresco.get()}") + } + + if (isWebpEnabled) { + // For webp support + implementation("com.facebook.fresco:webpsupport:${expoLibs.versions.fresco.get()}") + if (isWebpAnimatedEnabled) { + // Animated webp support + implementation("com.facebook.fresco:animated-webp:${expoLibs.versions.fresco.get()}") + } + } + + if (hermesEnabled.toBoolean()) { + implementation("com.facebook.react:hermes-android") + } else { + implementation jscFlavor + } +} diff --git a/examples/react-native/shopping-list/android/app/debug.keystore b/examples/react-native/shopping-list/android/app/debug.keystore new file mode 100644 index 000000000..364e105ed Binary files /dev/null and b/examples/react-native/shopping-list/android/app/debug.keystore differ diff --git a/examples/react-native/shopping-list/android/app/proguard-rules.pro b/examples/react-native/shopping-list/android/app/proguard-rules.pro new file mode 100644 index 000000000..551eb41da --- /dev/null +++ b/examples/react-native/shopping-list/android/app/proguard-rules.pro @@ -0,0 +1,14 @@ +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the proguardFiles +# directive in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# react-native-reanimated +-keep class com.swmansion.reanimated.** { *; } +-keep class com.facebook.react.turbomodule.** { *; } + +# Add any project specific keep options here: diff --git a/examples/react-native/shopping-list/android/app/src/debug/AndroidManifest.xml b/examples/react-native/shopping-list/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 000000000..3ec2507ba --- /dev/null +++ b/examples/react-native/shopping-list/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/examples/react-native/shopping-list/android/app/src/main/AndroidManifest.xml b/examples/react-native/shopping-list/android/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..6c82be9f0 --- /dev/null +++ b/examples/react-native/shopping-list/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/react-native/shopping-list/android/app/src/main/java/com/tanstack/shoppinglist/MainActivity.kt b/examples/react-native/shopping-list/android/app/src/main/java/com/tanstack/shoppinglist/MainActivity.kt new file mode 100644 index 000000000..c2a82a315 --- /dev/null +++ b/examples/react-native/shopping-list/android/app/src/main/java/com/tanstack/shoppinglist/MainActivity.kt @@ -0,0 +1,61 @@ +package com.tanstack.shoppinglist + +import android.os.Build +import android.os.Bundle + +import com.facebook.react.ReactActivity +import com.facebook.react.ReactActivityDelegate +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled +import com.facebook.react.defaults.DefaultReactActivityDelegate + +import expo.modules.ReactActivityDelegateWrapper + +class MainActivity : ReactActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + // Set the theme to AppTheme BEFORE onCreate to support + // coloring the background, status bar, and navigation bar. + // This is required for expo-splash-screen. + setTheme(R.style.AppTheme); + super.onCreate(null) + } + + /** + * Returns the name of the main component registered from JavaScript. This is used to schedule + * rendering of the component. + */ + override fun getMainComponentName(): String = "main" + + /** + * Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate] + * which allows you to enable New Architecture with a single boolean flags [fabricEnabled] + */ + override fun createReactActivityDelegate(): ReactActivityDelegate { + return ReactActivityDelegateWrapper( + this, + BuildConfig.IS_NEW_ARCHITECTURE_ENABLED, + object : DefaultReactActivityDelegate( + this, + mainComponentName, + fabricEnabled + ){}) + } + + /** + * Align the back button behavior with Android S + * where moving root activities to background instead of finishing activities. + * @see onBackPressed + */ + override fun invokeDefaultOnBackPressed() { + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) { + if (!moveTaskToBack(false)) { + // For non-root activities, use the default implementation to finish them. + super.invokeDefaultOnBackPressed() + } + return + } + + // Use the default back button implementation on Android S + // because it's doing more than [Activity.moveTaskToBack] in fact. + super.invokeDefaultOnBackPressed() + } +} diff --git a/examples/react-native/shopping-list/android/app/src/main/java/com/tanstack/shoppinglist/MainApplication.kt b/examples/react-native/shopping-list/android/app/src/main/java/com/tanstack/shoppinglist/MainApplication.kt new file mode 100644 index 000000000..a47d1cbd9 --- /dev/null +++ b/examples/react-native/shopping-list/android/app/src/main/java/com/tanstack/shoppinglist/MainApplication.kt @@ -0,0 +1,57 @@ +package com.tanstack.shoppinglist + +import android.app.Application +import android.content.res.Configuration + +import com.facebook.react.PackageList +import com.facebook.react.ReactApplication +import com.facebook.react.ReactNativeHost +import com.facebook.react.ReactPackage +import com.facebook.react.ReactHost +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load +import com.facebook.react.defaults.DefaultReactNativeHost +import com.facebook.react.soloader.OpenSourceMergedSoMapping +import com.facebook.soloader.SoLoader + +import expo.modules.ApplicationLifecycleDispatcher +import expo.modules.ReactNativeHostWrapper + +class MainApplication : Application(), ReactApplication { + + override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper( + this, + object : DefaultReactNativeHost(this) { + override fun getPackages(): List { + val packages = PackageList(this).packages + // Packages that cannot be autolinked yet can be added manually here, for example: + // packages.add(MyReactNativePackage()) + return packages + } + + override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry" + + override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG + + override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED + override val isHermesEnabled: Boolean = BuildConfig.IS_HERMES_ENABLED + } + ) + + override val reactHost: ReactHost + get() = ReactNativeHostWrapper.createReactHost(applicationContext, reactNativeHost) + + override fun onCreate() { + super.onCreate() + SoLoader.init(this, OpenSourceMergedSoMapping) + if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { + // If you opted-in for the New Architecture, we load the native entry point for this app. + load() + } + ApplicationLifecycleDispatcher.onApplicationCreate(this) + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig) + } +} diff --git a/examples/react-native/shopping-list/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png b/examples/react-native/shopping-list/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png new file mode 100644 index 000000000..31df827b1 Binary files /dev/null and b/examples/react-native/shopping-list/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png differ diff --git a/examples/react-native/shopping-list/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png b/examples/react-native/shopping-list/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png new file mode 100644 index 000000000..ef243aab6 Binary files /dev/null and b/examples/react-native/shopping-list/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png differ diff --git a/examples/react-native/shopping-list/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png b/examples/react-native/shopping-list/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png new file mode 100644 index 000000000..e9d547451 Binary files /dev/null and b/examples/react-native/shopping-list/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png differ diff --git a/examples/react-native/shopping-list/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png b/examples/react-native/shopping-list/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png new file mode 100644 index 000000000..d61da15d2 Binary files /dev/null and b/examples/react-native/shopping-list/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png differ diff --git a/examples/react-native/shopping-list/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png b/examples/react-native/shopping-list/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png new file mode 100644 index 000000000..4aeed11d0 Binary files /dev/null and b/examples/react-native/shopping-list/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png differ diff --git a/examples/react-native/shopping-list/android/app/src/main/res/drawable/ic_launcher_background.xml b/examples/react-native/shopping-list/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 000000000..883b2a080 --- /dev/null +++ b/examples/react-native/shopping-list/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/examples/react-native/shopping-list/android/app/src/main/res/drawable/rn_edit_text_material.xml b/examples/react-native/shopping-list/android/app/src/main/res/drawable/rn_edit_text_material.xml new file mode 100644 index 000000000..5c25e728e --- /dev/null +++ b/examples/react-native/shopping-list/android/app/src/main/res/drawable/rn_edit_text_material.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + diff --git a/examples/react-native/shopping-list/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/examples/react-native/shopping-list/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 000000000..a2f590828 Binary files /dev/null and b/examples/react-native/shopping-list/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/examples/react-native/shopping-list/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/examples/react-native/shopping-list/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 000000000..1b5239980 Binary files /dev/null and b/examples/react-native/shopping-list/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/examples/react-native/shopping-list/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/examples/react-native/shopping-list/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 000000000..ff10afd6e Binary files /dev/null and b/examples/react-native/shopping-list/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/examples/react-native/shopping-list/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/examples/react-native/shopping-list/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 000000000..115a4c768 Binary files /dev/null and b/examples/react-native/shopping-list/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/examples/react-native/shopping-list/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/examples/react-native/shopping-list/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 000000000..dcd3cd808 Binary files /dev/null and b/examples/react-native/shopping-list/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/examples/react-native/shopping-list/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/examples/react-native/shopping-list/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 000000000..459ca609d Binary files /dev/null and b/examples/react-native/shopping-list/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/examples/react-native/shopping-list/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/examples/react-native/shopping-list/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 000000000..8ca12fe02 Binary files /dev/null and b/examples/react-native/shopping-list/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/examples/react-native/shopping-list/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/examples/react-native/shopping-list/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 000000000..8e19b410a Binary files /dev/null and b/examples/react-native/shopping-list/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/examples/react-native/shopping-list/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/examples/react-native/shopping-list/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 000000000..b824ebdd4 Binary files /dev/null and b/examples/react-native/shopping-list/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/examples/react-native/shopping-list/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/examples/react-native/shopping-list/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 000000000..4c19a13c2 Binary files /dev/null and b/examples/react-native/shopping-list/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/examples/react-native/shopping-list/android/app/src/main/res/values-night/colors.xml b/examples/react-native/shopping-list/android/app/src/main/res/values-night/colors.xml new file mode 100644 index 000000000..3c05de5be --- /dev/null +++ b/examples/react-native/shopping-list/android/app/src/main/res/values-night/colors.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/react-native/shopping-list/android/app/src/main/res/values/colors.xml b/examples/react-native/shopping-list/android/app/src/main/res/values/colors.xml new file mode 100644 index 000000000..a89072772 --- /dev/null +++ b/examples/react-native/shopping-list/android/app/src/main/res/values/colors.xml @@ -0,0 +1,5 @@ + + #FFFFFF + #023c69 + #ffffff + \ No newline at end of file diff --git a/examples/react-native/shopping-list/android/app/src/main/res/values/strings.xml b/examples/react-native/shopping-list/android/app/src/main/res/values/strings.xml new file mode 100644 index 000000000..de4852f3c --- /dev/null +++ b/examples/react-native/shopping-list/android/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Shopping List Demo + \ No newline at end of file diff --git a/examples/react-native/shopping-list/android/app/src/main/res/values/styles.xml b/examples/react-native/shopping-list/android/app/src/main/res/values/styles.xml new file mode 100644 index 000000000..00ab510a5 --- /dev/null +++ b/examples/react-native/shopping-list/android/app/src/main/res/values/styles.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/examples/react-native/shopping-list/android/build.gradle b/examples/react-native/shopping-list/android/build.gradle new file mode 100644 index 000000000..fa7b11e23 --- /dev/null +++ b/examples/react-native/shopping-list/android/build.gradle @@ -0,0 +1,37 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath('com.android.tools.build:gradle') + classpath('com.facebook.react:react-native-gradle-plugin') + classpath('org.jetbrains.kotlin:kotlin-gradle-plugin') + } +} + +def reactNativeAndroidDir = new File( + providers.exec { + workingDir(rootDir) + commandLine("node", "--print", "require.resolve('react-native/package.json')") + }.standardOutput.asText.get().trim(), + "../android" +) + +allprojects { + repositories { + maven { + // All of React Native (JS, Obj-C sources, Android binaries) is installed from npm + url(reactNativeAndroidDir) + } + + google() + mavenCentral() + maven { url 'https://www.jitpack.io' } + } +} + +apply plugin: "expo-root-project" +apply plugin: "com.facebook.react.rootproject" diff --git a/examples/react-native/shopping-list/android/gradle.properties b/examples/react-native/shopping-list/android/gradle.properties new file mode 100644 index 000000000..9f8da2272 --- /dev/null +++ b/examples/react-native/shopping-list/android/gradle.properties @@ -0,0 +1,59 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m +org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true + +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true + +# Enable AAPT2 PNG crunching +android.enablePngCrunchInReleaseBuilds=true + +# Use this property to specify which architecture you want to build. +# You can also override it from the CLI using +# ./gradlew -PreactNativeArchitectures=x86_64 +reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64 + +# Use this property to enable support to the new architecture. +# This will allow you to use TurboModules and the Fabric render in +# your application. You should enable this flag either if you want +# to write custom TurboModules/Fabric components OR use libraries that +# are providing them. +newArchEnabled=true + +# Use this property to enable or disable the Hermes JS engine. +# If set to false, you will be using JSC instead. +hermesEnabled=true + +# Enable GIF support in React Native images (~200 B increase) +expo.gif.enabled=true +# Enable webp support in React Native images (~85 KB increase) +expo.webp.enabled=true +# Enable animated webp support (~3.4 MB increase) +# Disabled by default because iOS doesn't support animated webp +expo.webp.animated=false + +# Enable network inspector +EX_DEV_CLIENT_NETWORK_INSPECTOR=true + +# Use legacy packaging to compress native libraries in the resulting APK. +expo.useLegacyPackaging=false + +# Whether the app is configured to use edge-to-edge via the app config or `react-native-edge-to-edge` plugin +expo.edgeToEdgeEnabled=false \ No newline at end of file diff --git a/examples/react-native/shopping-list/android/gradle/wrapper/gradle-wrapper.jar b/examples/react-native/shopping-list/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..a4b76b953 Binary files /dev/null and b/examples/react-native/shopping-list/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/examples/react-native/shopping-list/android/gradle/wrapper/gradle-wrapper.properties b/examples/react-native/shopping-list/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..37f853b1c --- /dev/null +++ b/examples/react-native/shopping-list/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/examples/react-native/shopping-list/android/gradlew b/examples/react-native/shopping-list/android/gradlew new file mode 100755 index 000000000..f3b75f3b0 --- /dev/null +++ b/examples/react-native/shopping-list/android/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/examples/react-native/shopping-list/android/gradlew.bat b/examples/react-native/shopping-list/android/gradlew.bat new file mode 100644 index 000000000..9b42019c7 --- /dev/null +++ b/examples/react-native/shopping-list/android/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/examples/react-native/shopping-list/android/settings.gradle b/examples/react-native/shopping-list/android/settings.gradle new file mode 100644 index 000000000..b0afb5c58 --- /dev/null +++ b/examples/react-native/shopping-list/android/settings.gradle @@ -0,0 +1,39 @@ +pluginManagement { + def reactNativeGradlePlugin = new File( + providers.exec { + workingDir(rootDir) + commandLine("node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })") + }.standardOutput.asText.get().trim() + ).getParentFile().absolutePath + includeBuild(reactNativeGradlePlugin) + + def expoPluginsPath = new File( + providers.exec { + workingDir(rootDir) + commandLine("node", "--print", "require.resolve('expo-modules-autolinking/package.json', { paths: [require.resolve('expo/package.json')] })") + }.standardOutput.asText.get().trim(), + "../android/expo-gradle-plugin" + ).absolutePath + includeBuild(expoPluginsPath) +} + +plugins { + id("com.facebook.react.settings") + id("expo-autolinking-settings") +} + +extensions.configure(com.facebook.react.ReactSettingsExtension) { ex -> + if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') { + ex.autolinkLibrariesFromCommand() + } else { + ex.autolinkLibrariesFromCommand(expoAutolinking.rnConfigCommand) + } +} +expoAutolinking.useExpoModules() + +rootProject.name = 'Shopping List Demo' + +expoAutolinking.useExpoVersionCatalog() + +include ':app' +includeBuild(expoAutolinking.reactNativeGradlePlugin) diff --git a/examples/react-native/shopping-list/app.json b/examples/react-native/shopping-list/app.json new file mode 100644 index 000000000..234920786 --- /dev/null +++ b/examples/react-native/shopping-list/app.json @@ -0,0 +1,19 @@ +{ + "expo": { + "name": "Shopping List Demo", + "slug": "shopping-list-demo", + "version": "1.0.0", + "orientation": "portrait", + "userInterfaceStyle": "light", + "newArchEnabled": true, + "ios": { + "supportsTablet": true, + "bundleIdentifier": "com.tanstack.shoppinglist" + }, + "android": { + "package": "com.tanstack.shoppinglist" + }, + "scheme": "shopping-list", + "plugins": ["expo-router"] + } +} diff --git a/examples/react-native/shopping-list/app/_layout.tsx b/examples/react-native/shopping-list/app/_layout.tsx new file mode 100644 index 000000000..b1607dc7c --- /dev/null +++ b/examples/react-native/shopping-list/app/_layout.tsx @@ -0,0 +1,222 @@ +// Must be first import to polyfill crypto before anything else loads +import '../src/polyfills' + +import React, { useCallback, useState } from 'react' +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 { + Alert, + Modal, + Platform, + Pressable, + StyleSheet, + Text, + View, +} from 'react-native' +import { queryClient } from '../src/utils/queryClient' +import { ShoppingProvider, useShopping } from '../src/db/ShoppingContext' + +function HeaderControls({ onAppRefresh }: { onAppRefresh: () => void }) { + const { + isOnline, + isSimulatedOffline, + setSimulateOffline, + clearLocalState, + pendingCount, + } = useShopping() + const [menuVisible, setMenuVisible] = useState(false) + const [isClearingState, setIsClearingState] = useState(false) + + const closeMenu = useCallback(() => { + setMenuVisible(false) + }, []) + + const toggleSimulatedOffline = useCallback(() => { + setMenuVisible(false) + void setSimulateOffline(!isSimulatedOffline) + }, [isSimulatedOffline, setSimulateOffline]) + + const clearAndRefresh = useCallback(() => { + setMenuVisible(false) + // Delay the alert one tick so iOS can fully dismiss the modal first. + setTimeout(() => { + Alert.alert( + `Clear local state`, + `This clears local SQLite data and queued offline transactions, then refreshes the app.`, + [ + { text: `Cancel`, style: `cancel` }, + { + text: `Clear`, + style: `destructive`, + onPress: () => { + if (isClearingState) return + setIsClearingState(true) + void (async () => { + try { + await clearLocalState() + onAppRefresh() + } catch (error) { + console.error(`[Shopping] Failed to clear local state`, error) + Alert.alert( + `Clear failed`, + error instanceof Error ? error.message : `Unknown error`, + ) + } finally { + setIsClearingState(false) + } + })() + }, + }, + ], + ) + }, 0) + }, [clearLocalState, isClearingState, onAppRefresh]) + + const statusLabel = isOnline + ? `Online` + : isSimulatedOffline + ? `Offline (sim)` + : `Offline` + + return ( + + + + {statusLabel} + {pendingCount > 0 ? ` · ${pendingCount} pending` : ``} + + + setMenuVisible(true)} + style={{ + backgroundColor: `#e5e7eb`, + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 8, + }} + accessibilityLabel="Open demo menu" + > + + ☰ + + + + + + + + + + {isSimulatedOffline + ? `Disable simulated offline mode` + : `Enable simulated offline mode`} + + + + + + Clear local state + + + + + + + + ) +} + +export default function RootLayout() { + const [refreshKey, setRefreshKey] = useState(0) + const refreshApp = useCallback(() => { + queryClient.clear() + setRefreshKey((current) => current + 1) + }, []) + + return ( + + + + + , + }} + > + + + + + + + ) +} + +const styles = StyleSheet.create({ + modalRoot: { + flex: 1, + }, + backdrop: { + ...StyleSheet.absoluteFillObject, + backgroundColor: `rgba(0, 0, 0, 0.12)`, + }, + menuAnchor: { + flex: 1, + alignItems: `flex-end`, + paddingTop: 70, + paddingRight: 12, + }, + menuCard: { + width: 260, + backgroundColor: `#fff`, + borderRadius: 12, + overflow: `hidden`, + borderWidth: StyleSheet.hairlineWidth, + borderColor: `#d1d5db`, + }, + menuItem: { + paddingHorizontal: 14, + paddingVertical: 12, + }, + menuText: { + fontSize: 14, + color: `#111827`, + fontWeight: `500`, + }, + menuTextDanger: { + color: `#b91c1c`, + }, + menuDivider: { + height: StyleSheet.hairlineWidth, + backgroundColor: `#e5e7eb`, + }, +}) diff --git a/examples/react-native/shopping-list/app/index.tsx b/examples/react-native/shopping-list/app/index.tsx new file mode 100644 index 000000000..318918e91 --- /dev/null +++ b/examples/react-native/shopping-list/app/index.tsx @@ -0,0 +1,10 @@ +import { SafeAreaView } from 'react-native-safe-area-context' +import { ListsScreen } from '../src/components/ListsScreen' + +export default function HomeScreen() { + return ( + + + + ) +} diff --git a/examples/react-native/shopping-list/app/list/[id].tsx b/examples/react-native/shopping-list/app/list/[id].tsx new file mode 100644 index 000000000..5e9758905 --- /dev/null +++ b/examples/react-native/shopping-list/app/list/[id].tsx @@ -0,0 +1,30 @@ +import { useLocalSearchParams, Stack } from 'expo-router' +import { SafeAreaView } from 'react-native-safe-area-context' +import { useLiveQuery } from '@tanstack/react-db' +import { eq } from '@tanstack/react-db' +import { listsCollection } from '../../src/db/collections' +import { ListDetail } from '../../src/components/ListDetail' + +export default function ListScreen() { + const { id } = useLocalSearchParams<{ id: string }>() as { id: string } + + // Get the list name for the header + const listResult = useLiveQuery((q) => + q + .from({ list: listsCollection }) + .where(({ list }) => eq(list.id, id)) + .select(({ list }) => ({ id: list.id, name: list.name })), + ) + const list = (listResult.data ?? [])[0] as + | { id: string; name: string } + | undefined + + return ( + <> + + + + + + ) +} diff --git a/examples/react-native/shopping-list/babel.config.js b/examples/react-native/shopping-list/babel.config.js new file mode 100644 index 000000000..e1e3637af --- /dev/null +++ b/examples/react-native/shopping-list/babel.config.js @@ -0,0 +1,6 @@ +module.exports = function (api) { + api.cache(true) + return { + presets: ['babel-preset-expo'], + } +} diff --git a/examples/react-native/shopping-list/docker-compose.yml b/examples/react-native/shopping-list/docker-compose.yml new file mode 100644 index 000000000..4cf98b042 --- /dev/null +++ b/examples/react-native/shopping-list/docker-compose.yml @@ -0,0 +1,35 @@ +version: '3.8' +services: + postgres: + image: postgres:17-alpine + environment: + POSTGRES_DB: shopping_list + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - '54322:5432' + volumes: + - ./postgres.conf:/etc/postgresql/postgresql.conf:ro + tmpfs: + - /var/lib/postgresql/data + - /tmp + command: + - postgres + - -c + - config_file=/etc/postgresql/postgresql.conf + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U postgres'] + interval: 5s + timeout: 5s + retries: 5 + + electric: + image: electricsql/electric:canary + environment: + DATABASE_URL: postgresql://postgres:postgres@postgres:5432/shopping_list?sslmode=disable + ELECTRIC_INSECURE: true + ports: + - '3003:3000' + depends_on: + postgres: + condition: service_healthy diff --git a/examples/react-native/shopping-list/metro.config.js b/examples/react-native/shopping-list/metro.config.js new file mode 100644 index 000000000..18b7c545c --- /dev/null +++ b/examples/react-native/shopping-list/metro.config.js @@ -0,0 +1,80 @@ +const { getDefaultConfig } = require('expo/metro-config') +const path = require('path') + +const projectRoot = __dirname +const monorepoRoot = path.resolve(projectRoot, '../../..') + +const config = getDefaultConfig(projectRoot) + +// Watch all files in the monorepo +config.watchFolders = [monorepoRoot] + +// Ensure symlinks are followed (important for pnpm) +config.resolver.unstable_enableSymlinks = true +config.resolver.unstable_enablePackageExports = true + +const localNodeModules = path.resolve(projectRoot, 'node_modules') + +// 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(`${escMonorepoRoot}/node_modules/\\.pnpm/react-native@0\\.83.*`), +] + +// Let Metro know where to resolve packages from (local first, then root) +config.resolver.nodeModulesPaths = [ + localNodeModules, + 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 diff --git a/examples/react-native/shopping-list/package.json b/examples/react-native/shopping-list/package.json new file mode 100644 index 000000000..d8e49d63f --- /dev/null +++ b/examples/react-native/shopping-list/package.json @@ -0,0 +1,49 @@ +{ + "name": "shopping-list-react-native", + "version": "1.0.0", + "private": true, + "main": "expo-router/entry", + "scripts": { + "start": "expo start", + "ios": "expo run:ios", + "android": "expo run:android", + "reset-cache": "expo start --clear", + "server": "npx tsx server/index.ts", + "db:up": "docker compose up -d", + "db:down": "docker compose down" + }, + "dependencies": { + "@electric-sql/client": "^1.5.13", + "@expo/metro-runtime": "~5.0.5", + "@react-native-async-storage/async-storage": "2.1.2", + "@op-engineering/op-sqlite": "^15.2.5", + "@react-native-community/netinfo": "11.4.1", + "@tanstack/db": "workspace:*", + "@tanstack/db-react-native-sqlite-persisted-collection": "workspace:*", + "@tanstack/electric-db-collection": "workspace:*", + "@tanstack/offline-transactions": "^1.0.21", + "@tanstack/react-db": "^0.1.74", + "@tanstack/react-query": "^5.90.20", + "expo": "~53.0.26", + "expo-constants": "~17.1.0", + "expo-linking": "~7.1.0", + "expo-router": "~5.1.11", + "expo-status-bar": "~2.2.0", + "metro": "0.82.5", + "react": "19.0.0", + "react-native": "0.79.6", + "react-native-safe-area-context": "5.4.0", + "react-native-screens": "~4.11.1" + }, + "devDependencies": { + "@babel/core": "^7.29.0", + "@types/cors": "^2.8.19", + "@types/express": "^5.0.6", + "@types/react": "^19.2.13", + "cors": "^2.8.6", + "express": "^5.2.1", + "postgres": "^3.4.8", + "tsx": "^4.21.0", + "typescript": "^5.9.2" + } +} diff --git a/examples/react-native/shopping-list/postgres.conf b/examples/react-native/shopping-list/postgres.conf new file mode 100644 index 000000000..cafbb9b3c --- /dev/null +++ b/examples/react-native/shopping-list/postgres.conf @@ -0,0 +1,17 @@ +listen_addresses = '*' +max_connections = 100 +shared_buffers = 128MB +dynamic_shared_memory_type = posix +max_wal_size = 1GB +min_wal_size = 80MB +log_timezone = 'UTC' +datestyle = 'iso, mdy' +timezone = 'UTC' +lc_messages = 'en_US.utf8' +lc_monetary = 'en_US.utf8' +lc_numeric = 'en_US.utf8' +lc_time = 'en_US.utf8' +default_text_search_config = 'pg_catalog.english' +wal_level = logical +max_replication_slots = 10 +max_wal_senders = 10 diff --git a/examples/react-native/shopping-list/server/index.ts b/examples/react-native/shopping-list/server/index.ts new file mode 100644 index 000000000..fa247ebd4 --- /dev/null +++ b/examples/react-native/shopping-list/server/index.ts @@ -0,0 +1,525 @@ +import { Readable } from 'node:stream' +import { pipeline } from 'node:stream/promises' +import cors from 'cors' +import express from 'express' +import postgres from 'postgres' +import { ELECTRIC_PROTOCOL_QUERY_PARAMS } from '@electric-sql/client' + +const app = express() +const PORT = 3001 +const ELECTRIC_URL = process.env.ELECTRIC_URL ?? `http://localhost:3003` +const sql = postgres({ + host: `localhost`, + port: 54322, + user: `postgres`, + password: `postgres`, + database: `shopping_list`, +}) + +const HOP_BY_HOP_HEADERS = new Set([ + `connection`, + `content-length`, + `keep-alive`, + `proxy-authenticate`, + `proxy-authorization`, + `te`, + `trailer`, + `transfer-encoding`, + `upgrade`, + `host`, +]) + +app.use(cors()) +app.use(express.json()) + +interface ShoppingList { + id: string + name: string + createdAt: string +} + +interface ShoppingItem { + id: string + listId: string + text: string + checked: boolean + createdAt: string +} + +function asIso(value: unknown): string { + if (value instanceof Date) return value.toISOString() + return new Date(String(value)).toISOString() +} + +function toShoppingList(row: { + id: string + name: string + createdAt: unknown +}): ShoppingList { + return { + id: row.id, + name: row.name, + createdAt: asIso(row.createdAt), + } +} + +function toShoppingItem(row: { + id: string + listId: string + text: string + checked: boolean + createdAt: unknown +}): ShoppingItem { + return { + id: row.id, + listId: row.listId, + text: row.text, + checked: row.checked, + createdAt: asIso(row.createdAt), + } +} + +function buildElectricShapeUrl(requestUrl: string, table: string): URL { + const url = new URL(requestUrl) + const originUrl = new URL(`/v1/shape`, ELECTRIC_URL) + + url.searchParams.forEach((value, key) => { + if (ELECTRIC_PROTOCOL_QUERY_PARAMS.includes(key)) { + originUrl.searchParams.set(key, value) + } + }) + + originUrl.searchParams.set(`table`, table) + + if (process.env.ELECTRIC_SOURCE_ID) { + originUrl.searchParams.set(`source_id`, process.env.ELECTRIC_SOURCE_ID) + } + const sourceSecret = + process.env.ELECTRIC_SOURCE_SECRET ?? process.env.ELECTRIC_SECRET + if (sourceSecret) { + originUrl.searchParams.set(`secret`, sourceSecret) + } + + return originUrl +} + +function buildForwardHeaders(req: express.Request): Headers { + const headers = new Headers() + for (const [key, value] of Object.entries(req.headers)) { + const lowerKey = key.toLowerCase() + if (HOP_BY_HOP_HEADERS.has(lowerKey)) { + continue + } + if (value === undefined) { + continue + } + headers.set(key, Array.isArray(value) ? value.join(`,`) : value) + } + return headers +} + +async function proxyElectricShape( + req: express.Request, + res: express.Response, + table: string, +) { + const requestUrl = `${req.protocol}://${req.get(`host`)}${req.originalUrl}` + const originUrl = buildElectricShapeUrl(requestUrl, table) + const forwardHeaders = buildForwardHeaders(req) + const body = + req.method === `POST` && req.body !== undefined + ? JSON.stringify(req.body) + : undefined + + if (body && !forwardHeaders.has(`content-type`)) { + forwardHeaders.set(`content-type`, `application/json`) + } + + const response = await fetch(originUrl, { + method: req.method, + headers: forwardHeaders, + body, + }) + + response.headers.forEach((value, key) => { + const lower = key.toLowerCase() + if ( + lower === `content-encoding` || + lower === `content-length` || + lower === `transfer-encoding` + ) { + return + } + res.setHeader(key, value) + }) + const varyHeader = response.headers.get(`vary`) + res.setHeader( + `Vary`, + varyHeader ? `${varyHeader}, Authorization` : `Authorization`, + ) + res.status(response.status) + + if (!response.body) { + res.end() + return + } + + const nodeStream = Readable.fromWeb(response.body as any) + res.on(`close`, () => nodeStream.destroy()) + await pipeline(nodeStream, res) +} + +async function ensureDb() { + await sql` + CREATE TABLE IF NOT EXISTS shopping_lists ( + id text PRIMARY KEY, + name text NOT NULL, + "createdAt" timestamptz NOT NULL DEFAULT now() + ) + ` + + await sql` + CREATE TABLE IF NOT EXISTS shopping_items ( + id text PRIMARY KEY, + "listId" text NOT NULL REFERENCES shopping_lists(id) ON DELETE CASCADE, + text text NOT NULL, + checked boolean NOT NULL DEFAULT false, + "createdAt" timestamptz NOT NULL DEFAULT now() + ) + ` + + const [{ count }] = await sql>` + SELECT count(*)::text as count FROM shopping_lists + ` + if (Number.parseInt(count, 10) > 0) { + return + } + + await sql` + INSERT INTO shopping_lists (id, name) + VALUES + ('list-grocery', 'Grocery'), + ('list-hardware', 'Hardware Store') + ON CONFLICT (id) DO NOTHING + ` + + await sql` + INSERT INTO shopping_items (id, "listId", text, checked) + VALUES + ('item-milk', 'list-grocery', 'Milk', false), + ('item-eggs', 'list-grocery', 'Eggs', false), + ('item-bread', 'list-grocery', 'Bread', true), + ('item-screwdriver', 'list-hardware', 'Screwdriver', false), + ('item-nails', 'list-hardware', 'Nails', false) + ON CONFLICT (id) DO NOTHING + ` +} + +app.get('/api/shapes/lists', async (req, res) => { + try { + await proxyElectricShape(req, res, `shopping_lists`) + } catch (error) { + console.error(`Failed to proxy lists shape`, error) + if (!res.headersSent) { + res.status(502).json({ error: `Failed to proxy lists shape` }) + } + } +}) + +app.post('/api/shapes/lists', async (req, res) => { + try { + await proxyElectricShape(req, res, `shopping_lists`) + } catch (error) { + console.error(`Failed to proxy lists shape`, error) + if (!res.headersSent) { + res.status(502).json({ error: `Failed to proxy lists shape` }) + } + } +}) + +app.get('/api/shapes/items', async (req, res) => { + try { + await proxyElectricShape(req, res, `shopping_items`) + } catch (error) { + console.error(`Failed to proxy items shape`, error) + if (!res.headersSent) { + res.status(502).json({ error: `Failed to proxy items shape` }) + } + } +}) + +app.post('/api/shapes/items', async (req, res) => { + try { + await proxyElectricShape(req, res, `shopping_items`) + } catch (error) { + console.error(`Failed to proxy items shape`, error) + if (!res.headersSent) { + res.status(502).json({ error: `Failed to proxy items shape` }) + } + } +}) + +app.get('/api/lists', async (_req, res) => { + const rows = await sql< + Array<{ + id: string + name: string + createdAt: unknown + }> + >` + SELECT id, name, "createdAt" + FROM shopping_lists + ORDER BY "createdAt" DESC + ` + res.json(rows.map(toShoppingList)) +}) + +app.post('/api/lists', async (req, res) => { + const { id, name, createdAt } = req.body as { + id?: string + name?: string + createdAt?: string + } + if (!name?.trim()) { + return res.status(400).json({ error: `List name is required` }) + } + + const [inserted] = await sql< + Array<{ + txid: string + id: string + name: string + createdAt: unknown + }> + >` + WITH tx AS ( + SELECT pg_current_xact_id()::xid::text as txid + ), + inserted AS ( + INSERT INTO shopping_lists (id, name, "createdAt") + VALUES ( + ${id ?? crypto.randomUUID()}, + ${name.trim()}, + COALESCE(${createdAt ?? null}, now()) + ) + RETURNING id, name, "createdAt" + ) + SELECT tx.txid, inserted.id, inserted.name, inserted."createdAt" + FROM tx, inserted + ` + + return res.status(201).json({ + list: toShoppingList(inserted), + txid: Number.parseInt(inserted.txid, 10), + }) +}) + +app.put('/api/lists/:id', async (req, res) => { + const { id } = req.params + const { name } = req.body as { name?: string } + if (name !== undefined && !name.trim()) { + return res.status(400).json({ error: `List name cannot be empty` }) + } + + const updatedRows = await sql` + WITH tx AS ( + SELECT pg_current_xact_id()::xid::text as txid + ), + updated AS ( + UPDATE shopping_lists + SET name = COALESCE(${name?.trim() ?? null}, name) + WHERE id = ${id} + RETURNING id, name, "createdAt" + ) + SELECT tx.txid, updated.id, updated.name, updated."createdAt" + FROM tx, updated + ` + const updated = updatedRows[0] as + | { txid: string; id: string; name: string; createdAt: unknown } + | undefined + + if (!updated) { + return res.status(404).json({ error: `List not found` }) + } + + return res.json({ + list: toShoppingList(updated), + txid: Number.parseInt(updated.txid, 10), + }) +}) + +app.delete('/api/lists/:id', async (req, res) => { + const { id } = req.params + + const deletedRows = await sql` + WITH tx AS ( + SELECT pg_current_xact_id()::xid::text as txid + ), + deleted AS ( + DELETE FROM shopping_lists + WHERE id = ${id} + RETURNING id + ) + SELECT tx.txid, deleted.id + FROM tx, deleted + ` + const deleted = deletedRows[0] as { txid: string; id: string } | undefined + + if (!deleted) { + return res.status(404).json({ error: `List not found` }) + } + + return res.json({ success: true, txid: Number.parseInt(deleted.txid, 10) }) +}) + +app.get('/api/items', async (_req, res) => { + const rows = await sql< + Array<{ + id: string + listId: string + text: string + checked: boolean + createdAt: unknown + }> + >` + SELECT id, "listId", text, checked, "createdAt" + FROM shopping_items + ORDER BY "createdAt" ASC + ` + res.json(rows.map(toShoppingItem)) +}) + +app.post('/api/items', async (req, res) => { + const { id, listId, text, checked, createdAt } = req.body as { + id?: string + listId?: string + text?: string + checked?: boolean + createdAt?: string + } + + if (!listId || !text?.trim()) { + return res.status(400).json({ error: `listId and text are required` }) + } + + const [inserted] = await sql< + Array<{ + txid: string + id: string + listId: string + text: string + checked: boolean + createdAt: unknown + }> + >` + WITH tx AS ( + SELECT pg_current_xact_id()::xid::text as txid + ), + inserted AS ( + INSERT INTO shopping_items (id, "listId", text, checked, "createdAt") + VALUES ( + ${id ?? crypto.randomUUID()}, + ${listId}, + ${text.trim()}, + COALESCE(${checked ?? null}, false), + COALESCE(${createdAt ?? null}, now()) + ) + RETURNING id, "listId", text, checked, "createdAt" + ) + SELECT tx.txid, inserted.id, inserted."listId", inserted.text, inserted.checked, inserted."createdAt" + FROM tx, inserted + ` + + return res.status(201).json({ + item: toShoppingItem(inserted), + txid: Number.parseInt(inserted.txid, 10), + }) +}) + +app.put('/api/items/:id', async (req, res) => { + const { id } = req.params + const { text, checked } = req.body as { text?: string; checked?: boolean } + if (text !== undefined && !text.trim()) { + return res.status(400).json({ error: `Item text cannot be empty` }) + } + + const updatedRows = await sql` + WITH tx AS ( + SELECT pg_current_xact_id()::xid::text as txid + ), + updated AS ( + UPDATE shopping_items + SET + text = COALESCE(${text?.trim() ?? null}, text), + checked = COALESCE(${checked ?? null}, checked) + WHERE id = ${id} + RETURNING id, "listId", text, checked, "createdAt" + ) + SELECT tx.txid, updated.id, updated."listId", updated.text, updated.checked, updated."createdAt" + FROM tx, updated + ` + const updated = updatedRows[0] as + | { + txid: string + id: string + listId: string + text: string + checked: boolean + createdAt: unknown + } + | undefined + + if (!updated) { + return res.status(404).json({ error: `Item not found` }) + } + + return res.json({ + item: toShoppingItem(updated), + txid: Number.parseInt(updated.txid, 10), + }) +}) + +app.delete('/api/items/:id', async (req, res) => { + const { id } = req.params + + const deletedRows = await sql` + WITH tx AS ( + SELECT pg_current_xact_id()::xid::text as txid + ), + deleted AS ( + DELETE FROM shopping_items + WHERE id = ${id} + RETURNING id + ) + SELECT tx.txid, deleted.id + FROM tx, deleted + ` + const deleted = deletedRows[0] as { txid: string; id: string } | undefined + + if (!deleted) { + return res.status(404).json({ error: `Item not found` }) + } + + return res.json({ success: true, txid: Number.parseInt(deleted.txid, 10) }) +}) + +async function start() { + try { + await ensureDb() + + app.listen(PORT, '0.0.0.0', () => { + console.log(`Server running at http://0.0.0.0:${PORT}`) + console.log(`For Android emulator API: http://10.0.2.2:${PORT}`) + console.log(`For iOS simulator API: http://localhost:${PORT}`) + console.log(`Electric shape endpoint: http://localhost:3003/v1/shape`) + }) + } catch (error) { + console.error(`Failed to start shopping-list server`, error) + console.error( + `Did you run 'pnpm db:up' in examples/react-native/shopping-list?`, + ) + process.exit(1) + } +} + +void start() diff --git a/examples/react-native/shopping-list/src/components/ListDetail.tsx b/examples/react-native/shopping-list/src/components/ListDetail.tsx new file mode 100644 index 000000000..6bcd31788 --- /dev/null +++ b/examples/react-native/shopping-list/src/components/ListDetail.tsx @@ -0,0 +1,270 @@ +import React, { useEffect, useState } from 'react' +import { + FlatList, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native' +import { eq, useLiveQuery } from '@tanstack/react-db' +import { itemsCollection } from '../db/collections' +import { useShopping } from '../db/ShoppingContext' + +interface ListDetailProps { + listId: string +} + +type ListItemRow = { + id: string + listId: string + text: string + checked: boolean + createdAt: string + $synced?: boolean +} + +function ItemRow({ + item, + onToggle, + onDelete, +}: { + item: ListItemRow + onToggle: () => void + onDelete: () => void +}) { + const [showSavingBadge, setShowSavingBadge] = useState(false) + + useEffect(() => { + if (item.$synced !== false) { + setShowSavingBadge(false) + return + } + + setShowSavingBadge(false) + const timer = setTimeout(() => { + setShowSavingBadge(true) + }, 200) + return () => { + clearTimeout(timer) + } + }, [item.id, item.$synced]) + + return ( + + + {item.checked && } + + + {item.text} + + {showSavingBadge ? ( + + Saving + + ) : null} + + Delete + + + ) +} + +export function ListDetail({ listId }: ListDetailProps) { + const [newItemText, setNewItemText] = useState(``) + const { itemActions } = useShopping() + + // Get items for this list + const itemsResult = useLiveQuery((q) => + q + .from({ item: itemsCollection }) + .where(({ item }) => eq(item.listId, listId)) + .orderBy(({ item }) => item.createdAt, `asc`), + ) + const items = itemsResult.data as Array + + const handleAddItem = async () => { + if (!newItemText.trim() || !itemActions.addItem) return + await itemActions.addItem({ listId, text: newItemText }) + setNewItemText(``) + } + + const handleToggleItem = (id: string) => { + itemActions.toggleItem?.(id) + } + + const handleDeleteItem = (id: string) => { + itemActions.deleteItem?.(id) + } + + const checkedCount = items.filter((i) => i.checked).length + + return ( + + {/* Summary */} + + {checkedCount}/{items.length} items checked + + + {/* Add item input */} + + + + Add + + + + {/* Items */} + {items.length === 0 ? ( + + No items yet. Add one above! + + ) : ( + item.id} + style={styles.list} + renderItem={({ item }) => ( + handleToggleItem(item.id)} + onDelete={() => handleDeleteItem(item.id)} + /> + )} + /> + )} + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + padding: 16, + backgroundColor: `#f5f5f5`, + }, + centered: { + flex: 1, + justifyContent: `center`, + alignItems: `center`, + }, + summary: { + fontSize: 14, + color: `#666`, + marginBottom: 12, + }, + inputRow: { + flexDirection: `row`, + gap: 8, + marginBottom: 16, + }, + input: { + flex: 1, + backgroundColor: `#fff`, + borderWidth: 1, + borderColor: `#d1d5db`, + borderRadius: 8, + paddingHorizontal: 12, + paddingVertical: 10, + fontSize: 16, + }, + addButton: { + backgroundColor: `#3b82f6`, + paddingHorizontal: 20, + paddingVertical: 10, + borderRadius: 8, + justifyContent: `center`, + }, + addButtonDisabled: { + opacity: 0.5, + }, + addButtonText: { + color: `#fff`, + fontWeight: `600`, + fontSize: 16, + }, + emptyText: { + color: `#666`, + fontSize: 16, + }, + list: { + flex: 1, + }, + itemRow: { + flexDirection: `row`, + alignItems: `center`, + backgroundColor: `#fff`, + borderWidth: 1, + borderColor: `#e5e5e5`, + borderRadius: 8, + padding: 12, + marginBottom: 8, + gap: 12, + }, + checkbox: { + width: 24, + height: 24, + borderWidth: 2, + borderColor: `#d1d5db`, + borderRadius: 4, + justifyContent: `center`, + alignItems: `center`, + }, + checkboxChecked: { + backgroundColor: `#22c55e`, + borderColor: `#22c55e`, + }, + checkmark: { + color: `#fff`, + fontSize: 14, + fontWeight: `bold`, + }, + itemText: { + flex: 1, + fontSize: 16, + color: `#111`, + }, + itemTextChecked: { + textDecorationLine: `line-through`, + color: `#999`, + }, + deleteButton: { + paddingHorizontal: 12, + paddingVertical: 6, + }, + savingBadge: { + backgroundColor: `#fef3c7`, + borderRadius: 999, + paddingHorizontal: 8, + paddingVertical: 2, + }, + savingBadgeText: { + color: `#92400e`, + fontSize: 11, + fontWeight: `700`, + }, + deleteButtonText: { + color: `#dc2626`, + fontSize: 14, + }, +}) diff --git a/examples/react-native/shopping-list/src/components/ListsScreen.tsx b/examples/react-native/shopping-list/src/components/ListsScreen.tsx new file mode 100644 index 000000000..1a5b501c3 --- /dev/null +++ b/examples/react-native/shopping-list/src/components/ListsScreen.tsx @@ -0,0 +1,389 @@ +import React, { useEffect, useState } from 'react' +import { + ActivityIndicator, + Alert, + FlatList, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native' +import { useRouter } from 'expo-router' +import { count, eq, useLiveQuery } from '@tanstack/react-db' +import { itemsCollection, listsCollection } from '../db/collections' +import { useShopping } from '../db/ShoppingContext' + +// Subcomponent that subscribes to the child aggregate collections for reactive counts +function ListCard({ + list, + onPress, + onDelete, +}: { + list: { + id: string + name: string + totalItems: any + checkedItems: any + uncheckedPreview: any + $synced?: boolean + } + onPress: () => void + onDelete: () => void +}) { + // Subscribe to the child collections — this is the "includes" pattern for React + const { data: totalData } = useLiveQuery(list.totalItems) + const { data: checkedData } = useLiveQuery(list.checkedItems) + const { data: uncheckedPreviewData } = useLiveQuery(list.uncheckedPreview) + const totalCount = (totalData as any)?.[0]?.n ?? 0 + const checkedCount = (checkedData as any)?.[0]?.n ?? 0 + const uncheckedPreview = + (uncheckedPreviewData as any as Array<{ text: string }> | undefined) ?? [] + const uncheckedCount = Math.max(0, totalCount - checkedCount) + const remainingCount = Math.max(0, uncheckedCount - uncheckedPreview.length) + const previewText = uncheckedPreview + .map((item) => item.text.trim()) + .filter((text) => text.length > 0) + .join(`, `) + const [showSavingBadge, setShowSavingBadge] = useState(false) + + useEffect(() => { + if (list.$synced !== false) { + setShowSavingBadge(false) + return + } + + setShowSavingBadge(false) + const timer = setTimeout(() => { + setShowSavingBadge(true) + }, 200) + return () => { + clearTimeout(timer) + } + }, [list.id, list.$synced]) + + return ( + + + + {list.name} + {showSavingBadge ? ( + + Saving + + ) : null} + + + {checkedCount}/{totalCount} items + + {uncheckedCount > 0 && previewText.length > 0 ? ( + + {previewText} + {remainingCount > 0 ? ` and ${remainingCount} more` : ``} + + ) : totalCount > 0 ? ( + All items checked + ) : null} + + + Delete + + + ) +} + +export function ListsScreen() { + const router = useRouter() + const [newListName, setNewListName] = useState(``) + const { listActions, isInitialized, initError } = useShopping() + + // ★ Includes query with aggregate subqueries: each list gets child collections + // with computed counts. ListCard subscribes to them via useLiveQuery. + const queryResult = useLiveQuery((q) => + q + .from({ list: listsCollection }) + .select(({ list }) => ({ + id: list.id, + name: list.name, + createdAt: list.createdAt, + $synced: list.$synced, + totalItems: q + .from({ item: itemsCollection }) + .where(({ item }) => eq(item.listId, list.id)) + .select(({ item }) => ({ n: count(item.id) })), + uncheckedPreview: q + .from({ item: itemsCollection }) + .where(({ item }) => eq(item.listId, list.id)) + .where(({ item }) => eq(item.checked, false)) + .select(({ item }) => ({ + id: item.id, + text: item.text, + createdAt: item.createdAt, + })) + .orderBy(({ item }) => item.createdAt, `asc`) + .limit(3), + checkedItems: q + .from({ item: itemsCollection }) + .where(({ item }) => eq(item.listId, list.id)) + .where(({ item }) => eq(item.checked, true)) + .select(({ item }) => ({ n: count(item.id) })), + })) + .orderBy(({ list }) => list.createdAt, `desc`), + ) + const lists = queryResult.data as unknown as Array<{ + id: string + name: string + createdAt: string + $synced?: boolean + totalItems: any + uncheckedPreview: any + checkedItems: any + }> + + const handleAddList = async () => { + if (!newListName.trim() || !listActions.addList) return + await listActions.addList(newListName) + setNewListName(``) + } + + const handleDeleteList = (id: string, name: string) => { + Alert.alert(`Delete "${name}"?`, `This will also delete all items.`, [ + { text: `Cancel`, style: `cancel` }, + { + text: `Delete`, + style: `destructive`, + onPress: () => listActions.deleteList?.(id), + }, + ]) + } + + if (initError) { + return ( + + + {initError} + + + ) + } + + if (!isInitialized) { + return ( + + + Initializing... + + ) + } + + return ( + + {/* Add list input */} + + + + Add + + + + {/* Lists */} + {lists.length === 0 ? ( + + No lists yet + + ) : ( + item.id} + style={styles.list} + renderItem={({ item: list }) => ( + router.push(`/list/${list.id}`)} + onDelete={() => handleDeleteList(list.id, list.name)} + /> + )} + /> + )} + + {/* Instructions */} + + Features showcased: + + 1. Includes — item counts from nested child queries + + + 2. Electric sync — real-time replication via shape streams + + + 3. Offline transactions — works without network + + + 4. SQLite persistence — data survives app restart + + + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + padding: 16, + backgroundColor: `#f5f5f5`, + }, + centered: { + flex: 1, + justifyContent: `center`, + alignItems: `center`, + gap: 12, + }, + errorBox: { + backgroundColor: `#fee2e2`, + borderWidth: 1, + borderColor: `#fca5a5`, + borderRadius: 8, + padding: 12, + marginBottom: 16, + }, + errorText: { + color: `#dc2626`, + fontSize: 14, + }, + inputRow: { + flexDirection: `row`, + gap: 8, + marginBottom: 16, + }, + input: { + flex: 1, + backgroundColor: `#fff`, + borderWidth: 1, + borderColor: `#d1d5db`, + borderRadius: 8, + paddingHorizontal: 12, + paddingVertical: 10, + fontSize: 16, + }, + addButton: { + backgroundColor: `#3b82f6`, + paddingHorizontal: 20, + paddingVertical: 10, + borderRadius: 8, + justifyContent: `center`, + }, + addButtonDisabled: { + opacity: 0.5, + }, + addButtonText: { + color: `#fff`, + fontWeight: `600`, + fontSize: 16, + }, + loadingText: { + color: `#666`, + fontSize: 14, + }, + emptyText: { + color: `#666`, + fontSize: 16, + }, + list: { + flex: 1, + }, + listCard: { + flexDirection: `row`, + alignItems: `center`, + backgroundColor: `#fff`, + borderWidth: 1, + borderColor: `#e5e5e5`, + borderRadius: 12, + padding: 16, + marginBottom: 8, + }, + listContent: { + flex: 1, + }, + listHeaderRow: { + flexDirection: `row`, + alignItems: `center`, + gap: 8, + }, + listName: { + fontSize: 18, + fontWeight: `600`, + color: `#111`, + }, + savingBadge: { + backgroundColor: `#fef3c7`, + borderRadius: 999, + paddingHorizontal: 8, + paddingVertical: 2, + }, + savingBadgeText: { + color: `#92400e`, + fontSize: 11, + fontWeight: `700`, + }, + listCount: { + fontSize: 14, + color: `#666`, + marginTop: 4, + }, + previewText: { + marginTop: 4, + color: `#374151`, + fontSize: 13, + }, + allDoneText: { + marginTop: 4, + color: `#15803d`, + fontSize: 12, + fontWeight: `600`, + }, + deleteButton: { + paddingHorizontal: 12, + paddingVertical: 6, + }, + deleteButtonText: { + color: `#dc2626`, + fontSize: 14, + }, + instructions: { + backgroundColor: `#f0f0f0`, + borderRadius: 8, + padding: 16, + marginTop: 16, + }, + instructionsTitle: { + fontWeight: `600`, + color: `#111`, + marginBottom: 8, + }, + instructionsText: { + color: `#666`, + fontSize: 13, + marginBottom: 2, + }, +}) diff --git a/examples/react-native/shopping-list/src/db/AsyncStorageAdapter.ts b/examples/react-native/shopping-list/src/db/AsyncStorageAdapter.ts new file mode 100644 index 000000000..eac19d075 --- /dev/null +++ b/examples/react-native/shopping-list/src/db/AsyncStorageAdapter.ts @@ -0,0 +1,41 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import type { StorageAdapter } from '@tanstack/offline-transactions' + +export class AsyncStorageAdapter implements StorageAdapter { + private prefix: string + + constructor(prefix = `offline-tx:`) { + this.prefix = prefix + } + + private getKey(key: string): string { + return `${this.prefix}${key}` + } + + async get(key: string): Promise { + return AsyncStorage.getItem(this.getKey(key)) + } + + async set(key: string, value: string): Promise { + await AsyncStorage.setItem(this.getKey(key), value) + } + + async delete(key: string): Promise { + await AsyncStorage.removeItem(this.getKey(key)) + } + + async keys(): Promise> { + const allKeys = await AsyncStorage.getAllKeys() + return allKeys + .filter((k) => k.startsWith(this.prefix)) + .map((k) => k.slice(this.prefix.length)) + } + + async clear(): Promise { + const keys = await this.keys() + const prefixedKeys = keys.map((k) => this.getKey(k)) + if (prefixedKeys.length > 0) { + await AsyncStorage.multiRemove(prefixedKeys) + } + } +} diff --git a/examples/react-native/shopping-list/src/db/ShoppingContext.tsx b/examples/react-native/shopping-list/src/db/ShoppingContext.tsx new file mode 100644 index 000000000..b4ca8d4ac --- /dev/null +++ b/examples/react-native/shopping-list/src/db/ShoppingContext.tsx @@ -0,0 +1,148 @@ +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react' +import NetInfo from '@react-native-community/netinfo' +import { + hydrateSimulatedOffline, + isSimulatedOffline, + setSimulatedOffline, + subscribeSimulatedOffline, +} from '../network/simulatedOffline' +import { + clearLocalState as clearCollectionsLocalState, + createItemActions, + createListActions, + createOfflineExecutor, +} from './collections' + +type OfflineExecutor = ReturnType + +interface ShoppingContextValue { + offline: OfflineExecutor | null + listActions: ReturnType + itemActions: ReturnType + isNetworkOnline: boolean + isSimulatedOffline: boolean + isOnline: boolean + setSimulateOffline: (enabled: boolean) => Promise + clearLocalState: () => Promise + pendingCount: number + isInitialized: boolean + initError: string | null +} + +const ShoppingContext = createContext(null) + +export function ShoppingProvider({ children }: { children: React.ReactNode }) { + const [offline, setOffline] = useState(null) + const [isNetworkOnline, setIsNetworkOnline] = useState(true) + const [isSimulatedOfflineState, setIsSimulatedOfflineState] = + useState(isSimulatedOffline()) + const [pendingCount, setPendingCount] = useState(0) + const [isInitialized, setIsInitialized] = useState(false) + const [initError, setInitError] = useState(null) + + // Initialize offline executor + useEffect(() => { + try { + const executor = createOfflineExecutor() + setOffline(executor) + setIsInitialized(true) + return () => { + executor.dispose() + } + } catch (err) { + console.error(`[Shopping] Failed to create executor:`, err) + setInitError(err instanceof Error ? err.message : `Failed to initialize`) + setIsInitialized(true) + } + }, []) + + // Monitor network status (for UI display only — + // ReactNativeOnlineDetector in the executor handles retry automatically) + useEffect(() => { + void hydrateSimulatedOffline().catch((err) => { + console.warn(`[Shopping] Failed to hydrate simulated offline state`, err) + }) + return subscribeSimulatedOffline(() => { + setIsSimulatedOfflineState(isSimulatedOffline()) + }) + }, []) + + useEffect(() => { + const unsubscribe = NetInfo.addEventListener((state) => { + const connected = + state.isConnected === true && state.isInternetReachable !== false + setIsNetworkOnline(connected) + }) + return () => unsubscribe() + }, []) + + // Monitor pending transactions + useEffect(() => { + if (!offline) return + const interval = setInterval(() => { + setPendingCount(offline.getPendingCount()) + }, 100) + return () => clearInterval(interval) + }, [offline]) + + const listActions = useMemo(() => createListActions(offline), [offline]) + const itemActions = useMemo(() => createItemActions(offline), [offline]) + const setSimulateOffline = useCallback((enabled: boolean) => { + return setSimulatedOffline(enabled) + }, []) + const clearLocalState = useCallback(async () => { + await setSimulatedOffline(false) + await clearCollectionsLocalState(offline) + }, [offline]) + const isOnline = isNetworkOnline && !isSimulatedOfflineState + + const value = useMemo( + () => ({ + offline, + listActions, + itemActions, + isNetworkOnline, + isSimulatedOffline: isSimulatedOfflineState, + isOnline, + setSimulateOffline, + clearLocalState, + pendingCount, + isInitialized, + initError, + }), + [ + offline, + listActions, + itemActions, + isNetworkOnline, + isSimulatedOfflineState, + isOnline, + setSimulateOffline, + clearLocalState, + pendingCount, + isInitialized, + initError, + ], + ) + + return ( + + {children} + + ) +} + +export function useShopping(): ShoppingContextValue { + const ctx = useContext(ShoppingContext) + if (!ctx) { + throw new Error(`useShopping must be used within ShoppingProvider`) + } + return ctx +} diff --git a/examples/react-native/shopping-list/src/db/collections.ts b/examples/react-native/shopping-list/src/db/collections.ts new file mode 100644 index 000000000..5ae0ca49f --- /dev/null +++ b/examples/react-native/shopping-list/src/db/collections.ts @@ -0,0 +1,377 @@ +import { open } from '@op-engineering/op-sqlite' +import { createCollection } from '@tanstack/react-db' +import { electricCollectionOptions } from '@tanstack/electric-db-collection' +import { + createReactNativeSQLitePersistence, + persistedCollectionOptions, +} from '@tanstack/db-react-native-sqlite-persisted-collection' +import { startOfflineExecutor } from '@tanstack/offline-transactions/react-native' +import { API_URL, itemsApi, listsApi } from '../utils/api' +import { createOfflineAwareFetch } from '../network/simulatedOffline' +import { simulatedOnlineDetector } from '../network/SimulatedOnlineDetector' +import { AsyncStorageAdapter } from './AsyncStorageAdapter' +import type { PendingMutation } from '@tanstack/db' +import type { OpSQLiteDatabaseLike } from '@tanstack/db-react-native-sqlite-persisted-collection' +import type { ElectricCollectionUtils } from '@tanstack/electric-db-collection' + +export type ShoppingList = { + id: string + name: string + createdAt: string +} + +export type ShoppingItem = { + id: string + listId: string + text: string + checked: boolean + createdAt: string +} + +const database = open({ + name: `shopping-list.sqlite`, + location: `default`, +}) as unknown as OpSQLiteDatabaseLike + +const sharedPersistence = createReactNativeSQLitePersistence({ + database, +}) as any +const offlineStorage = new AsyncStorageAdapter(`shopping-offline:`) + +type SQLiteResultWithRows = { + rows?: { + _array?: Array> + length?: unknown + item?: unknown + } + resultRows?: Array> + results?: Array +} + +function getExecuteMethod(db: OpSQLiteDatabaseLike) { + return db.executeAsync ?? db.execute ?? db.executeRaw ?? db.execAsync +} + +function extractRows(result: unknown): Array> { + const fromRowsObject = ( + rows: SQLiteResultWithRows[`rows`], + ): Array> | null => { + if (!rows) return null + if (Array.isArray(rows._array)) { + return rows._array + } + if (typeof rows.length === `number` && typeof rows.item === `function`) { + const item = rows.item as (index: number) => unknown + const extracted: Array> = [] + for (let i = 0; i < rows.length; i++) { + const row = item(i) + if (row && typeof row === `object`) { + extracted.push(row as Record) + } + } + return extracted + } + return null + } + + if (Array.isArray(result)) { + if (result.length === 0) return [] + const first = result[0] as SQLiteResultWithRows + if (Array.isArray(first.resultRows)) { + return first.resultRows + } + const fromFirstRows = fromRowsObject(first.rows) + if (fromFirstRows) { + return fromFirstRows + } + if (Array.isArray(first.results) && first.results.length > 0) { + return extractRows(first.results[0]) + } + return result as Array> + } + const maybe = result as SQLiteResultWithRows + if (Array.isArray(maybe.resultRows)) { + return maybe.resultRows + } + const fromDirectRows = fromRowsObject(maybe.rows) + if (fromDirectRows) { + return fromDirectRows + } + if (Array.isArray(maybe.results) && maybe.results.length > 0) { + return extractRows(maybe.results[0]) + } + return [] +} + +async function executeSql( + sql: string, + params: ReadonlyArray = [], +): Promise { + const execute = getExecuteMethod(database) + if (!execute) { + throw new Error(`No execute method available for op-sqlite database`) + } + return Promise.resolve( + execute.call(database, sql, params.length ? params : undefined), + ) +} + +export async function clearLocalState( + offline: ReturnType | null, +): Promise { + if (offline) { + await offline.clearOutbox() + } else { + await offlineStorage.clear() + } + + const rows = extractRows( + await executeSql( + `SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`, + ), + ) + + await executeSql(`PRAGMA foreign_keys = OFF`) + try { + for (const row of rows) { + const tableName = row.name + if (typeof tableName === `string`) { + await executeSql( + `DROP TABLE IF EXISTS "${tableName.replace(/"/g, `""`)}"`, + ) + } + } + } finally { + await executeSql(`PRAGMA foreign_keys = ON`) + } +} + +export const listsCollection = createCollection( + persistedCollectionOptions< + ShoppingList, + string | number, + never, + ElectricCollectionUtils + >({ + ...electricCollectionOptions({ + id: `lists-collection`, + shapeOptions: { + url: `${API_URL}/api/shapes/lists`, + fetchClient: createOfflineAwareFetch(fetch), + onError: (error) => { + console.error(`[Electric] lists shape error`, error) + }, + }, + getKey: (item) => item.id, + }), + persistence: sharedPersistence, + schemaVersion: 1, + }), +) + +export const itemsCollection = createCollection( + persistedCollectionOptions< + ShoppingItem, + string | number, + never, + ElectricCollectionUtils + >({ + ...electricCollectionOptions({ + id: `items-collection`, + shapeOptions: { + url: `${API_URL}/api/shapes/items`, + fetchClient: createOfflineAwareFetch(fetch), + onError: (error) => { + console.error(`[Electric] items shape error`, error) + }, + }, + getKey: (item) => item.id, + }), + persistence: sharedPersistence, + schemaVersion: 1, + }), +) + +async function syncLists({ + transaction, +}: { + transaction: { mutations: Array } + idempotencyKey: string +}) { + for (const mutation of transaction.mutations) { + const data = mutation.modified as ShoppingList + switch (mutation.type) { + case `insert`: { + const created = await listsApi.create({ + id: data.id, + name: data.name, + createdAt: data.createdAt, + }) + await listsCollection.utils.awaitTxId(created.txid) + break + } + case `update`: { + const updated = await listsApi.update(data.id, { name: data.name }) + if (updated) { + await listsCollection.utils.awaitTxId(updated.txid) + } + break + } + case `delete`: { + const deleted = await listsApi.delete( + (mutation.original as ShoppingList).id, + ) + if (deleted) { + await listsCollection.utils.awaitTxId(deleted.txid) + } + break + } + } + } +} + +async function syncItems({ + transaction, +}: { + transaction: { mutations: Array } + idempotencyKey: string +}) { + for (const mutation of transaction.mutations) { + const data = mutation.modified as ShoppingItem + switch (mutation.type) { + case `insert`: { + const created = await itemsApi.create({ + id: data.id, + listId: data.listId, + text: data.text, + checked: data.checked, + createdAt: data.createdAt, + }) + await itemsCollection.utils.awaitTxId(created.txid) + break + } + case `update`: { + const updated = await itemsApi.update(data.id, { + text: data.text, + checked: data.checked, + }) + if (updated) { + await itemsCollection.utils.awaitTxId(updated.txid) + } + break + } + case `delete`: { + const deleted = await itemsApi.delete( + (mutation.original as ShoppingItem).id, + ) + if (deleted) { + await itemsCollection.utils.awaitTxId(deleted.txid) + } + break + } + } + } +} + +export function createOfflineExecutor() { + return startOfflineExecutor({ + collections: { + lists: listsCollection, + items: itemsCollection, + }, + storage: offlineStorage, + mutationFns: { + syncLists, + syncItems, + }, + onlineDetector: simulatedOnlineDetector, + onLeadershipChange: (isLeader) => { + console.log(`[Offline] Leadership changed:`, isLeader) + }, + onStorageFailure: (diagnostic) => { + console.warn(`[Offline] Storage failure:`, diagnostic) + }, + }) +} + +export function createListActions( + offline: ReturnType | null, +) { + if (!offline) { + return { addList: null, deleteList: null } + } + + const addList = offline.createOfflineAction({ + mutationFnName: `syncLists`, + onMutate: (name: string) => { + const newList: ShoppingList = { + id: crypto.randomUUID(), + name: name.trim(), + createdAt: new Date().toISOString(), + } + listsCollection.insert(newList) + return newList + }, + }) + + const deleteList = offline.createOfflineAction({ + mutationFnName: `syncLists`, + onMutate: (id: string) => { + const list = listsCollection.get(id) + if (list) { + listsCollection.delete(id) + } + return list + }, + }) + + return { addList, deleteList } +} + +export function createItemActions( + offline: ReturnType | null, +) { + if (!offline) { + return { addItem: null, toggleItem: null, deleteItem: null } + } + + const addItem = offline.createOfflineAction({ + mutationFnName: `syncItems`, + onMutate: ({ listId, text }: { listId: string; text: string }) => { + const newItem: ShoppingItem = { + id: crypto.randomUUID(), + listId, + text: text.trim(), + checked: false, + createdAt: new Date().toISOString(), + } + itemsCollection.insert(newItem) + return newItem + }, + }) + + const toggleItem = offline.createOfflineAction({ + mutationFnName: `syncItems`, + onMutate: (id: string) => { + const item = itemsCollection.get(id) as ShoppingItem | undefined + if (!item) return + itemsCollection.update(id, (draft) => { + draft.checked = !draft.checked + }) + return item + }, + }) + + const deleteItem = offline.createOfflineAction({ + mutationFnName: `syncItems`, + onMutate: (id: string) => { + const item = itemsCollection.get(id) + if (item) { + itemsCollection.delete(item.id) + } + return item + }, + }) + + return { addItem, toggleItem, deleteItem } +} diff --git a/examples/react-native/shopping-list/src/network/SimulatedOnlineDetector.ts b/examples/react-native/shopping-list/src/network/SimulatedOnlineDetector.ts new file mode 100644 index 000000000..ea5b5cb76 --- /dev/null +++ b/examples/react-native/shopping-list/src/network/SimulatedOnlineDetector.ts @@ -0,0 +1,34 @@ +import { ReactNativeOnlineDetector } from '@tanstack/offline-transactions/react-native' +import { + isSimulatedOffline, + subscribeSimulatedOffline, +} from './simulatedOffline' +import type { OnlineDetector } from '@tanstack/offline-transactions/react-native' + +class SimulatedOnlineDetector implements OnlineDetector { + private readonly baseDetector = new ReactNativeOnlineDetector() + + subscribe(callback: () => void): () => void { + const unsubscribeBase = this.baseDetector.subscribe(callback) + const unsubscribeSimulated = subscribeSimulatedOffline(callback) + return () => { + unsubscribeBase() + unsubscribeSimulated() + } + } + + isOnline(): boolean { + return this.baseDetector.isOnline() && !isSimulatedOffline() + } + + notifyOnline(): void { + this.baseDetector.notifyOnline() + } + + dispose(): void { + this.baseDetector.dispose() + } +} + +export const simulatedOnlineDetector: OnlineDetector = + new SimulatedOnlineDetector() diff --git a/examples/react-native/shopping-list/src/network/simulatedOffline.ts b/examples/react-native/shopping-list/src/network/simulatedOffline.ts new file mode 100644 index 000000000..9533dfe6d --- /dev/null +++ b/examples/react-native/shopping-list/src/network/simulatedOffline.ts @@ -0,0 +1,44 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' + +const STORAGE_KEY = `shopping-list:simulate-offline` + +let forcedOffline = false +const listeners = new Set<() => void>() + +function notifyListeners() { + for (const listener of listeners) { + listener() + } +} + +export function isSimulatedOffline(): boolean { + return forcedOffline +} + +export async function hydrateSimulatedOffline(): Promise { + const stored = await AsyncStorage.getItem(STORAGE_KEY) + forcedOffline = stored === `true` + notifyListeners() +} + +export async function setSimulatedOffline(value: boolean): Promise { + forcedOffline = value + await AsyncStorage.setItem(STORAGE_KEY, value ? `true` : `false`) + notifyListeners() +} + +export function subscribeSimulatedOffline(listener: () => void): () => void { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} + +export function createOfflineAwareFetch(baseFetch: typeof fetch): typeof fetch { + return async (input: RequestInfo | URL, init?: RequestInit) => { + if (isSimulatedOffline()) { + throw new TypeError(`Network request blocked by simulated offline mode`) + } + return baseFetch(input, init) + } +} diff --git a/examples/react-native/shopping-list/src/polyfills.ts b/examples/react-native/shopping-list/src/polyfills.ts new file mode 100644 index 000000000..1c00a87f9 --- /dev/null +++ b/examples/react-native/shopping-list/src/polyfills.ts @@ -0,0 +1,25 @@ +// Polyfill for crypto.randomUUID() which is not available in React Native Hermes +if (typeof global.crypto === 'undefined') { + global.crypto = {} as Crypto +} + +if (typeof global.crypto.randomUUID !== 'function') { + global.crypto.randomUUID = + function randomUUID(): `${string}-${string}-${string}-${string}-${string}` { + // Simple UUID v4 implementation + const hex = '0123456789abcdef' + let uuid = '' + for (let i = 0; i < 36; i++) { + if (i === 8 || i === 13 || i === 18 || i === 23) { + uuid += '-' + } else if (i === 14) { + uuid += '4' // Version 4 + } else if (i === 19) { + uuid += hex[(Math.random() * 4) | 8] // Variant bits + } else { + uuid += hex[(Math.random() * 16) | 0] + } + } + return uuid as `${string}-${string}-${string}-${string}-${string}` + } +} diff --git a/examples/react-native/shopping-list/src/utils/api.ts b/examples/react-native/shopping-list/src/utils/api.ts new file mode 100644 index 000000000..5cefb9bf0 --- /dev/null +++ b/examples/react-native/shopping-list/src/utils/api.ts @@ -0,0 +1,140 @@ +import { Platform } from 'react-native' +import { createOfflineAwareFetch } from '../network/simulatedOffline' + +const SERVER_PORT = 3001 +export const API_URL = Platform.select({ + android: `http://10.0.2.2:${SERVER_PORT}`, + ios: `http://localhost:${SERVER_PORT}`, + default: `http://localhost:${SERVER_PORT}`, +}) +const offlineAwareFetch = createOfflineAwareFetch(fetch) + +// ─── Types ────────────────────────────────────────────── + +export interface ShoppingList { + id: string + name: string + createdAt: string +} + +export interface ShoppingItem { + id: string + listId: string + text: string + checked: boolean + createdAt: string +} + +type ApiTxResult = { txid: number } & T + +// ─── Lists API ────────────────────────────────────────── + +export const listsApi = { + async getAll(): Promise> { + const response = await offlineAwareFetch(`${API_URL}/api/lists`) + if (!response.ok) { + throw new Error(`Failed to fetch lists: ${response.status}`) + } + return response.json() + }, + + async create(data: { + id?: string + name: string + createdAt?: string + }): Promise> { + const response = await offlineAwareFetch(`${API_URL}/api/lists`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) + if (!response.ok) { + throw new Error(`Failed to create list: ${response.status}`) + } + return response.json() + }, + + async update( + id: string, + data: { name?: string }, + ): Promise | null> { + const response = await offlineAwareFetch(`${API_URL}/api/lists/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) + if (response.status === 404) return null + if (!response.ok) { + throw new Error(`Failed to update list: ${response.status}`) + } + return response.json() + }, + + async delete(id: string): Promise | null> { + const response = await offlineAwareFetch(`${API_URL}/api/lists/${id}`, { + method: 'DELETE', + }) + if (response.status === 404) return null + if (!response.ok) { + throw new Error(`Failed to delete list: ${response.status}`) + } + return response.json() + }, +} + +// ─── Items API ────────────────────────────────────────── + +export const itemsApi = { + async getAll(): Promise> { + const response = await offlineAwareFetch(`${API_URL}/api/items`) + if (!response.ok) { + throw new Error(`Failed to fetch items: ${response.status}`) + } + return response.json() + }, + + async create(data: { + id?: string + listId: string + text: string + checked?: boolean + createdAt?: string + }): Promise> { + const response = await offlineAwareFetch(`${API_URL}/api/items`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) + if (!response.ok) { + throw new Error(`Failed to create item: ${response.status}`) + } + return response.json() + }, + + async update( + id: string, + data: { text?: string; checked?: boolean }, + ): Promise | null> { + const response = await offlineAwareFetch(`${API_URL}/api/items/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) + if (response.status === 404) return null + if (!response.ok) { + throw new Error(`Failed to update item: ${response.status}`) + } + return response.json() + }, + + async delete(id: string): Promise | null> { + const response = await offlineAwareFetch(`${API_URL}/api/items/${id}`, { + method: 'DELETE', + }) + if (response.status === 404) return null + if (!response.ok) { + throw new Error(`Failed to delete item: ${response.status}`) + } + return response.json() + }, +} diff --git a/examples/react-native/shopping-list/src/utils/queryClient.ts b/examples/react-native/shopping-list/src/utils/queryClient.ts new file mode 100644 index 000000000..55c589ceb --- /dev/null +++ b/examples/react-native/shopping-list/src/utils/queryClient.ts @@ -0,0 +1,10 @@ +import { QueryClient } from '@tanstack/react-query' + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60, // 1 minute + retry: 3, + }, + }, +}) diff --git a/examples/react-native/shopping-list/tsconfig.json b/examples/react-native/shopping-list/tsconfig.json new file mode 100644 index 000000000..52f606b02 --- /dev/null +++ b/examples/react-native/shopping-list/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true, + "paths": { + "~/*": ["./src/*"] + } + }, + "include": ["**/*.ts", "**/*.tsx"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b84a83ad1..fcacb08f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -363,6 +363,100 @@ importers: specifier: ^5.9.2 version: 5.9.3 + examples/react-native/shopping-list: + dependencies: + '@electric-sql/client': + specifier: ^1.5.13 + version: 1.5.13 + '@expo/metro-runtime': + specifier: ~5.0.5 + version: 5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.0.0)) + '@op-engineering/op-sqlite': + specifier: ^15.2.5 + version: 15.2.7(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.0.0))(react@19.0.0) + '@react-native-async-storage/async-storage': + specifier: 2.1.2 + version: 2.1.2(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.0.0)) + '@react-native-community/netinfo': + specifier: 11.4.1 + version: 11.4.1(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.0.0)) + '@tanstack/db': + specifier: workspace:* + version: link:../../../packages/db + '@tanstack/db-react-native-sqlite-persisted-collection': + specifier: workspace:* + version: link:../../../packages/db-react-native-sqlite-persisted-collection + '@tanstack/electric-db-collection': + specifier: workspace:* + version: link:../../../packages/electric-db-collection + '@tanstack/offline-transactions': + specifier: ^1.0.21 + version: link:../../../packages/offline-transactions + '@tanstack/react-db': + specifier: ^0.1.74 + version: link:../../../packages/react-db + '@tanstack/react-query': + specifier: ^5.90.20 + version: 5.90.21(react@19.0.0) + expo: + specifier: ~53.0.26 + version: 53.0.27(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.0.0)))(graphql@16.12.0)(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.0.0))(react@19.0.0) + expo-constants: + specifier: ~17.1.0 + version: 17.1.8(expo@53.0.27)(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.0.0)) + expo-linking: + specifier: ~7.1.0 + version: 7.1.7(expo@53.0.27)(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.0.0))(react@19.0.0) + expo-router: + specifier: ~5.1.11 + version: 5.1.11(@types/react@19.2.14)(expo-constants@17.1.8(expo@53.0.27)(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.0.0)))(expo-linking@7.1.7(expo@53.0.27)(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.0.0))(react@19.0.0))(expo@53.0.27)(react-native-safe-area-context@5.4.0(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.0.0))(react@19.0.0))(react-native-screens@4.11.1(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.0.0))(react@19.0.0) + expo-status-bar: + specifier: ~2.2.0 + version: 2.2.3(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.0.0))(react@19.0.0) + metro: + specifier: 0.82.5 + version: 0.82.5 + react: + specifier: 19.0.0 + version: 19.0.0 + react-native: + specifier: 0.79.6 + version: 0.79.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.0.0) + react-native-safe-area-context: + specifier: 5.4.0 + version: 5.4.0(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.0.0))(react@19.0.0) + react-native-screens: + specifier: ~4.11.1 + version: 4.11.1(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.0.0))(react@19.0.0) + devDependencies: + '@babel/core': + specifier: ^7.29.0 + version: 7.29.0 + '@types/cors': + specifier: ^2.8.19 + version: 2.8.19 + '@types/express': + specifier: ^5.0.6 + version: 5.0.6 + '@types/react': + specifier: ^19.2.13 + version: 19.2.14 + cors: + specifier: ^2.8.6 + version: 2.8.6 + express: + specifier: ^5.2.1 + version: 5.2.1 + postgres: + specifier: ^3.4.8 + version: 3.4.8 + tsx: + specifier: ^4.21.0 + version: 4.21.0 + typescript: + specifier: ^5.9.2 + version: 5.9.3 + examples/react/offline-transactions: dependencies: '@tanstack/db': @@ -761,7 +855,7 @@ importers: version: 0.45.1(@op-engineering/op-sqlite@15.2.7(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@12.8.0)(expo-sqlite@55.0.11(expo@55.0.8)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(gel@2.1.1)(kysely@0.28.11)(pg@8.20.0)(postgres@3.4.8)(sql.js@1.14.1) drizzle-zod: specifier: ^0.8.3 - version: 0.8.3(drizzle-orm@0.45.1(@op-engineering/op-sqlite@15.2.7(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@12.8.0)(expo-sqlite@55.0.11(expo@55.0.8)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(gel@2.1.1)(kysely@0.28.11)(pg@8.20.0)(postgres@3.4.8)(sql.js@1.14.1))(zod@4.3.6) + version: 0.8.3(drizzle-orm@0.45.1(@op-engineering/op-sqlite@15.2.7(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@12.8.0)(expo-sqlite@55.0.11(expo@55.0.8)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(gel@2.1.1)(kysely@0.28.11)(pg@8.20.0)(postgres@3.4.8)(sql.js@1.14.1))(zod@3.25.76) express: specifier: ^5.2.1 version: 5.2.1 @@ -19426,6 +19520,11 @@ snapshots: postgres: 3.4.8 sql.js: 1.14.1 + drizzle-zod@0.8.3(drizzle-orm@0.45.1(@op-engineering/op-sqlite@15.2.7(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@12.8.0)(expo-sqlite@55.0.11(expo@55.0.8)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(gel@2.1.1)(kysely@0.28.11)(pg@8.20.0)(postgres@3.4.8)(sql.js@1.14.1))(zod@3.25.76): + dependencies: + drizzle-orm: 0.45.1(@op-engineering/op-sqlite@15.2.7(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@12.8.0)(expo-sqlite@55.0.11(expo@55.0.8)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(gel@2.1.1)(kysely@0.28.11)(pg@8.20.0)(postgres@3.4.8)(sql.js@1.14.1) + zod: 3.25.76 + drizzle-zod@0.8.3(drizzle-orm@0.45.1(@op-engineering/op-sqlite@15.2.7(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@12.8.0)(expo-sqlite@55.0.11(expo@55.0.8)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(gel@2.1.1)(kysely@0.28.11)(pg@8.20.0)(postgres@3.4.8)(sql.js@1.14.1))(zod@4.3.6): dependencies: drizzle-orm: 0.45.1(@op-engineering/op-sqlite@15.2.7(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@12.8.0)(expo-sqlite@55.0.11(expo@55.0.8)(react-native@0.83.2(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(gel@2.1.1)(kysely@0.28.11)(pg@8.20.0)(postgres@3.4.8)(sql.js@1.14.1)